← Back to list

Type-Safe Kotlin: 원시값 포장, 일급 컬렉션, 그리고 언제 어떤 클래스를 선택할 것인가

Kotlin 코드를 열어보면 String, Int, List가 가득하다.

Eden in Tecoble · 2025-11-25 11:43 · 0 claps · 8.7 min read
#원시값-포장 #일급컬렉션 #value-class #data-class
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Type-Safe Kotlin: 원시값 포장, 일급 컬렉션, 그리고 언제 어떤 클래스를 선택할 것인가

Kotlin 코드를 열어보면 String, Int, List가 가득하다.

“모든 게 객체”라던 얘기는 어디 갔을까?

원시 타입 쓰는 건 문제가 아니다.

문제는 userId도 String, orderId도 String, 이메일도 String일 때다.

  • 이메일이 그냥 String
  • 가격이 그냥 Int
  • 주문 라인이 그냥 List

하나하나 의미는 중요한데, 타입은 아무것도 말해주지 않는다.

그래서 많은 개발자들이 원시값 포장(primitive wrapping)과 일급 컬렉션(first-class collection)을 쓰려고 한다.

그렇지만 이게 과하면 타입만 잔뜩 늘어나고 오히려 복잡해진다.

이 글은 그 균형점을 찾기 위한 글이다.

“value class를 언제 쓰고 언제 쓰지 말아야 하나요?” “data class vs value class 무엇을 선택해야 하나요?” “List는 언제 그냥 쓰고, 언제 일급 컬렉션으로 래핑해야 하나요?” “이게 과한 설계인지 판단하는 기준은 무엇인가요?”

Kotlin 문법 소개가 아니라, 실전에서 ‘타입을 어떻게 선택할지’ 에 대한 가이드를 제시하는 글이다.

📌 1. Primitive Obsession: 타입이 아무것도 말해주지 않을 때

아래 함수를 보자.

fun transferMoney(
    amount: Int,
    fromId: String, 
    toId: String
)

겉보기엔 괜찮아 보인다.

근데 아래와 같이 호출해도 컴파일 에러가 없다.

transferMoney(
    amount = -5000,              // 음수 금액
    fromId = "user@email.com",   // 이메일을 ID에
    toId = "product-123"         // 상품 ID를 계좌에
)

컴파일러는 아무 문제 없다고 한다.

왜냐? 타입이 전부 맞으니까.

근데 실행하면, 버그가 생긴다.

그렇다면 왜 이런 일이 생길까?

String은 그냥 String일 뿐이고, Int는 그냥 Int일 뿐이어서 그렇다.

Primitive Obsession이다.

📌 2. Kotlin이 제공하는 도구들

Primitive obsession을 피하려면 “그냥 String/Int/List” 대신 쓸 수 있는 도구가 필요하다.

Kotlin은 이미 몇 가지 괜찮은 선택지를 주고 있다.

  • 단일 값에 의미를 입히는 value class
  • 여러 필드를 하나의 상태로 묶는 data class
  • 행위를 담는 일반 class
  • 컬렉션을 도메인 개념으로 승격시키는 일급 컬렉션

하나씩 아주 짧게만 짚고 넘어가 보자.

2.1 Value Class — “단일 값에 의미와 규칙을 부여하고 싶을 때”

Kotlin 공식 문서에서는 value class를 이렇게 설명한다.

“Sometimes it is useful to wrap a value in a class to create a more domain-specific type.”

그냥 String 하나로 표현해도 되지만, 그 값이 도메인에서 중요한 의미를 갖는다면 명확한 타입으로 만들 수 있다는 뜻이다.

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "Invalid email" }
    }
}

여기서 하고 싶은건 단순하다.

첫째,

“이건 아무 문자열이 아니라 Email이다.” 라고 타입으로 의도를 드러내는 것.

둘째,

유효하지 않은 값이 절대 들어올 수 없도록 검증을 타입 생성 시점으로 끌어올리는 것이다.

value class는 성능 오버헤드도 거의 없다.

대신 제약이 있다.

프로퍼티는 오직 하나만 가질 수 있다.. 그래서 “Email”, “UserId”, “MoneyAmount” 같은 단일 값 자체가 하나의 개념일 때 가장 잘 맞는다.

반대로 필드가 여러 개라면 value class보다 data class를 쓰는것이 자연스럽다.

2.2 Data Class — “여러 필드를 하나의 도메인 모델로 묶고 싶을 때”

여러 속성이 모여 하나의 개념을 이룰때도 있다.

이럴때는 data class를 쓰는것이 더 자연스럽다.

data class UserProfile(
    val name: String,
    val email: Email,
    val age: Int
)

name, email, age를 따로 받으면 모호하지만, UserProfile이라는 이름 아래 묶이면 “유저 정보”라는 의미가 생긴다.

data class는

  • equals, hashCode, toString, copy를 자동으로 만들어주고
  • 값 비교나 복사를 자연스럽게 만들어준다

그래서 UI state, DTO, domain model 같은 “상태를 표현하는 타입”에서 자주 쓰인다.

행위가 중심인 타입이라면 data class 보다는 일반 class가 더 자연스럽다.

2.3 일반 Class — “행위 중심 객체 or 상태 + 메서드 조합”

도메인 로직을 갖는 객체는 data class가 아니다.

값 비교보다 “무엇을 하느냐"가 더 중요하기 때문이다.

class OrderProcessor(private val repository: OrderRepository) {
    fun process(order: Order) { … }
}

이런 타입은 다음 특징을 가진다.

  • 동일성(identity)이 중요하거나
  • 내부 상태가 바뀌거나
  • 의존성이 필요하거나
  • 행위(behavior)가 중심이거나

즉, “어떤 값을 가지고 있느냐”보다 “이 객체가 무슨 일을 하느냐”가 더 중요한 경우다.

그래서 data class가 아니라 일반 class가 더 자연스럽다.

2.4 List<T> vs 일급 컬렉션 — “리스트가 하나의 개념일 때”

아래 코드에는 List<T>가 너무 많다.

val items: List<CartItem>
val logs: List<HydrationLog>
val lines: List<OrderLine>

그런데 이 리스트들이 “그냥 배열”이 아닐 때가 많다. 장바구니 항목들, 주문 라인 집합, 하루의 기록들처럼 리스트 자체가 하나의 도메인 의미를 갖는 경우가 있다.

이럴 때 타입을 만들어주면 훨씬 명확해진다.

class CartItems(private val items: List<CartItem>) {
  val totalPrice: MoneyAmount
    get() = items.sumOf { it.price.value }.let(::MoneyAmount)

  fun add(item: CartItem) = CartItems(items + item)
}

명확한 장점이 있다.

  • “이 리스트가 무엇인지” 타입 이름만으로 드러나고
  • 합계 계산, 정렬, 필터링 같은 로직이 이곳으로 모이며
  • 외부에는 리스트의 내부 구조가 노출되지 않는다

이 패턴이 흔히 말하는 일급 컬렉션이다. 컬렉션도 하나의 도메인 객체로 취급하는 방식이다.

📌 3. 언제 어떤 클래스를 선택할까?’

value class, data class, 일반 class, 일급 컬렉션. 전부 쓸데가 있다. 문제는 언제 무엇을 쓰느냐다.

아래 기준은 경험적으로 가장 자주 쓰이는 선택 기준이다

3.1 value class가 적합한 상황

다음과 같은 값이라면 value class를 고려해볼 만하다.

  • 단일 값인데 도메인 의미가 뚜렷한 경우(ex.Email, UserId, OrderId, MoneyAmount )
  • 잘못 섞이면 큰 문제가 되는 경우 (ex. UserIdOrderId가 둘 다 String이면 컴파일러는 모른다)
  • 생성 시점 검증이 필요한 값
  • 런타임 오버헤드 없이 타입을 강화하고 싶은 경우

반대로, 이런 경우라면 value class는 오버엔지니어링이다.

  • pageIndex, retryCount처럼 도메인 의미가 약한 값
  • 감싸 놓고 하는 일이 없는 값
  • 타입 이름만 바뀌고 실속이 없는 경우

3.2 data class가 적합한 상황

3.2 data class가 잘 맞는 상황

data class는 여러 필드가 만나 하나의 상태를 이루는 경우 선택하면 된다.

  • UserProfile
  • Card
  • Order
  • HydrationLog

값 비교나 복사, 구조 분해가 필요한 곳에서도 data class가 자연스럽다.

다만 다음 같은 경우에는 data class가 어색하다.

  • 행위가 중심인 객체
  • 내부 상태가 바뀌거나 의존성을 갖는 객체
  • 동일성(identity)이 중요한 객체

이런 경우는 일반 class가 맞다.

3.3 일급 컬렉션이 적합한 상황

  • List를 감싸는 게 자연스러운 경우는 아래와 같다.
  • 리스트 자체가 하나의 도메인 개념일 때 (ex. OrderLines, CartItems, Transactions )
  • 컬렉션 전체에 규칙이 있을 때 (ex. 중복 금지, 최대 개수 제한, 정렬 기준 등 )
  • 합계/정렬/필터링 같은 로직이 중복될 때
  • 외부에 mutable 리스트를 노출하고 싶지 않을 때

하지만 리스트가 단순한 데이터 묶음이라면 굳이 감쌀 이유는 없다.

  • 화면에 한 번 뿌리고 끝나는 리스트
  • 도메인 의미가 없는 임시 리스트
  • 이름만 바꾼 빈 껍데기 래퍼 (StringList)

이런 경우는 그냥 List<T>로 두는 게 낫다.

3.4 그냥 primitive/List로 둬도 괜찮은 상황

  • 모든 걸 감싸는 것이 좋은 건 아니다. 다음 조건을 만족하면 원시값을 써도 전혀 문제 없다.
  • 함수 내부에서 잠깐 쓰이는 값
  • 도메인에서 이름조차 없거나 의미가 약한 값
  • 타입을 만들었더니 오히려 코드가 더 난해해진 경우
  • 팀 컨벤션에 맞지 않아 타입이 많아지면 혼란이 생기는 경우

도메인 의미가 거의 없는 값은 굳이 타입을 만들 필요가 없다.

4. Over-Engineering vs Under-Modeling

타입을 적극적으로 만드는 설계는 좋지만, 과하면 좋지 않다.

❌ 과한 설계(Over-Engineering)의 신호

  • 타입이 너무 많아져 IDE 없이는 읽기 어려움
  • 검증이나 로직이 거의 없는 빈 래퍼만 많음
  • “이 타입 왜 있는 거지?” 하는 의문이 생김
  • 팀원들이 타입 남발로 불편해함

❌ 부족한 설계(Under-Modeling)의 신호

  • 이메일/금액/아이디 같은 중요한 값이 전부 String
  • 검증 로직이 여러 곳에 중복
  • List 연산들이 여러 파일에 흩어짐
  • 잘못된 값이 들어와도 컴파일러가 전혀 잡아주지 않음

좋은 설계는 이 사이에서 결정된다. 모든 값을 포장하는 것도, 아무것도 포장하지 않는 것도 정답은 아니다.

Primitive wrapping과 일급 컬렉션은 단순한 문법 기술이 아니다. 도메인에서 중요한 개념을 코드로 옮기고, 컴파일러가 더 많은 잘못을 잡도록 만드는 도구다.

Kotlin은 이를 위해 여러 선택지를 제공한다.

  • value class
  • data class
  • 일반 class
  • 일급 컬렉션

모두 필요한 자리에서 쓰면 강력해진다. 하지만 “모든 값에 무조건 적용하자”는 접근은 오히려 복잡함을 만든다.

결국 기준은 단순하다.

타입은 정말 의미가 있을 때만 만드는 것이 좋다. 애매한 값까지 포장하면 오히려 코드만 복잡해진다.

이 원칙만 지켜도 코드는 더 읽기 편해지고, 실수는 줄어들며, 타입이 도메인의 의도를 자연스럽게 담아내게 된다.


메타데이터
post_id
7228f15f19eb
slug
원시값-포장과-일급-컬렉션-7228f15f19eb
url
https://medium.com/tecoble/%EC%9B%90%EC%8B%9C%EA%B0%92-%ED%8F%AC%EC%9E%A5%EA%B3%BC-%EC%9D%BC%EA%B8%89-%EC%BB%AC%EB%A0%89%EC%85%98-7228f15f19eb
canonical_url
https://medium.com/tecoble/%EC%9B%90%EC%8B%9C%EA%B0%92-%ED%8F%AC%EC%9E%A5%EA%B3%BC-%EC%9D%BC%EA%B8%89-%EC%BB%AC%EB%A0%89%EC%85%98-7228f15f19eb
author_url
https://medium.com/@devfeijoa
status
ok
fetched_at
2026-06-25 07:00:49