Scala in Practice
Option 체인, 함수 객체, 리스트 자르기, 가변성까지 코드에서 자주 만나는 Scala
- Haram Lee
- 2026-10-03
- studies / Topics
Scala for S-99가 문법 지도였다면, 이 글은 실제 코드를 읽다가 멈추게 되는 지점을 모은 것이다. Option을 길게 이어 붙인 체인, 함수인데 객체처럼 메서드를 가진 값, 리스트를 앞부분과 나머지로 자르는 함수, 그리고 [+A], [-A]가 붙은 타입이다. 재귀와 누산기는 Tail Recursion Optimization에서 다뤘으니 여기서는 반복하지 않는다.
예제는 Scala 3 문법으로 썼다.
1. Option 체인은 실패를 단계마다 삼킨다
설정 파일이나 요청 파라미터에서 문자열을 꺼내 숫자로 바꾸는 코드는 흔하다. 값이 없을 수도 있고, 빈 문자열일 수도 있고, 숫자가 아닐 수도 있다. 이 세 가지 실패를 if와 try로 하나씩 막는 대신 Option 체인 한 줄로 쓸 수 있다.
import scala.util.Try
def parsePort(raw: Option[String]): Option[Int] =
raw
.map(_.trim)
.filter(_.nonEmpty)
.flatMap(p => Try(p.toInt).toOption)각 단계가 무엇을 None으로 접는지 따로 보면 읽기 쉽다.
| 단계 | 입력이 Some("8080") | 입력이 Some(" ") | 입력이 Some("abc") | 입력이 None |
|---|---|---|---|---|
map(_.trim) | Some("8080") | Some("") | Some("abc") | None |
filter(_.nonEmpty) | Some("8080") | None | Some("abc") | None |
flatMap(Try(...).toOption) | Some(8080) | None | None | None |
map은 값이 있을 때만 함수를 적용한다.None이면 아무것도 하지 않고None을 넘긴다.filter는 조건을 만족하지 않는 값을None으로 바꾼다. 빈 문자열이 여기서 사라진다.Try(p.toInt)는NumberFormatException을 잡아Failure로 만들고,toOption은Failure를None으로 바꾼다. 결과가Option[Int]이므로map이 아니라flatMap을 써야Option[Option[Int]]로 겹치지 않는다.
한 번 None이 되면 이후 단계는 모두 건너뛴다. 그래서 체인의 마지막 결과만 보고는 어느 단계에서 실패했는지 알 수 없다. 실패 이유가 중요하다면 Option 대신 Either나 Try를 끝까지 들고 가야 한다. 반대로 “값이 없거나 이상하면 기본값을 쓴다"는 상황이라면 이 체인 뒤에 getOrElse(8080)만 붙이면 된다.
표준 라이브러리에는 이 패턴의 지름길도 있다. String.toIntOption은 Try(s.toInt).toOption과 같은 일을 예외 없이 한다.
raw.map(_.trim).filter(_.nonEmpty).flatMap(_.toIntOption)같은 체인을 for 표현식으로 쓸 수도 있다. for는 flatMap, map, withFilter 호출로 풀리는 문법이므로 두 코드는 같은 일을 한다.
for
s <- raw
t = s.trim
if t.nonEmpty
port <- t.toIntOption
yield port단계가 셋을 넘거나 중간 값에 이름을 붙이고 싶을 때는 for가 더 읽기 쉽다.
2. 함수는 apply를 가진 객체다
Scala에서 f(x)는 f.apply(x)의 줄임말이다. 그래서 apply 메서드를 정의한 객체는 함수처럼 호출할 수 있다.
object Square:
def apply(n: Int): Int = n * n
Square(4) // 16
Square.apply(4) // 16case class의 동반 객체에 컴파일러가 apply를 만들어 주기 때문에 new 없이 Point(1, 2)처럼 값을 만들 수 있다. List(1, 2, 3)도 List.apply(1, 2, 3)이다.
함수 값도 같은 원리다. Int => Int는 Function1[Int, Int]라는 trait의 줄임 표기이고, 이 trait에서 구현해야 하는 추상 메서드는 apply 하나뿐이다. 나머지 메서드는 apply만 있으면 만들 수 있으므로 trait 안에 이미 구현되어 있다. andThen, compose가 그렇고, 매개변수가 둘 이상인 Function2에는 curried와 tupled가 있다.
val add: (Int, Int) => Int = _ + _
val addCurried: Int => Int => Int = add.curried
val addTupled: ((Int, Int)) => Int = add.tupled
addCurried(1)(2) // 3
addTupled((1, 2)) // 3curried는 인자 둘을 한 번에 받는 함수를 “인자 하나를 받아 다시 함수를 돌려주는 함수"로 바꾼다. 일부 인자만 먼저 고정하고 싶을 때 쓴다.
val addTen = addCurried(10)
List(1, 2, 3).map(addTen) // List(11, 12, 13)tupled는 인자 둘을 튜플 하나로 받도록 바꾼다. 튜플 리스트를 다룰 때 유용하다.
val pairs = List((1, 2), (3, 4))
pairs.map(add.tupled) // List(3, 7)zip이나 Map의 원소처럼 튜플이 흘러 다니는 곳에서, 이미 있는 두 인자 함수를 고치지 않고 그대로 끼워 넣을 수 있다. Scala 3는 pairs.map((a, b) => a + b)처럼 람다를 직접 쓸 때는 튜플을 알아서 풀어 준다. 하지만 이미 만들어 둔 함수 값을 넘기는 pairs.map(add)는 (Int, Int) => Int와 ((Int, Int)) => Int가 다른 타입이라 컴파일되지 않는다. 이 차이를 tupled가 메운다.
여기서 메서드(def)와 함수 값이 어떻게 다른지도 짚고 넘어간다. def는 클래스나 객체에 속한 메서드이고 그 자체로는 값이 아니다. 함수 자리에 메서드를 넘기면 컴파일러가 apply를 가진 함수 객체로 감싸 준다(eta-expansion). add.curried처럼 함수의 메서드를 호출하려면 대상이 함수 값이어야 하므로, 메서드라면 먼저 값으로 바꿔야 한다.
def mul(a: Int, b: Int): Int = a * b
val mulFn = mul // (Int, Int) => Int 함수 값
mulFn.curried(3)(4) // 123. 리스트를 앞부분과 나머지로 자른다
“앞에서부터 조건이 맞는 데까지"를 떼어 내는 일은 생각보다 자주 나온다. 연속된 같은 값을 묶거나, 로그에서 헤더 줄만 떼어 내거나, 정렬된 데이터에서 기준값 이하만 고를 때다.
| 함수 | 결과 |
|---|---|
xs.takeWhile(p) | 앞에서부터 p를 만족하는 원소들 |
xs.dropWhile(p) | 앞에서부터 p를 만족하는 원소를 뺀 나머지 |
xs.span(p) | 위 둘을 한 번에 구한 (앞부분, 나머지) |
List(1, 1, 2, 1).span(_ == 1) // (List(1, 1), List(2, 1))
List(1, 1, 2, 1).filter(_ == 1) // List(1, 1, 1)span은 조건이 처음 깨지는 곳에서 멈춘다. 뒤쪽의 1은 앞부분에 들어가지 않는다. 리스트 전체에서 고르는 filter, 리스트를 둘로 나누는 partition과는 이 점이 다르다.
takeWhile과 dropWhile을 따로 부르면 앞부분을 두 번 훑지만 span은 한 번만 훑는다. 결과는 튜플이므로 바로 분해해서 받는다.
val (front, rest) = xs.span(_ == xs.head)“앞 덩어리를 떼고, 나머지로 같은 일을 반복한다"는 모양이 span과 잘 맞는다. 예를 들어 연속된 같은 값을 묶는 함수는 이렇게 쓸 수 있다.
def runs[A](xs: List[A]): List[List[A]] =
xs match
case Nil => Nil
case head :: _ =>
val (front, rest) = xs.span(_ == head)
front :: runs(rest)
runs(List("a", "a", "b", "c", "c", "c"))
// List(List(a, a), List(b), List(c, c, c))이 함수는 재귀 호출 뒤에 front ::가 남아 있으므로 꼬리 재귀가 아니다. 덩어리 수가 아주 많을 수 있다면 누산기에 앞으로 붙이고 마지막에 reverse하는 꼬리 재귀로 바꾼다.
비슷해 보이는 라이브러리 함수가 조건까지 같은지도 확인해야 한다. distinct는 연속하지 않은 중복까지 지우므로 “연속된 중복만 지운다"는 요구와 다르다. groupBy는 순서와 연속성을 버리고 같은 값을 한데 모은다.
4. 타입 패턴과 타입 소거
원소 타입이 제각각인 데이터, 예를 들어 JSON이나 BSON처럼 값이 숫자이기도 하고 중첩 문서이기도 한 데이터를 다루다 보면 List[Any]가 등장한다. Any는 무엇이든 담지만 꺼낼 때 무엇인지 확인해야 한다. 이때 타입 패턴을 쓴다.
def flattenAny(xs: List[Any]): List[Any] =
xs.flatMap {
case ys: List[?] => flattenAny(ys)
case other => List(other)
}
flattenAny(List(List(1, 1), 2, List(3, List(5, 8))))
// List(1, 1, 2, 3, 5, 8)패턴에 List[Int]가 아니라 List[?](Scala 2에서는 List[_])를 쓰는 이유는 타입 소거(type erasure) 때문이다. JVM은 실행 시점에 List[Int]와 List[String]을 구분하지 못한다. 리스트라는 사실까지만 알 수 있다. 그래서 case ys: List[Int]라고 쓰면 컴파일러가 “실행 시점에 확인할 수 없다"고 경고하고, 실제로는 List[String]도 그 case에 걸린다. 원소 타입을 모른다는 뜻으로 ?를 쓰는 것이 정직한 표현이다.
List[Any]는 무엇이 들어올지 타입이 말해 주지 않으므로, 가능하다면 구조를 타입으로 표현하는 편이 낫다.
enum Nested[A]:
case Leaf(value: A)
case Node(children: List[Nested[A]])
def flatten[A](n: Nested[A]): List[A] =
n match
case Nested.Leaf(v) => List(v)
case Nested.Node(cs) => cs.flatMap(flatten)이제 “원소이거나 다시 중첩 리스트"라는 사실이 타입에 드러나고, case를 빠뜨리면 컴파일러가 경고한다. 원소 타입 A도 보존된다.
5. 가변성: +A, -A, 그리고 표시 없음
제네릭 타입 F[A]가 있을 때, Cat이 Animal의 하위 타입이면 F[Cat]과 F[Animal]은 어떤 관계일까. 이 관계를 정하는 것이 가변성(variance)이다.
| 표기 | 이름 | Cat <: Animal일 때 |
|---|---|---|
F[+A] | 공변(covariant) | F[Cat] <: F[Animal] |
F[-A] | 반공변(contravariant) | F[Animal] <: F[Cat] |
F[A] | 무공변(invariant) | 아무 관계 없음 |
기준은 그 타입이 A를 내보내기만 하는지, 받아들이기만 하는지다.
class Animal(val name: String)
class Cat(name: String) extends Animal(name)
trait Producer[+A]:
def get: A
trait Consumer[-A]:
def accept(a: A): StringProducer[Cat]은 꺼낼 때마다 Cat을 주고, Cat은 Animal이므로 Producer[Animal]이 필요한 자리에 넣어도 안전하다. 그래서 공변이다. 반대로 Consumer[Animal]은 어떤 동물이든 받으니 Cat도 받을 수 있다. Consumer[Cat]이 필요한 자리에 Consumer[Animal]을 넣어도 안전하므로 반공변이다.
val catSource: Producer[Cat] = new Producer[Cat]:
def get: Cat = Cat("nabi")
val animalSource: Producer[Animal] = catSource // OK
val animalSink: Consumer[Animal] = new Consumer[Animal]:
def accept(a: Animal): String = s"got ${a.name}"
val catSink: Consumer[Cat] = animalSink // OK이 규칙은 앞에서 본 함수 타입에 그대로 들어 있다. 표준 라이브러리의 Function1은 Function1[-T, +R]로 선언되어 있다. 인자는 받기만 하므로 반공변, 결과는 내보내기만 하므로 공변이다.
val describe: Animal => String = _.name
val describeCat: Cat => Any = describe // OKAnimal => String은 어떤 동물이든 받아 문자열을 돌려준다. 고양이만 넘겨도 되고, 결과를 Any로만 받아도 된다. 그래서 Cat => Any 자리에 쓸 수 있다.
List[+A]와 Option[+A]가 공변인 이유도 같다. 불변 컬렉션이라 원소를 꺼내기만 하기 때문이다. 원소를 바꿔 넣을 수 있는 Array[A]나 mutable.ArrayBuffer[A]는 무공변이다. 만약 Array[Cat]을 Array[Animal]로 볼 수 있다면 그 배열에 Dog를 넣을 수 있게 되고, 원래 Array[Cat]을 쥐고 있던 코드는 고양이 자리에서 개를 꺼내게 된다. Java 배열은 실제로 공변이어서 이 문제를 런타임 예외(ArrayStoreException)로 막는다. Scala는 배열을 무공변으로 선언해 컴파일 시점에 막는다.
공변 타입 안에 A를 받는 메서드가 필요하면 하한 경계를 쓴다. List의 prepended가 이렇게 생겼다.
// List[+A] 안의 메서드
def prepended[B >: A](elem: B): List[B]List[Cat]에 Dog를 붙이면 결과가 List[Animal]처럼 공통 상위 타입으로 넓어진다. 원래 리스트를 바꾸지 않고 새 리스트를 만드니 안전하다.
6. fold에서 aggregate로
리스트를 훑으며 누산기를 갱신하는 foldLeft는 원소를 왼쪽부터 하나씩 차례로 처리한다는 전제가 있다. 그래서 누산 함수의 시그니처는 (B, A) => B 하나다.
List("a", "bb", "ccc").foldLeft(0)((acc, s) => acc + s.length) // 6데이터가 여러 조각으로 나뉘어 있고 조각마다 따로 접은 뒤 합쳐야 한다면 함수가 둘 필요하다. 조각 안에서 원소를 누산기에 넣는 함수와, 조각별 누산기끼리 합치는 함수다. Spark RDD의 aggregate가 이 모양이다.
// RDD[T]의 메서드
def aggregate[U: ClassTag](zeroValue: U)(seqOp: (U, T) => U, combOp: (U, U) => U): U조각이 어떤 순서로 끝날지 모르므로 combOp은 결합 법칙을 만족해야 한다. zeroValue는 조각마다 쓰이고 조각 결과를 합칠 때도 다시 쓰이므로 항등원이어야 한다. foldLeft에는 없던 조건이다. 같은 “접기"라도 순서를 보장받는지에 따라 시그니처와 조건이 달라진다. RDD 자체는 RDD 글에서 다뤘다.
정리
Option체인은 단계마다 실패를None으로 접는다. 실패 이유가 필요하면Either나Try를 유지한다.f(x)는f.apply(x)다. 함수 값은apply하나만 구현한 객체이고,curried,tupled,andThen은 그 위에 얹힌 메서드다.span은 조건이 처음 깨지는 곳에서 리스트를 둘로 자른다.filter,partition,distinct와 조건이 다르다.- 타입 패턴에는
List[?]를 쓴다. JVM은 원소 타입을 실행 시점에 모른다. - 내보내기만 하면
+A, 받기만 하면-A, 둘 다 하면 무공변이다.Function1[-T, +R]이 대표 예다.