Published on

Scala 3 Option·Either 조합 실전 패턴 7

Authors

서버/배치/데이터 파이프라인을 Scala로 작성하다 보면 OptionEither를 동시에 쓰는 순간이 반드시 옵니다. 예를 들어 “값이 없을 수도 있음(Option)”과 “실패 이유를 보존해야 함(Either)”이 한 흐름에 섞이면 Option[Either[E, A]], Either[E, Option[A]], OptionT 같은 형태가 튀어나오고, 그때부터 코드 가독성과 에러 품질이 급격히 떨어집니다.

이 글은 Scala 3 표준 라이브러리만으로 Option·Either를 조합할 때 자주 쓰는 실전 패턴 7가지를 정리합니다. 목표는 다음 3가지입니다.

  • None을 “조용한 실패”로 두지 않고, 필요한 곳에서는 의미 있는 에러로 승격하기
  • Either를 남발해 중첩이 깊어지지 않게 하기
  • 검증/파싱/조회 흐름에서 조기 반환과 에러 누적을 깔끔하게 처리하기

참고로 “예외 없이 오류를 전파한다”는 철학은 C++의 std::expected 같은 흐름과도 닮아 있습니다. 관심 있다면 C++23 std expected로 예외 없이 오류전파+자원관리도 함께 보면 개념이 더 선명해집니다.


준비: 예제 도메인과 에러 타입

패턴을 통일감 있게 보이도록 간단한 도메인을 가정합니다.

  • 입력: HTTP 쿼리/헤더/환경변수 등 문자열 기반
  • 처리: 파싱, 검증, 저장소 조회
  • 결과: 성공이면 도메인 값, 실패면 사람이 읽을 수 있는 에러
enum AppError:
  case Missing(name: String)
  case Invalid(name: String, reason: String)
  case NotFound(entity: String, id: String)

final case class UserId(value: Long)
final case class User(id: UserId, name: String)

패턴 1) Option을 Either로 승격: toRight / toLeft

Option은 “없음”만 표현하고 이유가 없습니다. 반면 API 응답이나 로그에는 이유가 꼭 필요합니다. 이때 가장 먼저 쓰는 패턴이 toRight입니다.

def requireParam(params: Map[String, String], name: String): Either[AppError, String] =
  params.get(name).toRight(AppError.Missing(name))

반대로 “성공이 왼쪽”인 흐름이 필요하면 toLeft도 있습니다(드물지만 특정 fold에서 유용).

핵심은 Option을 오래 끌고 가지 말고 경계에서 Either로 바꾸라는 것입니다.


패턴 2) Either[E, Option[A]]로 ‘선택 기능’을 표현하기

Option[A]는 “없어도 정상”인 기능 토글/선택 필드에서 자주 등장합니다. 이때 실패는 실패대로 보존하고, 값은 있을 수도/없을 수도 있게 하려면 Either[E, Option[A]]가 적절합니다.

예: age 파라미터는 선택이지만, 들어오면 숫자여야 함.

import scala.util.Try

def parseLong(name: String, s: String): Either[AppError, Long] =
  Try(s.toLong).toEither.left.map(_ => AppError.Invalid(name, "not a long"))

// 선택 파라미터: 없으면 Right(None)
// 있으면 파싱해서 Right(Some(value)) 또는 Left(error)

def optionalLong(params: Map[String, String], name: String): Either[AppError, Option[Long]] =
  params.get(name) match
    case None => Right(None)
    case Some(v) => parseLong(name, v).map(Some(_))

Option[Either[E, A]]로 만들면 “없음”과 “실패”가 같은 레벨에서 섞여 읽기 어려워집니다. 선택은 값 쪽에, 실패는 Either 쪽에 두는 게 유지보수에 유리합니다.


패턴 3) Option[Either[E, A]]를 만나면 transpose로 뒤집기

이미 라이브러리/기존 코드 때문에 Option[Either[E, A]]가 생겼다면, 그대로 두지 말고 transpose로 형태를 바꾸는 게 좋습니다.

Scala 2.13+ / Scala 3에서 Option에는 transpose가 있습니다.

val oe: Option[Either[AppError, Int]] = Some(Right(42))
val eo: Either[AppError, Option[Int]] = oe.transpose
  • None이면 Right(None)
  • Some(Right(a))이면 Right(Some(a))
  • Some(Left(e))이면 Left(e)

즉, 패턴 2에서 권장한 형태로 자동 변환됩니다. 중첩을 줄이는 가장 값싼 리팩터링입니다.


패턴 4) Option 체인에서 “에러 메시지”를 잃지 않는 법: fold로 승격

Option만으로 처리하다가 마지막에 getOrElse로 기본값을 넣으면, 문제 상황이 조용히 지나가 버립니다. 대신 foldEither를 만들면 “없음”을 의미 있는 실패로 바꿀 수 있습니다.

예: 헤더에서 토큰을 꺼내고, 없으면 에러.

def requireHeader(headers: Map[String, String], name: String): Either[AppError, String] =
  headers.get(name).fold(Left(AppError.Missing(name)))(Right(_))

toRight가 더 짧지만, fold는 “없을 때 추가 로직(로그, 메트릭)”을 넣기 쉬워 실무에서 자주 씁니다.


패턴 5) Either 파이프라인에서 실패를 한 번에 다루기: leftMap / map / flatMap

Either는 “성공”과 “실패”가 명확하므로, 변환 흐름을 함수형 파이프라인으로 만들기 좋습니다.

예: userId 파라미터를 읽고 UserId로 만들기.

def parseUserId(params: Map[String, String]): Either[AppError, UserId] =
  for
    raw <- requireParam(params, "userId")
    n   <- parseLong("userId", raw)
    id  <- if n > 0 then Right(UserId(n))
           else Left(AppError.Invalid("userId", "must be positive"))
  yield id

여기서 포인트는 Option이 끼어들지 않도록 경계(입력 읽기)에서 Either로 통일했다는 점입니다.

추가로 leftMap은 실패 타입을 바꾸거나 메시지를 보강할 때 유용합니다.

val result: Either[AppError, Long] =
  parseLong("limit", "abc").left.map(e => AppError.Invalid("limit", "expected integer"))

패턴 6) “없으면 조회 생략, 있으면 조회”를 traverse로 표현하기

선택 값이 있을 때만 외부 호출/DB 조회를 하고 싶을 때, 흔히 match가 난무합니다. 이때 Option.traverse를 쓰면 깔끔합니다.

예: 추천인 코드(referrer)가 있으면 사용자 조회를 하고, 없으면 그대로 None.

def findUserByName(name: String): Either[AppError, User] =
  // 예시 구현
  if name == "alice" then Right(User(UserId(1), "alice"))
  else Left(AppError.NotFound("User", name))

import scala.language.implicitConversions

def resolveReferrer(params: Map[String, String]): Either[AppError, Option[User]] =
  val refOpt: Option[String] = params.get("referrer")
  refOpt.traverse(findUserByName)

traverse의 결과는 자동으로 Either[AppError, Option[User]]가 됩니다.

  • None이면 Right(None)
  • Some(x)이면 findUserByName(x) 결과를 Right(Some(user)) 또는 Left(error)로 변환

선택적 부수효과(조회/검증)를 표현할 때 매우 강력합니다.


패턴 7) 여러 필드 검증에서 “첫 에러만” vs “에러 누적”을 분리하기

Either는 기본적으로 첫 실패에서 멈추는 모델입니다. 폼 검증처럼 “모든 에러를 한 번에 보여주기”가 필요하면 Either만으로는 부족합니다.

표준 라이브러리만 쓴다는 조건에서 현실적인 타협은 두 가지입니다.

7-1) 첫 에러만 필요하면 for 컴프리헨션으로 충분

final case class CreateUser(name: String, age: Int)

def validateName(s: String): Either[AppError, String] =
  if s.trim.nonEmpty then Right(s.trim)
  else Left(AppError.Invalid("name", "blank"))

def validateAge(n: Int): Either[AppError, Int] =
  if n >= 0 then Right(n)
  else Left(AppError.Invalid("age", "must be >= 0"))

def buildCreateUser(params: Map[String, String]): Either[AppError, CreateUser] =
  for
    rawName <- requireParam(params, "name")
    name    <- validateName(rawName)
    rawAge  <- requireParam(params, "age")
    ageLong <- parseLong("age", rawAge)
    age     <- validateAge(ageLong.toInt)
  yield CreateUser(name, age)

7-2) 에러를 누적하려면 List[AppError]로 수집 후 마지막에 결정

Cats의 Validated가 떠오르지만, 여기서는 표준만 씁니다. 방법은 “검증을 Option[AppError]로 만들고 flatten으로 모은 뒤, 값 생성은 별도로”입니다.

def errIf(cond: Boolean, e: => AppError): Option[AppError] =
  if cond then Some(e) else None

def validateAll(params: Map[String, String]): Either[List[AppError], CreateUser] =
  val nameOpt = params.get("name")
  val ageOpt  = params.get("age")

  val errors: List[AppError] = List(
    errIf(nameOpt.isEmpty, AppError.Missing("name")),
    errIf(ageOpt.isEmpty, AppError.Missing("age")),
    nameOpt.flatMap(n => errIf(n.trim.isEmpty, AppError.Invalid("name", "blank"))),
    ageOpt.flatMap(a => errIf(a.toLongOption.isEmpty, AppError.Invalid("age", "not a number")))
  ).flatten

  if errors.nonEmpty then Left(errors)
  else
    // 안전: 위에서 존재/형식 검증 완료
    val name = nameOpt.get.trim
    val age  = ageOpt.get.toLong.toInt
    Right(CreateUser(name, age))

이 패턴은 “한 번에 모든 입력 오류를 반환해야 하는 API”에서 실무적으로 자주 쓰입니다. 단, 마지막 get은 반드시 앞에서 논리적으로 안전하다는 보장이 있어야 합니다.


마무리: 조합 규칙을 팀 컨벤션으로 고정하기

Option과 Either를 섞는 문제는 문법이 아니라 규칙의 부재에서 커집니다. 아래 3가지만 팀 컨벤션으로 고정해도 코드 품질이 안정됩니다.

  1. 입력 경계에서 Option을 오래 끌지 말고 toRightEither로 승격한다
  2. 선택 필드는 Either[E, Option[A]]를 기본 형태로 삼고, Option[Either[E, A]]transpose로 제거한다
  3. “첫 실패 중단”과 “에러 누적” 요구사항을 분리하고, 후자는 별도 수집 로직으로 명시한다

에러 전파를 명시적으로 설계해두면, 운영에서 장애 원인 추적도 훨씬 빨라집니다. 회귀 원인을 자동으로 좁히는 접근에 관심 있다면 git bisect run으로 회귀 커밋 10분 추적 자동화도 같이 읽어보면 좋습니다.