
1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를 다뤘고, 3편에서는 함수 설계를 살펴봤습니다. 4편에서는 null safety를 정리했습니다.
이번 5편에서는 모델링을 다룹니다. 주인공은 data class와 sealed class입니다.
실무에서 코드는 문법보다 모델이 먼저 읽힙니다. 클래스 이름, 상태 구조, 결과 타입이 흐리면 함수가 아무리 짧아도 코드가 쉽게 이해되지 않습니다.
반대로 모델이 분명하면 코드가 한결 단순해집니다. null 체크가 줄어듭니다. 조건문도 짧아집니다. 리뷰도 쉬워집니다.
클린코드는 좋은 모델에서 시작합니다. data class는 데이터를 선명하게 만들고, sealed class는 가능한 상태를 닫아서 흐름을 분명하게 만듭니다.
코틀린은 이 두 도구를 아주 잘 제공합니다. data class는 읽기 좋은 데이터 구조를 만들기에 좋고, sealed class는 유한한 상태와 결과를 표현하기에 좋습니다.
다만 아무 데나 붙인다고 좋은 코드는 아닙니다. 이번 글에서는 문법 설명보다 실무 기준을 먼저 잡겠습니다. 언제 data class를 쓰면 좋은지, 언제 일반 클래스로 두는 편이 나은지, 왜 sealed class가 null과 Boolean 플래그를 줄이는지 차근차근 보겠습니다.
시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편도 함께 보시면 좋습니다.
- 왜 모델링이 클린코드에서 중요한가
- data class를 써야 하는 상황
- 모든 클래스를 data class로 만들면 안 되는 이유
- data class에서 자주 놓치는 함정
- sealed class가 필요한 이유
- sealed class와 enum은 어떻게 다를까
- data class와 sealed class를 함께 쓰는 패턴
- null과 상태 플래그를 sealed hierarchy로 바꾸기
- 실무 설계 기준
- 실무에서 자주 하는 실수
- 체크리스트
- 마무리
왜 모델링이 클린코드에서 중요할까요?
함수는 동작을 드러냅니다. 모델은 도메인의 규칙을 드러냅니다.
예를 들어 주문 조회 결과를 아래처럼 표현할 수 있습니다.
data class OrderResponse(
val isSuccess: Boolean,
val order: Order?,
val errorMessage: String?,
)
겉보기에는 간단합니다. 하지만 읽는 순간 질문이 생깁니다.
isSuccess = true인데order가null이면 어떻게 될까요?isSuccess = false인데errorMessage가 없으면 괜찮을까요?order와errorMessage가 둘 다 있으면 어느 쪽이 맞을까요?
이 모델은 문법상 맞습니다. 하지만 상태가 열려 있습니다. 그래서 호출하는 쪽이 매번 방어 코드를 써야 합니다.
if (response.isSuccess && response.order != null) {
showOrder(response.order)
} else if (!response.errorMessage.isNullOrBlank()) {
showError(response.errorMessage)
} else {
showUnknownError()
}
이런 코드는 읽는 사람을 지치게 만듭니다. “정상 상태가 무엇인지”를 타입이 알려주지 않기 때문입니다.
좋은 모델은 가능한 경우를 줄여줍니다. 그리고 말이 안 되는 조합을 애초에 만들기 어렵게 합니다.
data class를 써야 하는 상황
코틀린 공식 문서 기준으로 data class는 주로 데이터를 담는 용도에 맞습니다. 컴파일러는 주 생성자에 선언된 프로퍼티를 바탕으로 equals(), hashCode(), toString(), componentN(), copy()를 자동으로 만들어줍니다. 그래서 읽기 좋은 데이터 구조를 만들 때 아주 유용합니다.
예를 들어 아래 모델은 의도가 분명합니다.
data class Product(
val id: Long,
val name: String,
val price: Int,
)
이 선언만으로도 디버깅과 비교가 쉬워집니다.
val product = Product(
id = 1L,
name = "키보드",
price = 59000,
)
println(product)
// Product(id=1, name=키보드, price=59000)
특히 DTO, 요청/응답 모델, 화면에 그릴 뷰 모델, 값 객체처럼 데이터 자체가 중심인 경우에 잘 맞습니다.
좋은 예: 요청 모델
data class CreateOrderRequest(
val userId: Long,
val productId: Long,
val quantity: Int,
)
좋은 예: 화면 모델
data class UserSummary(
val id: Long,
val displayName: String,
val profileImageUrl: String?,
)
좋은 예: 값 객체
data class Money(
val amount: Int,
val currency: String,
)
이런 모델은 “무엇을 담는가”가 중요합니다. 그래서 data class의 자동 생성 기능이 큰 도움이 됩니다.
copy()가 코드를 단순하게 만드는 경우
상태 일부만 바꿔 새 값을 만들고 싶을 때도 좋습니다.
data class UserProfile(
val id: Long,
val nickname: String,
val email: String,
)
val profile = UserProfile(1L, "mango", "mango@example.com")
val updatedProfile = profile.copy(nickname = "mango-dev")
불변 객체를 유지하면서 변경을 표현할 수 있습니다. 이런 패턴은 테스트와 리팩터링에도 유리합니다.
모든 클래스를 data class로 만들면 안 되는 이유
data class가 편하다고 해서 모든 클래스에 붙이면 오히려 설계가 흐려집니다.
핵심은 이 질문입니다. 이 클래스는 데이터가 중심인가, 아니면 행위와 규칙이 중심인가?
아래 예제를 보겠습니다.
data class BankAccount(
val id: Long,
var balance: Int,
) {
fun deposit(amount: Int) {
balance += amount
}
fun withdraw(amount: Int) {
require(balance >= amount) { "잔액이 부족합니다." }
balance -= amount
}
}
처음 보면 나쁘지 않아 보입니다. 하지만 이 클래스는 단순 데이터 묶음이라기보다 행위와 상태 변화가 핵심입니다.
게다가 data class는 값 비교와 복사를 쉽게 열어줍니다. 도메인 엔티티처럼 정체성이 중요한 객체에는 이 점이 오히려 혼란을 만들 수 있습니다.
val account1 = BankAccount(id = 100L, balance = 1000)
val account2 = account1.copy()
println(account1 == account2) // true
이 결과가 항상 좋은 것은 아닙니다. 계좌 같은 객체는 단순히 필드 값이 같다고 같은 의미라고 보기 어려울 수 있기 때문입니다.
이럴 때는 일반 클래스로 두고, 필요한 행위와 불변식을 더 분명하게 드러내는 편이 낫습니다.
class BankAccount(
val id: Long,
private var balance: Int,
) {
fun deposit(amount: Int) {
require(amount > 0) { "입금 금액은 0보다 커야 합니다." }
balance += amount
}
fun withdraw(amount: Int) {
require(amount > 0) { "출금 금액은 0보다 커야 합니다." }
require(balance >= amount) { "잔액이 부족합니다." }
balance -= amount
}
fun currentBalance(): Int = balance
}
정리하면 이렇습니다. data class는 데이터를 표현할 때 강력합니다. 하지만 객체의 본질이 행위와 규칙이라면 일반 클래스가 더 잘 맞을 수 있습니다.
data class에서 자주 놓치는 함정
1. 주 생성자 밖의 프로퍼티는 자동 생성 대상에서 빠집니다
코틀린 공식 문서 기준으로 자동 생성되는 함수는 주 생성자에 선언된 프로퍼티만 사용합니다. 이 사실을 놓치면 비교나 로그 출력이 의도와 다르게 보일 수 있습니다.
data class Person(
val name: String,
) {
var age: Int = 0
}
val p1 = Person("Jane").apply { age = 10 }
val p2 = Person("Jane").apply { age = 20 }
println(p1 == p2) // true
println(p1) // Person(name=Jane)
age는 클래스 안에 있지만, equals()와 toString()에는 반영되지 않습니다. 이 동작을 모르고 쓰면 버그처럼 느껴질 수 있습니다.
의미 있는 상태라면 주 생성자로 올리는 편이 좋습니다.
data class Person(
val name: String,
val age: Int,
)
2. copy()는 깊은 복사가 아니라 얕은 복사입니다
이 부분은 실무에서 정말 자주 놓칩니다. 코틀린 공식 문서도 copy()가 shallow copy라고 분명히 설명합니다.
data class Team(
val name: String,
val members: MutableList,
)
val original = Team("backend", mutableListOf("mango"))
val copied = original.copy()
copied.members.add("june")
println(original.members) // [mango, june]
println(copied.members) // [mango, june]
두 객체가 별개처럼 보여도 내부 리스트 참조는 공유됩니다. 그래서 copy()를 깊은 복사처럼 믿고 쓰면 위험합니다.
가능하면 mutable 컬렉션보다 불변 컬렉션을 우선하는 편이 낫습니다.
data class Team(
val name: String,
val members: List,
)
val original = Team("backend", listOf("mango"))
val copied = original.copy(members = original.members + "june")
3. mutable 프로퍼티가 많아지면 data class의 장점이 줄어듭니다
data class는 불변 데이터와 잘 어울립니다. 그런데 주 생성자에 var가 많아지면 상태 추적이 어려워집니다.
data class Order(
var status: String,
var paid: Boolean,
var shipped: Boolean,
)
이런 구조는 값 객체라기보다 작은 상태 머신에 가깝습니다. 그런데 모델은 그 사실을 잘 드러내지 못합니다.
이럴 때는 sealed class나 더 명확한 타입으로 상태를 표현하는 편이 좋습니다. 잠시 뒤에 다시 보겠습니다.
4. Pair와 Triple을 남발하지 마세요
코틀린 공식 문서도 대부분의 경우 Pair와 Triple보다 이름 있는 data class가 읽기 좋다고 설명합니다.
fun fetchUser(): Pair<String, Int> {
return "mango" to 3
}
이 코드는 첫 번째 값이 무엇이고 두 번째 값이 무엇인지 바로 알기 어렵습니다.
data class UserRank(
val nickname: String,
val level: Int,
)
fun fetchUser(): UserRank {
return UserRank(
nickname = "mango",
level = 3,
)
}
이름이 생기는 순간 코드 해석 비용이 크게 줄어듭니다.
sealed class가 필요한 이유
sealed class와 sealed interface는 가능한 하위 타입을 제한합니다. 코틀린 공식 문서 기준으로 direct subclass는 같은 패키지와 모듈 안에 있어야 합니다. 그래서 컴파일 시점에 가능한 경우를 더 잘 알 수 있습니다.
실무에서 이 점이 특히 빛나는 곳은 상태, 결과, 에러 모델입니다.
예를 들어 화면 상태를 단순 문자열과 Boolean으로 표현하면 금방 꼬입니다.
data class ScreenState(
val isLoading: Boolean,
val data: List<Product>?,
val errorMessage: String?,
)
이 모델은 아래 같은 이상한 조합도 허용합니다.
- 로딩 중인데 데이터도 있음
- 에러 메시지가 있는데 데이터도 있음
- 아무 값도 없는데 로딩도 아님
이 상태를 sealed class로 바꾸면 모델이 훨씬 분명해집니다.
sealed interface ScreenState {
data object Loading : ScreenState
data class Success(val products: List<Product>) : ScreenState
data class Error(val message: String) : ScreenState
}
이제 가능한 경우는 세 가지뿐입니다. 타입이 상태 공간을 닫아준 셈입니다.
호출하는 쪽도 간단해집니다.
fun render(state: ScreenState) {
when (state) {
ScreenState.Loading -> showLoading()
is ScreenState.Success -> showProducts(state.products)
is ScreenState.Error -> showError(state.message)
}
}
코틀린 공식 문서처럼 sealed hierarchy를 when과 함께 쓰면, 가능한 경우를 모두 처리했는지 컴파일러가 더 잘 도와줄 수 있습니다. 이 점이 큰 장점입니다.
sealed class와 enum은 어떻게 다를까요?
둘 다 경우의 수를 제한할 수 있습니다. 하지만 표현력은 다릅니다.
enum class는 고정된 상수 집합에 적합합니다.
enum class OrderStatus {
CREATED,
PAID,
SHIPPED,
CANCELED,
}
이 정도면 충분한 경우가 많습니다. 상태마다 추가 데이터가 필요 없다면 enum이 더 단순합니다.
하지만 상태마다 담아야 할 데이터가 다르면 enum만으로는 부족해집니다.
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failure(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
여기서 Success는 거래 ID가 필요하고, Failure는 실패 사유가 필요합니다. 이런 구조는 sealed 쪽이 훨씬 잘 맞습니다.
정리하면 이렇게 보시면 됩니다.
- enum: 경우의 수는 작고, 각 항목이 거의 같은 형태일 때
- sealed class / sealed interface: 경우마다 데이터와 구조가 다를 때
data class와 sealed class를 함께 쓰는 패턴
실무에서는 둘을 따로보다 함께 쓰는 경우가 더 많습니다. sealed hierarchy의 각 분기를 data class나 data object로 표현하면 읽기 좋은 구조가 나옵니다.
예를 들어 API 호출 결과를 아래처럼 만들 수 있습니다.
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Failure(val message: String) : ApiResult<Nothing>
data object Loading : ApiResult<Nothing>
}
이 패턴의 장점은 분명합니다.
- 성공 시에는 반드시 데이터가 있습니다.
- 실패 시에는 실패 정보가 있습니다.
- 로딩 상태는 별도 타입으로 분리됩니다.
- 말이 안 되는 조합을 만들기 어렵습니다.
사용하는 쪽도 짧고 명확해집니다.
fun handleResult(result: ApiResult<List<Product>>) {
when (result) {
ApiResult.Loading -> showLoading()
is ApiResult.Success -> showProducts(result.data)
is ApiResult.Failure -> showError(result.message)
}
}
코틀린 문서에서도 data object가 sealed hierarchy에서 data class와 함께 잘 어울린다고 설명합니다. Loading, Idle, EndOfFile 같은 단일 상태에 특히 잘 맞습니다.
도메인 규칙을 타입으로 올리기
회원 인증 결과도 비슷하게 표현할 수 있습니다.
sealed interface LoginResult {
data class Success(val userId: Long, val accessToken: String) : LoginResult
data class InvalidInput(val message: String) : LoginResult
data class Failure(val message: String) : LoginResult
}
이 구조는 “성공했지만 토큰은 없음” 같은 애매한 상태를 줄여줍니다. 그래서 서비스 코드도 깔끔해집니다.
fun login(email: String, password: String): LoginResult {
if (email.isBlank() || password.isBlank()) {
return LoginResult.InvalidInput("이메일과 비밀번호를 확인해주세요.")
}
val user = authService.authenticate(email, password)
?: return LoginResult.Failure("로그인에 실패했습니다.")
return LoginResult.Success(
userId = user.id,
accessToken = user.accessToken,
)
}
null과 상태 플래그를 sealed hierarchy로 바꾸기
클린코드에서 자주 보는 냄새가 있습니다. Boolean 플래그와 nullable 프로퍼티가 한 모델에 잔뜩 모여 있는 경우입니다.
아래 예제를 보겠습니다.
data class UploadState(
val isUploading: Boolean,
val progress: Int?,
val fileUrl: String?,
val errorMessage: String?,
)
이 모델은 가능한 조합이 너무 많습니다. 그래서 코드 곳곳에 분기와 null 체크가 따라옵니다.
if (state.isUploading && state.progress != null) {
showProgress(state.progress)
} else if (state.fileUrl != null) {
showUploaded(state.fileUrl)
} else if (state.errorMessage != null) {
showError(state.errorMessage)
}
이럴 때는 상태를 분리하는 편이 훨씬 읽기 좋습니다.
sealed interface UploadState {
data object Idle : UploadState
data class Uploading(val progress: Int) : UploadState
data class Success(val fileUrl: String) : UploadState
data class Failure(val message: String) : UploadState
}
fun render(state: UploadState) {
when (state) {
UploadState.Idle -> showIdle()
is UploadState.Uploading -> showProgress(state.progress)
is UploadState.Success -> showUploaded(state.fileUrl)
is UploadState.Failure -> showError(state.message)
}
}
코드 길이는 조금 늘었지만 해석 비용은 크게 줄었습니다. 이 차이가 중요합니다.
클린코드는 줄 수 경쟁이 아닙니다. 잘못된 상태를 만들기 어렵게 하는 설계가 더 중요합니다.
실무 설계 기준
1. 데이터가 중심이면 data class를 우선 검토하세요
요청 모델, 응답 모델, 화면 모델, 값 객체는 대체로 data class와 잘 맞습니다.
2. 상태와 결과는 sealed class 또는 sealed interface를 먼저 떠올리세요
성공, 실패, 로딩, 없음 같은 경우를 표현할 때 특히 좋습니다.
3. 상태마다 필요한 데이터가 다르면 enum보다 sealed가 낫습니다
enum 하나에 억지로 nullable 필드를 붙이기 시작하면 모델이 금방 흐려집니다.
4. mutable 상태가 많으면 data class를 다시 의심하세요
var가 많은 data class는 값 객체보다 가변 레코드에 가까워집니다. 이럴 때는 책임을 다시 나누는 편이 좋습니다.
5. when에서 무의미한 else를 남발하지 마세요
sealed hierarchy를 쓰는 이유 중 하나가 빠진 분기를 컴파일 타임에 잡기 위해서입니다. 별 의미 없는 else가 들어가면 그 장점이 약해집니다.
fun render(state: ScreenState) {
when (state) {
ScreenState.Loading -> showLoading()
is ScreenState.Success -> showProducts(state.products)
is ScreenState.Error -> showError(state.message)
}
}
가능한 경우가 다 드러나 있다면 이 형태가 더 낫습니다.
6. 이름 없는 튜플보다 이름 있는 모델을 택하세요
Pair와 Triple은 빠르게 쓰기에는 편합니다. 하지만 장기적으로는 이름 있는 모델이 더 읽기 쉽습니다.
실무에서 자주 하는 실수
실수 1. 모든 DTO를 그대로 도메인 모델로 쓰기
외부 응답 모델은 nullable 필드가 많을 수 있습니다. 그 구조를 그대로 서비스 내부까지 끌고 오면 핵심 로직이 지저분해집니다.
경계에서 한 번 정리하세요. 그리고 내부에서는 더 분명한 모델을 쓰는 편이 좋습니다.
data class UserApiResponse(
val id: Long?,
val nickname: String?,
val email: String?,
)
data class User(
val id: Long,
val nickname: String,
val email: String,
)
fun UserApiResponse.toDomain(): User {
return User(
id = requireNotNull(id) { "id가 없습니다." },
nickname = requireNotNull(nickname) { "nickname이 없습니다." },
email = requireNotNull(email) { "email이 없습니다." },
)
}
실수 2. sealed를 썼지만 결국 else로 다 덮어버리기
이렇게 하면 타입이 주는 도움을 반쯤 버리게 됩니다.
fun handle(result: ApiResult<String>) {
when (result) {
is ApiResult.Success -> println(result.data)
else -> println("처리 실패")
}
}
이 코드는 새 하위 타입이 추가되어도 눈치채기 어렵습니다. 분기를 명시적으로 적는 편이 더 낫습니다.
실수 3. data class 하나에 여러 역할을 몰아넣기
저장용 모델, 응답용 모델, 화면용 모델을 하나로 합치면 점점 nullable과 플래그가 늘어납니다.
모델은 역할별로 나누는 편이 좋습니다.
실수 4. sealed hierarchy를 너무 크게 만들기
모든 상황을 한 sealed class에 밀어 넣으면 오히려 복잡해집니다. 한 hierarchy는 한 관심사에 집중하는 편이 좋습니다.
예를 들어 화면 상태와 네트워크 결과를 한 타입으로 합치기보다, 각각의 책임에 맞게 나누는 편이 읽기 쉽습니다.
체크리스트
- 이 클래스는 데이터가 중심인가, 행위가 중심인가
- 주 생성자에 들어간 프로퍼티만 자동 생성 대상이라는 점을 알고 있는가
copy()를 깊은 복사처럼 오해하고 있지 않은가Pair,Triple로 이름을 숨기고 있지 않은가- Boolean 플래그와 nullable 필드 조합으로 상태를 억지로 표현하고 있지 않은가
- 유한한 상태라면 sealed hierarchy로 바꿀 수 없는가
when에서 가능한 분기를 명시적으로 처리하고 있는가
마무리
data class와 sealed class는 코틀린다운 모델링의 핵심입니다. 둘 다 자주 쓰이는 문법입니다. 하지만 정말 중요한 것은 “어디에 쓰느냐”입니다.
data class는 데이터를 선명하게 드러낼 때 강합니다. sealed class는 가능한 상태를 닫고 흐름을 분명하게 만들 때 강합니다.
이 둘을 잘 쓰면 코드가 짧아지기보다 먼저 명확해집니다. 그리고 그 명확함이 유지보수 비용을 크게 낮춰줍니다.
다음 편에서는 extension function과 scope function을 어떻게 읽기 좋게 써야 하는지 살펴보겠습니다. 코틀린다운 문법이 왜 가독성을 높이기도 하고, 반대로 흐리기도 하는지 실무 예제로 정리해보겠습니다.