티스토리 뷰

우테코 레벨1 영화 예매 미션의 피드백 중에 value class와 관련된 피드백이 있었다.
value class 자체를 이번 미션에서 처음 써 보았다.
사용한 이유는 단순히 '미션 힌트에 원시값과 문자열을 의미 있는 객체로 포장한다' 가 있었기 때문이다.

value class를 사용해서 좋은 점이 무엇인지, 왜 사용하는지 모르고 그냥 사용했기 때문에
리뷰어의 피드백에 답변을 잘 할수가 없었다.

피드백 뿐만 아니라 레벨1 인터뷰에서도 value class와 관련된 질문에 답변을 제대로 하지 못하였다.
그래서 이번에 value class에 대해 알아보려고 한다.
Value class가 만들어진 이유
Sometimes it is useful to wrap a value in a class to create a more domain-specific type. However, it introduces runtime overhead due to additional heap allocations. Moreover, if the wrapped type is primitive, the performance hit is significant, because primitive types are usually heavily optimized by the runtime, while their wrappers don't get any special treatment. To solve such issues, Kotlin introduces a special kind of class called an inline class.
특정 값을 클래스로 감싸서 도메인에 특화된 타입을 만드는 것이 유용할 때가 있다. 하지만 이 방식은 추가적인 힙 할당을 발생시켜 런타임 오버헤드를 발생시킨다. 특히나 원시 타입을 감쌀 경우에는 런타임 최적화를 받지 못해 성능 저하가 심해진다. 이를 해결하기 위해 Kotlin은 인라인 클래스를 도입했다.
공식문서에서는 이렇게 말하고 있다. 근데 뭔 말인지 하나도 모르겠다. 하나씩 짚어가며 이해해보자.
1. 특정 값을 클래스로 감싸서 도메인에 특화된 타입을 만드는 것이 유용할 때가 있다.
그렇다면 어떤 상황에서 클래스로 감싸는 것이 유용한가?
객체에 대한 규칙이 필요할 때
영화 이름을 예시로 들어보자. 단순하게 String 타입으로 받는다면 영화 이름으로 빈 값이 들어올 수가 있다.
영화 이름이 비어있는 경우는 없기 때문에 클래스로 감싸서 검증을 하여 객체가 잘못된 값을 가지고 생성되는 경우를 차단한다.
class MovieName(
val name: String,
) {
init {
require(name.isNotBlank()) { "영화 이름은 비어있을 수 없습니다." }
}
}
동일한 타입을 연속해서 받아야 할 때 (타입 혼동 방지를 하기 위해서)
영화관 좌석을 예매하는 함수로 예를 들어보자.
fun reserveSeat(screenNumber: Int, row: Int, column: Int)
이 함수는 상영관의 번호 (screenNumber), 좌석의 행(row), 좌석의 열(column) 을 인자로 받고 있고, 모두 Int 타입이다.
만약 3 상영관의 5행 10열의 좌석을 예약하기 위해서라면 다음과 같이 함수를 호출해야 할 것이다.
reserveSeat(3,5,10) // 3 상영관의 5행 10열 좌석 예약
그런데 만약 인자 순서를 row,column,screenNumber 로 헷갈려 작성하게 된다면?
reserveSeat(5,10,3) // 5 상영관의 10행 3열 예약
컴파일 단계에서 에러를 잡아내지 못한다. 3개의 인자 모두 Int 타입으로 지정되어 있고, 모두 Int 타입으로 입력되었기 때문이다.
어? 그러면 인자에 이름을 붙여서 함수를 호출하면 되지 않나요?
reserveSeat(
screenNumber = 3,
row = 5,
column = 10,
)
이름을 붙인다면 순서가 바뀌는 혼동을 줄일 수는 있지만 이름을 붙였는지 안붙였는지는 개발자가 직접 확인해야 한다.
만약 까먹고 이름을 붙이지 않아도 정상 작동하며, 이 과정에서 순서가 바뀔 가능성은 여전히 존재한다.
그리고 이름을 붙였다고해도 잘못된 값이 입력될 일도 여전히 발생할 수 있다.
fun reserveSeat(screenNumber: ScreenNumber, row: SeatRow, column: SeatColumn) {
...
}
data class ScreenNumber(
val number: Int
) {
...
}
data class SeatRow(
val row: Int
) {
...
}
data class SeatColumn(
val column: Int
) {
...
}
reserveSeat(
screenNumber = ScreenNumber(3),
row = SeatRow(5),
column = SeatColumn(10),
)
각각 ScreenNumber, SeatRow, SeatColumn으로 감싸 사용하게 되면 순서가 바뀌어 잘못된 값이 입력되는 경우를 완전히 차단할 수 있다.
2. 하지만 이 방식은 추가적인 힙 할당을 발생시켜 런타임 오버헤드를 발생시킨다고 한다.
단순하게 String 타입의 name을 class로 감싼다고 가정해보자.
class로 감싸게 되면 이 클래스가 무엇인지에 대한 설명과 같은 정보(해시코드 등.. 이를 객체 헤더라고 부른다.)가 붙어서 용량이 커지게 된다.
[JVM] JVM 내부의 힙 객체 헤더(Heap Object Headers on JVM Internals)
1. JVM 내부의 힙 객체의 헤더 (Heap Object Header on JVM Internals) [ 객체의 메모리 레이아웃과 객체 헤더 ]객체의 메모리 레이아웃다음의 HotSpot JVM 객체 메모리 구조에서 보이듯이, 모든 자바 객체는 인
mangkyu.tistory.com
객체 헤더가 무엇인지 궁금하다면?
String 타입 하나를 감싸기 위해서 필요 이상으로 낭비를 발생시키게 되는 것을 런타임 오버헤드라고 한다.
class가 아니라 data class, object 모두 마찬가지로 객체 헤더가 같이 생성되기 때문에 필요 이상으로 용량을 차지하는 것은 마찬가지이다.
하지만 value class를 사용한다면 실행 시점에서 객체 헤더를 날려버리고, 감싸져 있는 값만 스택에 넣어버리기 때문에 용량 최적화가 가능하다.
그렇다면 value class를 사용하면 무조건 용량이 최적화가 되는건가요?
그건 또 아니다. 예외의 경우가 있다.
공식문서에서는 다음과 같이 설명하고 있다.
The Kotlin compiler will prefer using underlying types instead of wrappers to produce the most performant and optimized code. However, sometimes it is necessary to keep wrappers around.
Kotlin 컴파일러는 성능이 가장 뛰어나고 최적화된 코드를 생성하기 위해 래퍼 대신 기본 타입을 사용하는 것을 선호합니다. 하지만 래퍼를 유지해야 하는 경우도 있습니다.
interface I
@JvmInline
value class Foo(val i: Int) : I
fun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}
fun <T> id(x: T): T = x
fun main() {
val f = Foo(42)
asInline(f) // unboxed: used as Foo itself
asGeneric(f) // boxed: used as generic type T
asInterface(f) // boxed: used as type I
asNullable(f) // boxed: used as Foo?, which is different from Foo
// below, 'f' first is boxed (while being passed to 'id') and then unboxed (when returned from 'id')
// In the end, 'c' contains unboxed representation (just '42'), as 'f'
val c = id(f)
}
공식문서에 작성되어 있는 예시 코드다.
코드에는 제네릭, 인터페이스, Nullable 타입일 때 boxed 된다고 한다.
boxed는 박싱이라는 뜻으로, 객체 헤더가 포함되어 래퍼 상태로 힙에 올라간다는 뜻이며, 위에서 말한 예외 상황에 해당된다.
제네릭, 인터페이스, Nullable 의 공통점이 뭔데 예외 상황에 해당하는건가요?
3가지의 공통점은 순수한 값으로 동작할 수 없다는 것이다. 제네릭은 어떤 타입이든 다 받을 수 있고, 실행할 때 전부 Object 타입으로 바뀌어 버린다. 인터페이스는 누구의 코드를 실행할 지 찾아야 한다, Nullable의 경우에는 값이 없을 수도 있다. 이런 이유 때문에 이들은 반드시 객체에 대한 주소를 가져야 한다.
3. 특히나 원시 타입을 감쌀 경우에는 런타임 최적화를 받지 못해 성능 저하가 심해진다.
앞서 설명한 객체 헤더와 이어지는 이야기다.
원시 타입인 Int는 4바이트이다. 하지만 이걸 클래스로 감싸게 되면 16바이트의 객체 헤더가 붙어 총 20바이트가 되게 된다.
단순한 Int 값 하나를 다루기 위해 용량이 기하급수적으로 늘어나는 것이다.
value class의 특징
1. 1개의 프로퍼티만 가질 수 있다.

2개 이상의 파라미터를 가지게 되면 컴파일 에러가 발생한다.
2. 런타임에 인스턴스가 생성되지 않고, 해당 프로퍼티의 값으로 대체된다.
fun main() {
val movieName = MovieName("프로젝트 해일메리")
}
@JvmInline
value class MovieName(
val name: String,
) {
init {
require(name.isNotBlank())
}
}
위 코드를 실행하게 되면 MovieName 인스턴스가 생성되는 것이 아니라, MovieName의 name 파라미터가 String 타입이기 때문에, String 타입으로 대체되게 된다. 더 정확하게 확인하기 위해, MovieName을 선언한 곳의 바이트 코드를 확인해보자.

val movieName = MovieName("프로젝트 해일메리")
// 위 코드의 바이트 코드
LINENUMBER 3 L0 // 코드의 3번째 줄, movieName이 선언된 위치를 의미한다.
LDC "\ud504\ub85c\uc81d\ud2b8 \ud574\uc77c\uba54\ub9ac" // "프로젝트 헤일메리" 의 유니코드 값
// MovieName의 생성자를 정적 메서드로 호출하여 String을 인자로 받아 String으로 리턴한다.
INVOKESTATIC MovieName.constructor-impl (Ljava/lang/String;)Ljava/lang/String;
ASTORE 0 // 위에서 받은 String 데이터를 0번 지역변수 칸에 저장해라.
아직 잘 와닿지 않는가? 그렇다면 MovieName을 일반 class로 선언했을 때 바이트 코드를 같이 확인해보자.

LINENUMBER 3 L0
NEW MovieName // 이게 추가됨, 새로운 MovieName 객체를 생성해라!!!!!!!!!!!!!
DUP // 새로 만든 MovieName 객체를 복사해라!!!!!!!
LDC "\ud504\ub85c\uc81d\ud2b8 \ud574\uc77c\uba54\ub9ac"
INVOKESPECIAL MovieName.<init> (Ljava/lang/String;)V
ASTORE 0
NEW MovieName 키워드가 추가되었다.
value class와는 다르게 일반 class는 새로운 객체를 만든다는 증거다.
3. init 블럭, 부 생성자 사용이 가능하다.
@JvmInline
value class Person(private val fullName: String) {
init {
require(fullName.isNotEmpty()) {
"이름은 비어있을 수 없습니다."
}
}
constructor(firstName: String, lastName: String) : this("$firstName $lastName") {
require(lastName.isNotBlank()) {
"성은 비어있을 수 없습니다."
}
}
}
4. 백킹 필드, lateinit을 사용할 수 없다.
백킹 필드란 getter, setter에 추가적인 로직을 더하거나, 프로퍼티 값이 변경될 때마다 특정 동작을 실행할 때 유용하다. 직접 백킹 필드를 선언할 수 없고, 코틀린이 필요하다고 판단한 경우에 자동으로 생성한다. 코틀린이 필요하다고 판단하는 경우는 다음과 같다.
- 프로퍼티가 기본 getter나 setter를 사용할 때
- 커스텀 접근자 중 최소 한 곳에서 field 키워드를 명시적으로 사용할 때
class Scoreboard {
var score: Int = 0
set(value) {
field = value // 실제 메모리 공간(백킹 필드)에 값을 저장
// 값을 업데이트할 때 로그 출력 동작을 추가함
println("Score updated to $field")
}
}
백킹 필드에 대한 내용은 아래 공식문서를 통해 확인할 수 있다.
Properties | Kotlin
kotlinlang.org
그렇다면 왜 백킹 필드와 lateinit을 사용할 수 없을까?
두 가지를 허용하는 순간, value class의 존재 이유인 객체 래퍼 제거를 할 수 없기 때문이다.
위에서 value class는 실행하는 순간 클래스를 벗기고 안에있는 값으로 대체한다고 말했다.
그런데 백킹 필드를 허용하게 되면 내부 프로퍼티에 데이터가 1개 추가되어 총 2개가 된다.
JVM에서는 2개 이상의 독립된 데이터를 1개의 원시타입으로 묶어 처리할 수 없기 때문에, 이 2개를 묶기 위하여 객체를 생성하여 관리하게 된다.
lateinit도 마찬가지다. lateinit을 사용하게 되면 백킹 필드를 요구하게 된다.
그리고 lateinit을 선언하려면 결국 var로 선언해야 한다. 하지만 value class는 런타임에 단일 값으로 취급되어야 하기 때문에 val로 선언하는 것을 강제한다. 때문에 lateinit을 사용할 수 없다.

5. 인터페이스 구현이 가능하다.
fun main() {
val movieName = MovieName("프로젝트 해일메리")
println(movieName.prettyPrint())
}
interface Printable {
fun prettyPrint(): String
}
@JvmInline
value class MovieName(
val name: String,
) : Printable {
init {
require(name.isNotBlank())
}
override fun prettyPrint(): String = "이 영화는 $name 입니다."
}
6. 다른 클래스를 상속받을 수 없다.
클래스를 상속받게 되면 부모 클래스가 가진 데이터를 모두 물려받게 된다.
그렇게 되면 데이터가 2개 이상이 되기 때문에 객체 헤더가 붙게 되기 때문에 value class를 사용하는 이유가 없어진다.
따라서 컴파일러에서 하지 못하도록 컴파일 에러를 발생시킨다.

마무리
지금까지 value class에 대해 알아보았다.
처음에는 value class로 감싸면 클래스명 만으로 의미 전달이 쉬울 것이라고 생각했는데, 의미 전달은 변수명으로도 충분히 가능하다고 생각한다. 때문에 모든 값을 객체로 감쌀 필요는 없다고 생각한다.
위에서 정리한 것처럼, 여러 개의 동일한 타입이 함수 파라미터에 들어가는 경우 또는 특정한 제약조건이 있는 데이터의 경우는 value class로 감싸면 좋을 것 같다.
참고 자료
Inline value classes | Kotlin
kotlinlang.org
'코딩 > Kotlin' 카테고리의 다른 글
| [Kotlin] 코루틴 - async 와 Deferred (2) | 2026.06.03 |
|---|---|
| [Kotlin] enum class, sealed class, sealed interface (0) | 2026.03.27 |
| [Kotlin] val 과 const val (0) | 2026.03.12 |
| [Kotlin] 오류 처리와 테스트 (0) | 2026.02.23 |
| [백준/Kotlin] 1120. 문자열 (0) | 2026.02.23 |
- Total
- Today
- Yesterday
- 키보드
- 운영체제
- jwt
- Vercel
- 문맥교환
- 마인크래프트 플러그인 개발
- OS
- 토큰
- 마인크래프트 엑스레이방지
- 개발
- 마인크래프트
- MongoDB
- 플러터
- 도메인
- 엑스레이 플러그인
- context switching
- 쿠키
- 마인크래프트 플러그인
- 웹
- ㅇ
- scaffold
- keyboard
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |