Scala for S-99

99개 문제를 풀기 전에 훑어보는 Scala 3 문법과 ADT

  • Haram Lee
  • 2026-08-26
  • studies / Topics

이 글은 S-99의 답안집이 아니다. 문제를 읽다가 Scala 문법 때문에 멈추지 않도록, 자주 보게 될 언어의 모양과 그 뒤의 사고방식을 한 번에 훑는 지도에 가깝다.

특정 문제의 풀이 함수, 알고리즘 선택, 완성된 정답 코드는 의도적으로 싣지 않는다. 처음에는 위에서 아래로 가볍게 읽고, 실제로 코드를 쓰기 시작한 뒤에는 필요한 제목과 마지막 치트시트만 다시 찾아보면 된다.

1. Scala 코드는 대부분 값을 만든다

Scala에서는 if, match, 코드 블록까지 값을 돌려주는 **표현식(expression)**이다.

scala
val temperature = 21

val label =
  if temperature >= 20 then "warm"
  else "cool"

label에는 if가 선택한 문자열이 들어간다. 별도의 삼항 연산자가 필요하지 않다.

여러 줄 블록도 마지막 표현식이 블록 전체의 값이 된다.

scala
val message =
  val name = "Haram"
  val greeting = s"Hello, $name"
  greeting.toUpperCase

return을 적지 않았지만 message"HELLO, HARAM"이다. 이 규칙은 함수 본문에서도 그대로 적용된다.

scala
def square(n: Int): Int =
  n * n

함수 시그니처는 다음 순서로 읽는다.

text
def 이름(매개변수: 타입): 반환타입 = 본문

val은 한 번 정하면 다른 값을 대입할 수 없고, var는 다시 대입할 수 있다. S-99를 풀 때는 상태를 바꾸기보다 새 값을 만드는 연습이 중요하므로 우선 val부터 생각하는 편이 좋다.

한 줄 요약: Scala 코드를 읽을 때는 “이 줄이 무엇을 실행하지?”와 함께 “이 표현식이 어떤 값을 만들지?”를 묻는다.

2. 들여쓰기와 중괄호는 같은 구조를 표현한다

Scala 3는 들여쓰기로 블록을 나타낼 수 있다.

scala
def describe(score: Int): String =
  if score >= 90 then
    "excellent"
  else
    "keep going"

같은 코드를 중괄호 스타일로도 쓸 수 있다.

scala
def describe(score: Int): String = {
  if (score >= 90) {
    "excellent"
  } else {
    "keep going"
  }
}

둘 중 하나가 더 함수형인 것은 아니다. 저장소의 스타일에 맞추고, 한 블록 안에서는 일관성을 유지하면 된다.

3. 제네릭의 [A]는 아직 정하지 않은 타입이다

다음 함수에서 A는 값이 아니라 타입을 위한 매개변수다.

scala
def duplicate[A](value: A): (A, A) =
  (value, value)

호출할 때 컴파일러가 A를 추론한다.

scala
duplicate(3)       // (Int, Int)
duplicate("scala") // (String, String)

[A] 덕분에 같은 로직을 Int, String, 직접 만든 타입에 반복해서 작성하지 않아도 된다. 관례적으로 A, B, T 같은 짧은 이름을 쓰지만, 역할이 중요하면 [Input], [Result]처럼 길게 써도 된다.

scala
def transform[Input, Result](
    value: Input,
    f: Input => Result
): Result =
  f(value)

여기서 Input => Result는 입력을 받아 결과를 만드는 함수 타입이다.

4. 연산자처럼 보여도 메서드 호출이다

Scala에서 거의 모든 연산자는 메서드다.

scala
1 + 2
1.+(2)

두 표현은 같은 의미다. 컬렉션에서 보는 기호도 같은 원리로 읽을 수 있다.

scala
val words = List("scala", "is", "fun")
val withPrefix = "today" :: words
val withSuffix = words :+ "today"

이름이 :로 끝나는 연산자는 오른쪽 결합이다. 따라서 다음 두 표현은 같은 뜻이다.

scala
"today" :: words
words.::("today")

불변 연결 리스트인 List는 앞에 붙이는 ::O(1)이고, 끝에 붙이는 :+는 리스트 길이에 비례할 수 있다. 기호를 외우기보다 “어느 값의 메서드를 호출하고, 데이터 구조의 어느 쪽을 바꾸는가”로 읽으면 덜 헷갈린다.

5. List는 비어 있거나, 머리와 꼬리가 있다

List[A]는 같은 타입 A의 값을 순서대로 담는 불변 연결 리스트다.

scala
val colors: List[String] = List("red", "green", "blue")
val empty: List[String] = Nil

리스트의 구조를 바라볼 때 중요한 이름은 세 개다.

문법의미
Nil빈 리스트
head첫 원소
tail첫 원소를 제외한 나머지 리스트

List("red", "green")은 구조적으로 다음처럼도 볼 수 있다.

scala
"red" :: "green" :: Nil

::는 오른쪽부터 묶이므로 "red" :: ("green" :: Nil)이다. 기존 리스트를 바꾸지 않고 새 리스트를 만들어 낸다.

튜플은 서로 다른 타입의 값을 고정된 개수만큼 묶는다.

scala
val entry: (String, Int) = ("scala", 3)
val (language, version) = entry

범위는 연속된 정수를 표현한다.

scala
val closed = 1 to 4    // 1, 2, 3, 4
val openEnd = 1 until 4 // 1, 2, 3

한 줄 요약: List는 순서가 있는 재귀적 구조이고, 튜플은 서로 다른 필드를 잠시 묶는 product다.

6. 패턴 매칭은 데이터의 모양을 읽는다

match도 값을 돌려주는 표현식이다.

scala
val code = 2

val word = code match
  case 1 => "one"
  case 2 => "two"
  case _ => "other"

_는 여기서는 나머지 모든 경우를 받는 wildcard다. 리스트의 모양도 분해할 수 있다.

scala
def firstLabel[A](items: List[A]): String =
  items match
    case Nil         => "empty"
    case head :: Nil => s"one item: $head"
    case head :: _   => s"starts with: $head"

패턴 왼쪽의 소문자 이름은 매칭된 값을 새 변수에 바인딩한다. _는 값이 필요 없다는 뜻이다.

튜플과 case class도 같은 방식으로 분해된다.

scala
case class Badge(name: String, level: Int)

val badge = Badge("reader", 2)

val text = badge match
  case Badge(name, level) => s"$name / $level"

조건을 더 붙이고 싶으면 guard를 사용한다.

scala
val category = badge match
  case Badge(_, level) if level >= 10 => "veteran"
  case Badge(_, level) if level >= 5  => "regular"
  case _                              => "new"

가능한 경우를 빠뜨리지 않는 패턴 매칭을 exhaustive match라고 한다. 닫힌 타입을 매칭할 때 경우가 누락되면 컴파일러가 경고해 줄 수 있다.

7. 함수도 값이다

메서드는 def로 정의하고, 함수 값은 A => B 타입을 가진 값으로 다룰 수 있다.

scala
def shout(text: String): String =
  text.toUpperCase

val whisper: String => String =
  text => text.toLowerCase

람다(lambda)는 이름 없는 함수다.

scala
val lengths = List("a", "scala", "types").map(word => word.length)

매개변수가 명확하면 축약 문법도 쓸 수 있다.

scala
val lengths = List("a", "scala", "types").map(_.length)

다른 함수를 받거나 함수를 반환하는 함수를 **고차 함수(higher-order function)**라고 한다.

scala
def applyTwice[A](value: A, f: A => A): A =
  f(f(value))

applyTwice(" hi ", _.trim)

컬렉션에서 자주 만나는 고차 함수는 역할부터 구분하면 된다.

메서드묻는 질문결과의 모양
map각 원소를 무엇으로 바꿀까?원소 수를 유지한 새 컬렉션
filter어떤 원소를 남길까?일부 원소만 남은 컬렉션
flatMap각 원소가 만든 여러 결과를 어떻게 한 층으로 합칠까?평탄화된 새 컬렉션
foldLeft왼쪽부터 상태를 어떻게 누적할까?하나의 누적 결과
scala
val names = List(" Ada ", "", " Grace ")

val cleaned = names
  .map(_.trim)
  .filter(_.nonEmpty)
scala
val words = List("hello scala", "small functions")
  .flatMap(_.split(" ").toList)
scala
val total = List(2, 4, 6).foldLeft(0) { (sum, n) =>
  sum + n
}

foldLeft0은 초기 상태이고, (sum, n)은 현재 상태와 다음 원소다. 메서드 이름보다 타입과 입력·출력 모양을 먼저 확인하면 응용하기 쉽다.

8. 재귀는 문제보다 상태를 작게 만든다

재귀 함수에는 보통 두 부분이 있다.

  • base case: 더 나아가지 않고 값을 돌려주는 경우
  • recursive case: 더 작은 상태로 자기 자신을 호출하는 경우
scala
def countdown(n: Int): Unit =
  if n <= 0 then
    println("go")
  else
    println(n)
    countdown(n - 1)

이 코드는 n을 하나씩 줄여 base case에 도달한다. 재귀를 읽을 때는 다음 세 가지를 찾는다.

  1. 언제 멈추는가?
  2. 다음 호출에서 무엇이 더 작아지는가?
  3. 재귀 호출이 끝난 뒤 남은 계산이 있는가?

중간 결과를 인자로 가지고 다니는 값을 **누산기(accumulator)**라고 한다. 재귀 호출이 함수의 마지막 작업이면 꼬리 재귀 최적화가 가능할 수 있다. 자세한 호출 흐름과 @tailrec의 역할은 Tail Recursion Optimization에서 이어서 볼 수 있다.

한 줄 요약: 재귀를 “자기 자신을 부르는 신기한 함수”보다 “종료 조건을 향해 상태를 갱신하는 반복”으로 읽는다.

9. 실패도 타입의 일부로 만든다

값이 없을 수 있는데 평범한 A를 반환한다고 쓰면 호출자는 실패 가능성을 타입만 보고 알 수 없다. Option[A]는 값이 있는 Some[A]와 값이 없는 None을 하나의 타입으로 묶는다.

scala
val aliases = Map(
  1 -> "ram",
  2 -> "scala-reader"
)

val alias: Option[String] = aliases.get(3)

Option을 꺼내는 한 가지 방법은 패턴 매칭이다.

scala
val display = alias match
  case Some(name) => name
  case None       => "anonymous"

값이 있을 때만 변환하려면 map을 사용할 수 있다.

scala
val upper: Option[String] = alias.map(_.toUpperCase)
val safe: String = alias.getOrElse("anonymous")

실패 이유도 보존하려면 Either[Error, Value]를 사용할 수 있다. 관례적으로 Left가 실패, Right가 성공이다.

scala
def parsePort(text: String): Either[String, Int] =
  text.toIntOption match
    case Some(port) if port >= 0 && port <= 65535 => Right(port)
    case _ => Left(s"invalid port: $text")

OptionEither는 예외가 무조건 나쁘다는 선언이 아니다. 실패가 정상적인 입력의 일부일 때 그 가능성을 함수 시그니처에 드러내는 도구다.

10. ADT는 가능한 데이터의 모양을 설계한다

ADT는 Algebraic Data Type, 대수적 데이터 타입이다. 이름이 거창하지만 핵심 질문은 단순하다.

이 값이 가질 수 있는 모양은 무엇이고, 각 모양 안에는 어떤 값이 들어가는가?

Product type: 이것도 있고 저것도 있다

case class는 여러 필드를 하나의 값으로 묶는다.

scala
case class Profile(
    name: String,
    active: Boolean
)

Profile 하나에는 name도 있고 active도 있다. 가능한 값의 수를 생각하면 각 필드의 경우의 수를 곱하므로 product type이라고 부른다.

case class는 데이터 모델링에 편한 기능을 컴파일러가 제공한다.

  • 생성자 매개변수는 기본적으로 공개된 불변 필드다.
  • 값 기준 equals, hashCode, 읽기 좋은 toString이 생성된다.
  • copy로 일부 필드만 바꾼 새 값을 만들 수 있다.
  • 패턴 매칭에서 구조 분해할 수 있다.
scala
val reader = Profile("Haram", active = true)
val paused = reader.copy(active = false)

Sum type: 이것이거나 저것이다

enum은 가능한 경우를 하나의 닫힌 타입 아래 모은다.

scala
enum Delivery:
  case Pickup
  case Shipping(address: String)
  case Digital(email: String)

Delivery 값은 Pickup이거나 Shipping이거나 Digital이다. 각 경우의 수를 더한다고 생각할 수 있어 sum type이라고 부른다.

scala
def destination(delivery: Delivery): String =
  delivery match
    case Delivery.Pickup            => "store"
    case Delivery.Shipping(address) => address
    case Delivery.Digital(email)    => email

모든 case가 한 파일의 닫힌 정의 안에 있으므로 컴파일러가 누락된 경우를 검사할 수 있다.

같은 모델을 sealed trait, case class, case object 조합으로도 표현할 수 있다.

scala
sealed trait Notice
case class Email(to: String) extends Notice
case class Push(deviceId: String) extends Notice
case object Muted extends Notice

case object는 추가 데이터가 없는 하나뿐인 경우에 잘 맞는다. Scala 3의 enum은 이런 ADT를 더 짧게 표현할 수 있게 한다.

한 줄 요약: product는 여러 필드를 함께 가지는 모양이고, sum은 여러 모양 중 하나를 고르는 타입이다. 실제 모델은 둘을 중첩해 만든다.

11. OptionList도 ADT다

표준 라이브러리 타입도 같은 눈으로 볼 수 있다. Option[A]의 모양은 개념적으로 다음과 같다.

scala
enum Maybe[+A]:
  case Present(value: A)
  case Absent

실제 코드에서는 표준 Option, Some, None을 사용한다. 여기서는 “값이 있거나 없다”가 sum type이라는 구조만 보면 된다.

연결 리스트도 재귀 ADT로 표현할 수 있다.

scala
enum Chain[+A]:
  case End
  case Link(head: A, tail: Chain[A])

Chain[A] 안의 tail이 다시 Chain[A]다. 이런 타입을 재귀 ADT라고 한다. 실제 Scala List도 비어 있는 Nil과 원소를 이어 붙이는 :: 구현으로 이루어진 닫힌 타입이다.

패턴 매칭은 ADT를 소비하는 자연스러운 도구다. 타입을 정의할 때 만든 각 모양이 case 하나에 대응한다.

12. 재귀 ADT는 유한한 값으로 끝나야 한다

재귀 ADT의 정의는 자기 자신을 가리킬 수 있지만, 실제로 만든 값은 언젠가 재귀하지 않는 case에 도달해야 한다.

scala
enum Expr:
  case Number(value: Int)
  case Add(left: Expr, right: Expr)

Add는 두 개의 Expr을 품지만 Number는 더 작은 Expr을 요구하지 않는다.

scala
val expression = Expr.Add(
  Expr.Number(1),
  Expr.Number(2)
)

이 글에서는 이 값을 계산하거나 변환하는 함수를 작성하지 않는다. 중요한 것은 데이터의 모양만 보고 다음을 알아차리는 것이다.

  • ExprNumber 또는 Add인 sum type이다.
  • Addleftright를 함께 가진 product type이다.
  • leftright가 다시 Expr이므로 재귀적이다.
  • Number가 재귀를 멈출 수 있는 모양이다.

13. List, Tree, Graph를 타입으로 먼저 본다

구조 이름을 보자마자 알고리즘부터 떠올릴 필요는 없다. 먼저 어떤 정보를 보존해야 하는지 타입의 관점에서 본다.

구조타입이 표현해야 하는 것재귀성
List현재 원소와 나머지, 또는 비어 있음꼬리 하나로 이어짐
Tree현재 값과 자식 관계, 또는 비어 있음여러 하위 구조로 갈라질 수 있음
Graph정점 집합과 정점 사이의 연결임의의 연결과 순환이 가능함

Tree는 보통 “종료되는 모양 또는 값을 가진 노드”라는 sum으로 시작하고, 노드 안에 자식 필드를 두는 product로 이어진다. Graph는 재귀 case class 하나로만 표현하면 순환과 공유를 다루기 불편할 수 있어, 정점과 간선을 별도 컬렉션으로 모델링하기도 한다.

여기까지는 정답 구현이 아니라 유효한 상태와 관계를 타입에 어떻게 담을지에 대한 관찰이다. 타입을 먼저 명확히 하면 함수 시그니처가 요구하는 입력과 출력도 더 잘 보인다.

14. 타입을 읽기 위한 최소 도구

공변성 +A

List[+A], Option[+A]+는 공변성(covariance)을 나타낸다. 타입이 A를 주로 생산하는 불변 컨테이너일 때 자연스럽다.

scala
class Animal
class Cat extends Animal

val cats: List[Cat] = List(Cat())
val animals: List[Animal] = cats

CatAnimal의 하위 타입이면 공변인 List[Cat]List[Animal]로 사용할 수 있다. 처음부터 직접 variance를 설계할 필요는 없지만 표준 타입의 [+A]를 읽을 정도는 알아두면 좋다.

타입 별칭

타입 별칭은 기존 타입에 문맥을 담은 이름을 붙인다.

scala
type UserId = Long

val owner: UserId = 42L

새로운 런타임 타입을 만드는 것은 아니지만 시그니처의 의도를 읽기 쉽게 한다.

Companion object

클래스와 같은 이름의 object를 companion object라고 한다. 특정 인스턴스가 아니라 타입 전체와 관련된 생성 함수나 상수를 둘 수 있다.

scala
case class Distance(meters: Double)

object Distance:
  def kilometers(value: Double): Distance =
    Distance(value * 1000)
scala
val walk = Distance.kilometers(1.5)

case class의 기본 생성·패턴 매칭 기능도 companion과 연결되어 있다.

15. 작은 테스트는 함수의 계약을 보여준다

테스트는 구현이 무엇인지보다 입력과 기대 결과가 무엇인지 먼저 보여준다. 이 저장소에서 사용하는 MUnit의 기본 모양은 간단하다.

scala
class TextSuite extends munit.FunSuite:
  test("trim은 양끝 공백을 제거한다"):
    assertEquals("  scala  ".trim, "scala")

테스트 이름은 행동을 설명하고, assertEquals(actual, expected)는 실제 값과 기대 값을 비교한다. 작은 예제, 경계에 가까운 예제, 타입이 달라져도 지켜야 하는 계약을 나눠 생각하면 구현 전에 문제를 더 정확히 읽을 수 있다.

지금은 몰라도 되는 것

Scala에는 다음처럼 강력한 기능이 더 많다.

  • given / using과 type class
  • extension method
  • opaque type과 match type
  • macro와 compile-time metaprogramming
  • effect system과 비동기·동시성 라이브러리

이 기능들은 유용하지만 S-99를 시작하기 위한 선행 조건은 아니다. 지금 코드에 등장하지 않는다면 잠시 미뤄도 된다. List, 패턴 매칭, 함수, 재귀, ADT를 손에 익힌 다음 필요할 때 돌아오는 편이 부담이 적다.

최종 치트시트

문법읽는 법
val x = value바뀌지 않는 이름 x에 값을 묶는다
def f(x: A): BA를 받아 B를 만드는 메서드
[A]호출 시 정해지는 타입 매개변수
A => BA를 받아 B를 만드는 함수 타입
(A, B)두 값을 함께 담는 product
List[A]A를 순서대로 담는 불변 연결 리스트
Nil빈 리스트
head :: tail리스트의 첫 원소와 나머지를 분해하거나 앞에 붙인다
value match값의 모양에 따라 결과를 만든다
case _나머지 모든 경우 또는 사용하지 않을 값
case p if condition패턴에 조건을 추가한다
Option[A]A가 있거나 없을 수 있다
Either[E, A]실패 E 또는 성공 A
case class여러 필드를 함께 가지는 불변 product 모델
enum가능한 case를 닫아 둔 sum 모델
sealed trait하위 타입 범위를 같은 파일 안으로 제한한다
map각 원소나 내부 값을 변환한다
flatMap변환 결과의 중첩을 한 층 합친다
filter조건에 맞는 값만 남긴다
foldLeft초기 상태에서 왼쪽부터 누적한다
@tailrec의도한 꼬리 재귀 형태를 컴파일러가 검사한다

코드를 읽다가 막히면 다음 순서로 확인한다.

  1. 입력 타입과 반환 타입은 무엇인가?
  2. 데이터가 가질 수 있는 모양은 몇 개인가?
  3. case에서 새로 바인딩된 이름은 무엇인가?
  4. 다음 단계로 넘기는 상태는 무엇인가?
  5. 실패나 빈 상태가 타입에 표현되어 있는가?

이 다섯 질문에 답할 수 있으면, 문법은 이미 문제를 가리는 장벽이 아니라 문제를 설명하는 언어가 되기 시작한 것이다.

참고한 공식 자료

Discussion