TL;DR
- 케이스 클래스로 불변 데이터 모델링, 자동 생성되는
equals,copy활용- Option으로 null 안전성, Either로 상세한 에러 처리
- 패턴 매칭으로 타입에 따른 분기 처리
- 컬렉션 API (
map,filter,groupBy)로 선언적 데이터 처리- 타입 클래스로 확장 가능한 기능 구현
대상 독자: Scala 환경 설정을 마친 개발자, 실제 코드로 Scala를 학습하려는 분
선수 지식:
- Scala 개발 환경 설정 완료 (환경 설정 참조)
- 객체지향 프로그래밍 기본 개념
- 함수형 프로그래밍 기초 (권장)
Scala의 핵심 개념을 활용한 종합 예제입니다. 이 문서에서는 케이스 클래스, 패턴 매칭, 컬렉션 처리, Option, Either, 타입 클래스 등 실무에서 자주 사용하는 기능들을 예제 코드와 함께 설명합니다.
💻 온라인 실행: 아래 모든 예제는 Scastie에서 복사하여 바로 실행할 수 있습니다. Scala 3를 선택하고 코드를 붙여넣으세요!
예제 1: 데이터 모델링#
케이스 클래스로 도메인 모델을 정의합니다. 케이스 클래스는 불변 데이터를 표현하는 데 적합하며, equals, hashCode, toString, copy 메서드가 자동으로 생성됩니다. 전자상거래 도메인에서 자주 볼 수 있는 Product, OrderLine, Order를 모델링해보겠습니다.
// 도메인 모델
case class Product(id: Int, name: String, price: Double)
case class OrderLine(product: Product, quantity: Int)
case class Order(id: Int, customer: String, lines: List[OrderLine])
// 팩토리 메서드
object Order:
def create(id: Int, customer: String, lines: List[OrderLine]): Option[Order] =
if lines.isEmpty then None
else Some(Order(id, customer, lines))
// 사용
val laptop = Product(1, "노트북", 1500000)
val mouse = Product(2, "마우스", 50000)
val order = Order.create(
1,
"김철수",
List(
OrderLine(laptop, 1),
OrderLine(mouse, 2)
)
)위 코드에서 Order.create 팩토리 메서드는 주문 라인이 비어있으면 None을 반환하고, 그렇지 않으면 Some(Order(…))를 반환합니다. 이렇게 하면 빈 주문이 생성되는 것을 컴파일 타임에 방지할 수 있습니다.
핵심 포인트
- 케이스 클래스: 불변 데이터 표현에 적합,
equals,hashCode,toString,copy자동 생성- 팩토리 메서드: 객체 생성 시 유효성 검사를 강제하여 잘못된 상태 방지
- Option 반환: null 대신 Option으로 “값이 없을 수 있음"을 타입으로 표현
예제 2: 주문 처리#
패턴 매칭과 고차 함수로 비즈니스 로직을 구현합니다. Scala 3의 extension 키워드를 사용하면 기존 클래스에 새로운 메서드를 추가할 수 있습니다. 이를 통해 Order 클래스를 수정하지 않고도 새로운 기능을 추가합니다.
// 주문 확장 메서드
extension (order: Order)
def totalPrice: Double =
order.lines.map(line => line.product.price * line.quantity).sum
def itemCount: Int =
order.lines.map(_.quantity).sum
def hasProduct(productId: Int): Boolean =
order.lines.exists(_.product.id == productId)
def applyDiscount(rate: Double): Order =
order.copy(
lines = order.lines.map { line =>
line.copy(
product = line.product.copy(
price = line.product.price * (1 - rate)
)
)
}
)
// 사용
order.foreach { o =>
println(s"총 금액: ${o.totalPrice}원") // 1,600,000원
println(s"상품 수: ${o.itemCount}개") // 3개
println(s"노트북 포함: ${o.hasProduct(1)}") // true
val discounted = o.applyDiscount(0.1)
println(s"할인 후: ${discounted.totalPrice}원") // 1,440,000원
}totalPrice 메서드는 각 주문 라인의 가격과 수량을 곱한 값들의 합계를 계산합니다. applyDiscount 메서드는 케이스 클래스의 copy 메서드를 중첩 사용하여 불변성을 유지하면서 할인을 적용합니다.
핵심 포인트
- extension: 기존 클래스를 수정하지 않고 새 메서드 추가 (Scala 3)
- copy: 불변 객체의 일부 필드만 변경한 새 객체 생성
- 고차 함수:
map,sum,exists등으로 컬렉션을 선언적으로 처리
예제 3: 에러 처리#
Either를 사용한 안전한 에러 처리입니다. Either는 성공(Right)과 실패(Left)를 표현하며, 실패 시 에러 정보를 담을 수 있어 Option보다 더 상세한 에러 처리가 가능합니다. 검증 로직에서 어떤 이유로 실패했는지 알려주는 데 유용합니다.
// 에러 타입
enum ValidationError:
case EmptyName
case InvalidPrice(price: Double)
case InvalidQuantity(qty: Int)
import ValidationError.*
// 검증 함수
def validateProduct(name: String, price: Double): Either[ValidationError, Product] =
if name.isEmpty then Left(EmptyName)
else if price <= 0 then Left(InvalidPrice(price))
else Right(Product(0, name, price))
def validateOrderLine(
product: Product,
quantity: Int
): Either[ValidationError, OrderLine] =
if quantity <= 0 then Left(InvalidQuantity(quantity))
else Right(OrderLine(product, quantity))
// 조합
def createOrderLine(
name: String,
price: Double,
quantity: Int
): Either[ValidationError, OrderLine] =
for
product <- validateProduct(name, price)
line <- validateOrderLine(product, quantity)
yield line
// 사용
createOrderLine("노트북", 1500000, 1) match
case Right(line) => println(s"주문 라인: $line")
case Left(EmptyName) => println("상품명이 비어있습니다")
case Left(InvalidPrice(p)) => println(s"잘못된 가격: $p")
case Left(InvalidQuantity(q)) => println(s"잘못된 수량: $q")for comprehension을 사용하면 Either 값들을 순차적으로 연결할 수 있습니다. 첫 번째 검증이 실패하면 즉시 Left를 반환하고, 모든 검증이 통과하면 최종 결과를 Right로 감싸서 반환합니다.
핵심 포인트
- Either[L, R]:
Left는 실패,Right는 성공 (관례)- enum: 에러 타입을 열거형으로 정의하여 타입 안전성 확보
- for comprehension: 여러 Either를 순차적으로 연결, 첫 실패에서 중단
- 패턴 매칭: 모든 에러 케이스를 명시적으로 처리
예제 4: 컬렉션 처리#
함수형 스타일로 데이터를 처리합니다. Scala 컬렉션은 map, filter, groupBy, sortBy 등 풍부한 메서드를 제공합니다. 명령형 반복문 대신 이러한 선언적 메서드를 사용하면 코드가 간결하고 읽기 쉬워집니다.
// 샘플 데이터
val products = List(
Product(1, "노트북", 1500000),
Product(2, "마우스", 50000),
Product(3, "키보드", 150000),
Product(4, "모니터", 500000),
Product(5, "스피커", 200000)
)
// 필터링
val expensive = products.filter(_.price >= 200000)
println(s"고가 상품: ${expensive.map(_.name)}")
// List(노트북, 모니터)
// 변환
val priceList = products.map(p => s"${p.name}: ${p.price}원")
println(priceList.mkString("\n"))
// 그룹화
val byPriceRange = products.groupBy { p =>
if p.price < 100000 then "저가"
else if p.price < 500000 then "중가"
else "고가"
}
println(s"가격대별: $byPriceRange")
// 집계
val totalValue = products.map(_.price).sum
val avgPrice = products.map(_.price).sum / products.length
val maxPrice = products.maxBy(_.price)
println(s"총 가치: ${totalValue}원")
println(s"평균 가격: ${avgPrice}원")
println(s"최고가 상품: ${maxPrice.name}")
// 정렬
val sortedByPrice = products.sortBy(_.price)
val sortedByName = products.sortBy(_.name)filter는 조건에 맞는 요소만 추출하고, map은 각 요소를 변환합니다. groupBy는 키 함수를 기준으로 요소들을 그룹화하여 Map을 반환합니다. sortBy는 주어진 함수의 결과를 기준으로 정렬합니다.
핵심 포인트
- filter: 조건에 맞는 요소만 추출
- map: 각 요소를 변환하여 새 컬렉션 생성
- groupBy: 키 함수 기준으로 그룹화 →
Map[K, List[V]]반환- sortBy: 정렬 기준 지정, maxBy/minBy: 최대/최소 요소 추출
예제 5: Option 활용#
null 대신 Option을 사용합니다. Option은 값이 있을 수도 있고 없을 수도 있는 상황을 타입으로 표현합니다. NullPointerException을 컴파일 타임에 방지할 수 있어 더 안전한 코드를 작성할 수 있습니다.
// 저장소
object ProductRepository:
private val products = Map(
1 -> Product(1, "노트북", 1500000),
2 -> Product(2, "마우스", 50000)
)
def findById(id: Int): Option[Product] = products.get(id)
def findByName(name: String): Option[Product] =
products.values.find(_.name.contains(name))
// 사용
ProductRepository.findById(1) match
case Some(product) => println(s"찾음: $product")
case None => println("상품 없음")
// 체이닝
val price = ProductRepository
.findById(1)
.map(_.price)
.getOrElse(0.0)
// for comprehension
val orderTotal = for
laptop <- ProductRepository.findById(1)
mouse <- ProductRepository.findById(2)
yield laptop.price + mouse.price
println(s"주문 합계: ${orderTotal.getOrElse(0.0)}원")Option에 map을 적용하면 값이 있는 경우에만 변환이 수행됩니다. for comprehension을 사용하면 여러 Option 값을 조합할 수 있으며, 중간에 None이 있으면 전체 결과가 None이 됩니다.
핵심 포인트
- Option[T]:
Some(값)또는None으로 값의 존재 여부 표현- getOrElse: None일 때 기본값 반환
- map/flatMap: 값이 있을 때만 변환 수행
- for comprehension: 여러 Option 조합, 하나라도 None이면 전체가 None
예제 6: 타입 클래스#
타입 클래스로 확장 가능한 기능을 구현합니다. 타입 클래스는 기존 타입을 수정하지 않고 새로운 기능을 추가하는 패턴입니다. JSON 인코더를 예로 들어 타입 클래스의 활용법을 보여줍니다.
// JSON 인코더 타입 클래스
trait JsonEncoder[A]:
def encode(a: A): String
object JsonEncoder:
given JsonEncoder[String] with
def encode(s: String): String = s"\"$s\""
given JsonEncoder[Int] with
def encode(i: Int): String = i.toString
given JsonEncoder[Double] with
def encode(d: Double): String = d.toString
given JsonEncoder[Product] with
def encode(p: Product): String =
s"""{"id":${p.id},"name":"${p.name}","price":${p.price}}"""
given [A](using e: JsonEncoder[A]): JsonEncoder[List[A]] with
def encode(list: List[A]): String =
list.map(e.encode).mkString("[", ",", "]")
// 확장 메서드
extension [A](a: A)(using e: JsonEncoder[A])
def toJson: String = e.encode(a)
// 사용
val laptop = Product(1, "노트북", 1500000)
println(laptop.toJson)
// {"id":1,"name":"노트북","price":1500000.0}
val products = List(
Product(1, "노트북", 1500000),
Product(2, "마우스", 50000)
)
println(products.toJson)
// [{"id":1,"name":"노트북","price":1500000.0},{"id":2,"name":"마우스","price":50000.0}]given 키워드로 타입 클래스 인스턴스를 정의합니다. List에 대한 인코더는 요소 타입의 인코더가 있으면 자동으로 생성됩니다. extension을 통해 toJson 메서드를 모든 인코딩 가능한 타입에 추가합니다.
핵심 포인트
- 타입 클래스: 기존 타입을 수정하지 않고 기능 추가하는 패턴
- trait: 타입 클래스 인터페이스 정의
- given: 타입 클래스 인스턴스 정의 (Scala 3)
- extension + using: 조건부 확장 메서드 (인코더가 있는 타입에만
toJson추가)
예제 프로젝트 실행#
위 예제들을 로컬에서 실행하려면 예제 프로젝트 디렉토리에서 sbt run 명령을 실행합니다.
cd examples/scala/scala3-basics
sbt run예제 7: 실무 시나리오 - REST API 응답 처리#
실제 API 응답을 처리하는 패턴입니다. HTTP 응답은 성공할 수도 있고 실패할 수도 있으므로, 이를 안전하게 처리하는 유틸리티를 만들어봅니다.
import scala.util.{Try, Success, Failure}
// API 응답 모델
case class ApiResponse[T](
status: Int,
data: Option[T],
error: Option[String]
)
// 사용자 도메인
case class User(id: Long, name: String, email: String)
// API 클라이언트 시뮬레이션
object UserApiClient:
def fetchUser(id: Long): ApiResponse[User] =
if id > 0 then
ApiResponse(200, Some(User(id, s"User$id", s"user$id@example.com")), None)
else
ApiResponse(404, None, Some("User not found"))
def fetchUsers(ids: List[Long]): List[ApiResponse[User]] =
ids.map(fetchUser)
// 응답 처리 유틸리티
object ApiResponseHandler:
extension [T](response: ApiResponse[T])
def toEither: Either[String, T] =
response match
case ApiResponse(status, Some(data), _) if status < 400 => Right(data)
case ApiResponse(_, _, Some(error)) => Left(error)
case _ => Left("Unknown error")
def toOption: Option[T] = response.data.filter(_ => response.status < 400)
// 사용 예시
import ApiResponseHandler.*
val response = UserApiClient.fetchUser(1)
val userOrError = response.toEither
userOrError match
case Right(user) => println(s"환영합니다, ${user.name}!")
case Left(error) => println(s"오류: $error")
// 여러 사용자 처리
val userIds = List(1L, 2L, -1L, 3L)
val results = UserApiClient.fetchUsers(userIds)
.map(_.toEither)
.collect { case Right(user) => user }
println(s"성공적으로 조회된 사용자: ${results.length}명")ApiResponse를 Either나 Option으로 변환하는 확장 메서드를 정의했습니다. collect 메서드는 부분 함수를 사용하여 Right인 경우만 추출합니다.
핵심 포인트
- API 응답 래퍼: 상태 코드, 데이터, 에러 메시지를 하나의 타입으로 표현
- 확장 메서드: 응답을
Either나Option으로 변환하여 일관된 에러 처리- collect: 특정 패턴에 맞는 요소만 추출하여 새 컬렉션 생성
예제 8: 실무 시나리오 - 설정 관리#
환경별 설정을 타입 안전하게 관리하는 패턴입니다. enum으로 환경을 정의하고, 패턴 매칭을 통해 각 환경에 맞는 설정을 반환합니다.
// 설정 ADT
enum Environment:
case Development, Staging, Production
case class DatabaseConfig(
host: String,
port: Int,
database: String,
maxConnections: Int
)
case class AppConfig(
environment: Environment,
database: DatabaseConfig,
debug: Boolean
)
object AppConfig:
import Environment.*
def load(env: Environment): AppConfig = env match
case Development =>
AppConfig(
environment = Development,
database = DatabaseConfig("localhost", 5432, "dev_db", 5),
debug = true
)
case Staging =>
AppConfig(
environment = Staging,
database = DatabaseConfig("staging.db.internal", 5432, "staging_db", 20),
debug = true
)
case Production =>
AppConfig(
environment = Production,
database = DatabaseConfig("prod.db.internal", 5432, "prod_db", 100),
debug = false
)
def fromString(envStr: String): Either[String, AppConfig] =
envStr.toLowerCase match
case "dev" | "development" => Right(load(Development))
case "staging" => Right(load(Staging))
case "prod" | "production" => Right(load(Production))
case _ => Left(s"Unknown environment: $envStr")
// 사용 예시
val config = AppConfig.fromString("production")
config match
case Right(cfg) =>
println(s"환경: ${cfg.environment}")
println(s"DB 호스트: ${cfg.database.host}")
println(s"디버그 모드: ${cfg.debug}")
case Left(error) =>
println(s"설정 로드 실패: $error")Environment enum으로 가능한 환경을 열거하고, 각 환경에 대해 적절한 설정을 패턴 매칭으로 반환합니다. fromString 메서드는 문자열 입력을 받아 Either로 결과를 반환하므로 잘못된 환경 문자열도 안전하게 처리됩니다.
핵심 포인트
- enum: 가능한 값들을 타입으로 열거하여 컴파일 타임 안전성 확보
- 패턴 매칭: 각 환경에 맞는 설정을 명확하게 분기
- Either 반환: 잘못된 입력을 안전하게 처리, 에러 메시지 전달 가능
연습 과제#
다음 과제들을 직접 구현해보면서 Scala 실력을 향상시켜 보세요.
재고 관리 추가 ⭐:
Product에stock필드를 추가하고, 재고 확인 로직을 구현하세요. 재고가 부족하면 주문이 생성되지 않도록 합니다.주문 상태 ⭐⭐:
Order에 상태(PENDING, CONFIRMED, SHIPPED)를 추가하세요. enum을 사용하여 정의하고, 상태 전이 로직도 구현해보세요.검색 기능 ⭐⭐: 가격 범위와 이름으로 상품을 검색하는 함수를 구현하세요. 여러 조건을 조합할 수 있는 검색 기능을 만들어봅니다.
💡 연습 과제 해답은 Scastie에서 직접 구현하고 테스트해보세요!
다음 단계#
기본 예제를 학습했다면 Scala 2와 3의 문법 차이를 비교해보세요.
- Scala 2 vs 3 비교 — 버전별 코드 비교