Scala for S-99
99개 문제를 풀기 전에 훑어보는 Scala 3 문법과 ADT
- Haram Lee
- 2026-08-26
- studies / Topics
이 글은 S-99의 답안집이 아니다. 문제를 읽다가 Scala 문법 때문에 멈추지 않도록, 자주 보게 될 언어의 모양과 그 뒤의 사고방식을 한 번에 훑는 지도에 가깝다.
특정 문제의 풀이 함수, 알고리즘 선택, 완성된 정답 코드는 의도적으로 싣지 않는다. 처음에는 위에서 아래로 가볍게 읽고, 실제로 코드를 쓰기 시작한 뒤에는 필요한 제목과 마지막 치트시트만 다시 찾아보면 된다.
1. Scala 코드는 대부분 값을 만든다
Scala에서는 if, match, 코드 블록까지 값을 돌려주는 **표현식(expression)**이다.
val temperature = 21
val label =
if temperature >= 20 then "warm"
else "cool"label에는 if가 선택한 문자열이 들어간다. 별도의 삼항 연산자가 필요하지 않다.
여러 줄 블록도 마지막 표현식이 블록 전체의 값이 된다.
val message =
val name = "Haram"
val greeting = s"Hello, $name"
greeting.toUpperCasereturn을 적지 않았지만 message는 "HELLO, HARAM"이다. 이 규칙은 함수 본문에서도 그대로 적용된다.
def square(n: Int): Int =
n * n함수 시그니처는 다음 순서로 읽는다.
def 이름(매개변수: 타입): 반환타입 = 본문val은 한 번 정하면 다른 값을 대입할 수 없고, var는 다시 대입할 수 있다. S-99를 풀 때는 상태를 바꾸기보다 새 값을 만드는 연습이 중요하므로 우선 val부터 생각하는 편이 좋다.
한 줄 요약: Scala 코드를 읽을 때는 “이 줄이 무엇을 실행하지?”와 함께 “이 표현식이 어떤 값을 만들지?”를 묻는다.
2. 들여쓰기와 중괄호는 같은 구조를 표현한다
Scala 3는 들여쓰기로 블록을 나타낼 수 있다.
def describe(score: Int): String =
if score >= 90 then
"excellent"
else
"keep going"같은 코드를 중괄호 스타일로도 쓸 수 있다.
def describe(score: Int): String = {
if (score >= 90) {
"excellent"
} else {
"keep going"
}
}둘 중 하나가 더 함수형인 것은 아니다. 저장소의 스타일에 맞추고, 한 블록 안에서는 일관성을 유지하면 된다.
3. 제네릭의 [A]는 아직 정하지 않은 타입이다
다음 함수에서 A는 값이 아니라 타입을 위한 매개변수다.
def duplicate[A](value: A): (A, A) =
(value, value)호출할 때 컴파일러가 A를 추론한다.
duplicate(3) // (Int, Int)
duplicate("scala") // (String, String)[A] 덕분에 같은 로직을 Int, String, 직접 만든 타입에 반복해서 작성하지 않아도 된다. 관례적으로 A, B, T 같은 짧은 이름을 쓰지만, 역할이 중요하면 [Input], [Result]처럼 길게 써도 된다.
def transform[Input, Result](
value: Input,
f: Input => Result
): Result =
f(value)여기서 Input => Result는 입력을 받아 결과를 만드는 함수 타입이다.
4. 연산자처럼 보여도 메서드 호출이다
Scala에서 거의 모든 연산자는 메서드다.
1 + 2
1.+(2)두 표현은 같은 의미다. 컬렉션에서 보는 기호도 같은 원리로 읽을 수 있다.
val words = List("scala", "is", "fun")
val withPrefix = "today" :: words
val withSuffix = words :+ "today"이름이 :로 끝나는 연산자는 오른쪽 결합이다. 따라서 다음 두 표현은 같은 뜻이다.
"today" :: words
words.::("today")불변 연결 리스트인 List는 앞에 붙이는 ::가 O(1)이고, 끝에 붙이는 :+는 리스트 길이에 비례할 수 있다. 기호를 외우기보다 “어느 값의 메서드를 호출하고, 데이터 구조의 어느 쪽을 바꾸는가”로 읽으면 덜 헷갈린다.
5. List는 비어 있거나, 머리와 꼬리가 있다
List[A]는 같은 타입 A의 값을 순서대로 담는 불변 연결 리스트다.
val colors: List[String] = List("red", "green", "blue")
val empty: List[String] = Nil리스트의 구조를 바라볼 때 중요한 이름은 세 개다.
| 문법 | 의미 |
|---|---|
Nil | 빈 리스트 |
head | 첫 원소 |
tail | 첫 원소를 제외한 나머지 리스트 |
List("red", "green")은 구조적으로 다음처럼도 볼 수 있다.
"red" :: "green" :: Nil::는 오른쪽부터 묶이므로 "red" :: ("green" :: Nil)이다. 기존 리스트를 바꾸지 않고 새 리스트를 만들어 낸다.
튜플은 서로 다른 타입의 값을 고정된 개수만큼 묶는다.
val entry: (String, Int) = ("scala", 3)
val (language, version) = entry범위는 연속된 정수를 표현한다.
val closed = 1 to 4 // 1, 2, 3, 4
val openEnd = 1 until 4 // 1, 2, 3한 줄 요약:
List는 순서가 있는 재귀적 구조이고, 튜플은 서로 다른 필드를 잠시 묶는 product다.
6. 패턴 매칭은 데이터의 모양을 읽는다
match도 값을 돌려주는 표현식이다.
val code = 2
val word = code match
case 1 => "one"
case 2 => "two"
case _ => "other"_는 여기서는 나머지 모든 경우를 받는 wildcard다. 리스트의 모양도 분해할 수 있다.
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도 같은 방식으로 분해된다.
case class Badge(name: String, level: Int)
val badge = Badge("reader", 2)
val text = badge match
case Badge(name, level) => s"$name / $level"조건을 더 붙이고 싶으면 guard를 사용한다.
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 타입을 가진 값으로 다룰 수 있다.
def shout(text: String): String =
text.toUpperCase
val whisper: String => String =
text => text.toLowerCase람다(lambda)는 이름 없는 함수다.
val lengths = List("a", "scala", "types").map(word => word.length)매개변수가 명확하면 축약 문법도 쓸 수 있다.
val lengths = List("a", "scala", "types").map(_.length)다른 함수를 받거나 함수를 반환하는 함수를 **고차 함수(higher-order function)**라고 한다.
def applyTwice[A](value: A, f: A => A): A =
f(f(value))
applyTwice(" hi ", _.trim)컬렉션에서 자주 만나는 고차 함수는 역할부터 구분하면 된다.
| 메서드 | 묻는 질문 | 결과의 모양 |
|---|---|---|
map | 각 원소를 무엇으로 바꿀까? | 원소 수를 유지한 새 컬렉션 |
filter | 어떤 원소를 남길까? | 일부 원소만 남은 컬렉션 |
flatMap | 각 원소가 만든 여러 결과를 어떻게 한 층으로 합칠까? | 평탄화된 새 컬렉션 |
foldLeft | 왼쪽부터 상태를 어떻게 누적할까? | 하나의 누적 결과 |
val names = List(" Ada ", "", " Grace ")
val cleaned = names
.map(_.trim)
.filter(_.nonEmpty)val words = List("hello scala", "small functions")
.flatMap(_.split(" ").toList)val total = List(2, 4, 6).foldLeft(0) { (sum, n) =>
sum + n
}foldLeft의 0은 초기 상태이고, (sum, n)은 현재 상태와 다음 원소다. 메서드 이름보다 타입과 입력·출력 모양을 먼저 확인하면 응용하기 쉽다.
8. 재귀는 문제보다 상태를 작게 만든다
재귀 함수에는 보통 두 부분이 있다.
- base case: 더 나아가지 않고 값을 돌려주는 경우
- recursive case: 더 작은 상태로 자기 자신을 호출하는 경우
def countdown(n: Int): Unit =
if n <= 0 then
println("go")
else
println(n)
countdown(n - 1)이 코드는 n을 하나씩 줄여 base case에 도달한다. 재귀를 읽을 때는 다음 세 가지를 찾는다.
- 언제 멈추는가?
- 다음 호출에서 무엇이 더 작아지는가?
- 재귀 호출이 끝난 뒤 남은 계산이 있는가?
중간 결과를 인자로 가지고 다니는 값을 **누산기(accumulator)**라고 한다. 재귀 호출이 함수의 마지막 작업이면 꼬리 재귀 최적화가 가능할 수 있다. 자세한 호출 흐름과 @tailrec의 역할은 Tail Recursion Optimization에서 이어서 볼 수 있다.
한 줄 요약: 재귀를 “자기 자신을 부르는 신기한 함수”보다 “종료 조건을 향해 상태를 갱신하는 반복”으로 읽는다.
9. 실패도 타입의 일부로 만든다
값이 없을 수 있는데 평범한 A를 반환한다고 쓰면 호출자는 실패 가능성을 타입만 보고 알 수 없다. Option[A]는 값이 있는 Some[A]와 값이 없는 None을 하나의 타입으로 묶는다.
val aliases = Map(
1 -> "ram",
2 -> "scala-reader"
)
val alias: Option[String] = aliases.get(3)Option을 꺼내는 한 가지 방법은 패턴 매칭이다.
val display = alias match
case Some(name) => name
case None => "anonymous"값이 있을 때만 변환하려면 map을 사용할 수 있다.
val upper: Option[String] = alias.map(_.toUpperCase)
val safe: String = alias.getOrElse("anonymous")실패 이유도 보존하려면 Either[Error, Value]를 사용할 수 있다. 관례적으로 Left가 실패, Right가 성공이다.
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")Option과 Either는 예외가 무조건 나쁘다는 선언이 아니다. 실패가 정상적인 입력의 일부일 때 그 가능성을 함수 시그니처에 드러내는 도구다.
10. ADT는 가능한 데이터의 모양을 설계한다
ADT는 Algebraic Data Type, 대수적 데이터 타입이다. 이름이 거창하지만 핵심 질문은 단순하다.
이 값이 가질 수 있는 모양은 무엇이고, 각 모양 안에는 어떤 값이 들어가는가?
Product type: 이것도 있고 저것도 있다
case class는 여러 필드를 하나의 값으로 묶는다.
case class Profile(
name: String,
active: Boolean
)Profile 하나에는 name도 있고 active도 있다. 가능한 값의 수를 생각하면 각 필드의 경우의 수를 곱하므로 product type이라고 부른다.
case class는 데이터 모델링에 편한 기능을 컴파일러가 제공한다.
- 생성자 매개변수는 기본적으로 공개된 불변 필드다.
- 값 기준
equals,hashCode, 읽기 좋은toString이 생성된다. copy로 일부 필드만 바꾼 새 값을 만들 수 있다.- 패턴 매칭에서 구조 분해할 수 있다.
val reader = Profile("Haram", active = true)
val paused = reader.copy(active = false)Sum type: 이것이거나 저것이다
enum은 가능한 경우를 하나의 닫힌 타입 아래 모은다.
enum Delivery:
case Pickup
case Shipping(address: String)
case Digital(email: String)Delivery 값은 Pickup이거나 Shipping이거나 Digital이다. 각 경우의 수를 더한다고 생각할 수 있어 sum type이라고 부른다.
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 조합으로도 표현할 수 있다.
sealed trait Notice
case class Email(to: String) extends Notice
case class Push(deviceId: String) extends Notice
case object Muted extends Noticecase object는 추가 데이터가 없는 하나뿐인 경우에 잘 맞는다. Scala 3의 enum은 이런 ADT를 더 짧게 표현할 수 있게 한다.
한 줄 요약: product는 여러 필드를 함께 가지는 모양이고, sum은 여러 모양 중 하나를 고르는 타입이다. 실제 모델은 둘을 중첩해 만든다.
11. Option과 List도 ADT다
표준 라이브러리 타입도 같은 눈으로 볼 수 있다. Option[A]의 모양은 개념적으로 다음과 같다.
enum Maybe[+A]:
case Present(value: A)
case Absent실제 코드에서는 표준 Option, Some, None을 사용한다. 여기서는 “값이 있거나 없다”가 sum type이라는 구조만 보면 된다.
연결 리스트도 재귀 ADT로 표현할 수 있다.
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에 도달해야 한다.
enum Expr:
case Number(value: Int)
case Add(left: Expr, right: Expr)Add는 두 개의 Expr을 품지만 Number는 더 작은 Expr을 요구하지 않는다.
val expression = Expr.Add(
Expr.Number(1),
Expr.Number(2)
)이 글에서는 이 값을 계산하거나 변환하는 함수를 작성하지 않는다. 중요한 것은 데이터의 모양만 보고 다음을 알아차리는 것이다.
Expr은Number또는Add인 sum type이다.Add는left와right를 함께 가진 product type이다.left와right가 다시Expr이므로 재귀적이다.Number가 재귀를 멈출 수 있는 모양이다.
13. List, Tree, Graph를 타입으로 먼저 본다
구조 이름을 보자마자 알고리즘부터 떠올릴 필요는 없다. 먼저 어떤 정보를 보존해야 하는지 타입의 관점에서 본다.
| 구조 | 타입이 표현해야 하는 것 | 재귀성 |
|---|---|---|
| List | 현재 원소와 나머지, 또는 비어 있음 | 꼬리 하나로 이어짐 |
| Tree | 현재 값과 자식 관계, 또는 비어 있음 | 여러 하위 구조로 갈라질 수 있음 |
| Graph | 정점 집합과 정점 사이의 연결 | 임의의 연결과 순환이 가능함 |
Tree는 보통 “종료되는 모양 또는 값을 가진 노드”라는 sum으로 시작하고, 노드 안에 자식 필드를 두는 product로 이어진다. Graph는 재귀 case class 하나로만 표현하면 순환과 공유를 다루기 불편할 수 있어, 정점과 간선을 별도 컬렉션으로 모델링하기도 한다.
여기까지는 정답 구현이 아니라 유효한 상태와 관계를 타입에 어떻게 담을지에 대한 관찰이다. 타입을 먼저 명확히 하면 함수 시그니처가 요구하는 입력과 출력도 더 잘 보인다.
14. 타입을 읽기 위한 최소 도구
공변성 +A
List[+A], Option[+A]의 +는 공변성(covariance)을 나타낸다. 타입이 A를 주로 생산하는 불변 컨테이너일 때 자연스럽다.
class Animal
class Cat extends Animal
val cats: List[Cat] = List(Cat())
val animals: List[Animal] = catsCat이 Animal의 하위 타입이면 공변인 List[Cat]도 List[Animal]로 사용할 수 있다. 처음부터 직접 variance를 설계할 필요는 없지만 표준 타입의 [+A]를 읽을 정도는 알아두면 좋다.
타입 별칭
타입 별칭은 기존 타입에 문맥을 담은 이름을 붙인다.
type UserId = Long
val owner: UserId = 42L새로운 런타임 타입을 만드는 것은 아니지만 시그니처의 의도를 읽기 쉽게 한다.
Companion object
클래스와 같은 이름의 object를 companion object라고 한다. 특정 인스턴스가 아니라 타입 전체와 관련된 생성 함수나 상수를 둘 수 있다.
case class Distance(meters: Double)
object Distance:
def kilometers(value: Double): Distance =
Distance(value * 1000)val walk = Distance.kilometers(1.5)case class의 기본 생성·패턴 매칭 기능도 companion과 연결되어 있다.
15. 작은 테스트는 함수의 계약을 보여준다
테스트는 구현이 무엇인지보다 입력과 기대 결과가 무엇인지 먼저 보여준다. 이 저장소에서 사용하는 MUnit의 기본 모양은 간단하다.
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): B | A를 받아 B를 만드는 메서드 |
[A] | 호출 시 정해지는 타입 매개변수 |
A => B | A를 받아 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 | 의도한 꼬리 재귀 형태를 컴파일러가 검사한다 |
코드를 읽다가 막히면 다음 순서로 확인한다.
- 입력 타입과 반환 타입은 무엇인가?
- 데이터가 가질 수 있는 모양은 몇 개인가?
- 각
case에서 새로 바인딩된 이름은 무엇인가? - 다음 단계로 넘기는 상태는 무엇인가?
- 실패나 빈 상태가 타입에 표현되어 있는가?
이 다섯 질문에 답할 수 있으면, 문법은 이미 문제를 가리는 장벽이 아니라 문제를 설명하는 언어가 되기 시작한 것이다.