0. 개요
블랙잭 미션 사이클2에서, 리뷰어 백호가 record 사용 이유에 대해 물어봤다.
이에 대한 나의 생각과 그로부터 파생된 내용들을 그리고 언제 record를 사용할 지 나만의 기준을 정의해보려고 한다.
1. 불변 클래스
1 - 1. 불변성이 필요한 이유.
코드는 사람이든 AI이든 언제나 추가하고 삭제할 수 있다.
개발은 항상 가변적이다.
그렇기에, 불변 요소를 찾아야한다. 그리고 불변 요소를 찾았다면, 코드로 구현해내야한다.
그렇지 않으면, 모든 요소가 변경될 수 있고, 고려할 사항이 많아지기 때문이다!
1 - 2. 자바에서는 어떻게 불변 클래스를 만들 수 있을까
자바에서는 불변에 친숙한 final이라는 제어자를 제공한다.
final을 필드에 적용하면, 한 번 초기화된 필드를 다시 정의할 수 없다.
final을 클래스에 적용하면, 해당 클래스를 상속할 수 없다.
public final class FinalNumber {
private final int symbol;
public FinalNumber(int symbol) {
this.symbol = symbol;
}
public int number() {
return this.symbol;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
FinalNumber that = (FinalNumber) o;
return symbol == that.symbol;
}
@Override
public int hashCode() {
return Objects.hashCode(symbol);
}
@Override
public String toString() {
return String.valueOf(this.symbol);
}
}
이런 식으로 작성한다면, FinalNumber를 생성하고, 내부 값을 수정할 수 없다.
1 - 3. 불변 클래스의 장점.
내가 생각하는 불변 클래스의 장점은 다음과 같다.
1 - 3 - 1. 불변 보장 그 자체로 이점이다.
불변은 값의 안정성을 보장하기 때문에, 그 자체적으로 이득이다.
객체가 생성되고, 파괴되기까지 값은 계속 불변이다.
어디서든 사용하더라도, 같은 값이라는 걸 보장할 수 있고, 이는 여러 스레드에서 사용해도 괜찮다는 뜻이다.
1 - 3 - 2. 값 자체를 비교할 수 있다.
나는 getter를 수정의 의도가 없는 조회 혹은 출력에 대해서만 사용한다.
그러면 값을 가지고 있는 불변 클래스를 비교하기 힘들어진다.
이때, eqauls()와 hashCode()를 구현한다면 위 단점을 상쇄할 수 있다.
즉, getter로 내부 값을 꺼내지 않고, 객체끼리 동등성 비교를 할 수 있다는 것이다.


1 - 3 - 3. 테스트 코드 작성에 큰 도움이 된다.
특히 불변 객체는 테스트 코드 작성에 큰 도움이 된다.
블랙잭 미션 사이클1에서는 불변 객체를 만들어놓고, 테스트 성공 여부는 getter를 사용해서 검증했었다.
생각을 해보니, 불변 객체끼리 비교를 하면 해결이었다.
과거 테스트 코드를 위한 getter를 사용해야하나? 고민했는데, 지금은 불변 객체끼리 비교할 수 없나? 라는 선택지를 알게되었다.
1 - 3 - 4. 응집도 증가
불변 객체는 주로 Name, Number, Lotto와 같이 서비스 정책에 따르는 개념에 적용한다.
이때, 이름은 5글자 이하이거나, 숫자는 1-45 사이의 숫자이거나와 같은 검증을 불변 객체 내부에 둘 수 있다.
객체가 자신의 상태를 활용해 스스로 결정하는 것.
응집도가 올라간다.
응집도가 올라가면, 문제가 생겼을 때, 수정해야할 부분이 줄어든다.
이름이 고유해야할 경우, Name의 일급 컬렉션인 UniqueNames라는 불변객체가 있다고 하자.
만약 이름 검증에서 개발자의 의도대로 동작하지 않는다면,
이름 글자수 제한은 Name에서 확인하면 되고,
이름의 고유성은 UniqueNames에서 확인하면 된다.
InputParser에서 유틸 함수로 진행하는 크루도 많이 있었다.
그러나 이는 응집도가 비교적 낮다고 생각한다.
이 경우, 이름 글자수 제한은 Name에서 확인하고 이름의 고유성은 InputParser에서 확인해야하기 때문이다.
이는 '이름'이라는 도메인에 접근할 때, 어느 경우는 불변객체를 어느 경우는 유틸함수를 찾아야하는 비일관성을 제공한다.
1 - 4. 불변 클래스의 단점
불변성을 띄는 요소를 불변 클래스로 만드는 건 위와 같은 장점이 있다.
그러나, 단점이 존재한다.
1 - 4 - 1. 작성할 코드가 많다.
단순히 final을 추가하는 것 말고도 equals(), hashCode() 등 추가적으로 구현해야할 코드가 존재한다.
1 - 4 - 2. getter
정확히 이 단점은 불변 클래스라기 보다는 getter를 지양해야하는 이유에 속한다.
위 예시는 원시 타입(int)이기 때문에 상관없지만 필드가 컬렉션이라면?
getter로 필드를 그대로 반환했을 때, 외부에서 수정될 위험이 존재한다.
컬렉션에 final을 붙이더라도, 참조를 바꾸지 못할 뿐, 컬렉션에 값을 수정하는 행위는 막을 수 없기 때문이다.
여기서 파생적으로 방어적 복사를 알아볼 필요성도 생긴다.
2. record
자바 14이후 불변 클래스를 위한 record가 추가되었다.
레코드는 헤더부분과 바디 부분으로 나누어져있으며, 헤더에는 record 클래스가 가지는 필드를 정의한다.
FinalNumber를 record로 표현하면 다음과 같다.
public record Number(
int symbol
) {
}
record는 최종적으로 어떻게 변하게 될까?
Compiled from "Number.java"
public final class Number extends java.lang.Record {
private final int symbol;
public Number(int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int symbol();
}
Number.java를 컴파일한 Number.class를 javap 명령어로 디스어셈블한 결과이다.
여기서 확인할 수 있는 건 record에서는 int symbol만 헤더에 추가했는데 다양한 접근 제어자와 메서드가 생겼다는 사실이다.
2 - 1. record의 장점
디스어셈블 결과를 바탕으로 다음과 같은 장점을 알 수 있다.
1. private final이 자동적으로 symbol에 붙은 모습을 확인할 수 있다.
2. equals(), toString(), hashCode()가 자동으로 생성된다.
3. public getter 메소드가 자동으로 생성된다.
4. public 생성자가 자동으로 생성된다.
따라서 record를 사용하면 불변 클래스 생성에 많은 번거로움을 줄일 수 있다.
2 - 2. record의 단점
record는 항상 좋은 것일까?
물론 아니다. 내가 생각하는 record의 단점은 다음과 같다.
1. publi getter 생성
2. public 생성자에 헤더에 정의된 필드와 다른 타입을 정의할 수 없다.
내가 생각하는 두 가지 단점에 대해 어떻게 개선할 수 있을 지는 다음 목차에서 진행해보겠다!
3. public getter
final class는 개발자가 직접 getter 메서드를 작성해야한다.
이 과정에서 getter내부에 방어적 복사를 적용해 안전하게 반환할 수 있다.
그러나 record로 인해 생성되는 public getter는 헤더에 정의된 필드를 그대로 반환한다.
이는 위에서 말했듯 컬렉션 타입을 필드로 가질 경우 문제가 생긴다.
3 - 1. 문제점.
다음과 같이 6개의 숫자를 가지는 Lotto record가 존재한다고 하자.
- 편의상 비교를 위해 getSize()를 추가했다.
public record Lotto(
List<Number> numbers
) {
public int getSize() {
return numbers.size();
}
}
record를 사용했기 때문에 numbers에 private final 접근 제어자가 붙는다.
그러나 이는 근본적으로 외부로부터의 numbers에 대한 수정을 막아주지 못한다.
다음 코드를 참고하자.
public static void main(String[] args) {
List<Number> numbers = lottoNumberListBuilder(); // 번호 6개를 build
Lotto lotto = new Lotto(numbers);
System.out.println(lotto.getSize()); // 결과 6
lotto.numbers().clear();
System.out.println(lotto.getSize()); // 결과 0
}
위와 같이, record가 생성해주는 public getter로 접근하여 clear()를 날려버리면
Lotto 내부의 List<Number>의 데이터는 모두 사라지게 된다.
3 - 2. 개선점.
record가 getter를 자동 생성하기 때문에 이를 private으로 바꿀 수 없다.
컬렉션을 가지는 record를 어떻게 개선할 수 있을까?
바로 생성자에서 수정 불가능한 List로 넣어주는 것이다.
record는 헤더에 정의된 필드라면 추가적으로 생성자를 만들 수 있다.
여기서 추가로 만드는 생성자에서 애초에 수정을 막아주는 컬렉션을 대입해주는 것이다.
public record Lotto(
List<Number> numbers
) {
public Lotto(List<Number> numbers){
this.numbers = List.copyOf(numbers);
}
public int getSize() {
return numbers.size();
}
}
이러고 기존 코드를 실행하면

다음과 같이 불변 컬렉션을 수정할 수 없다는 예외가 터지게 된다.
만약 record를 사용하지 않는다면, 개발자가 만든 getter에 UnmodifiableList를 반환하도록 하면 된다.
물론 UnmodifiableList 역시 완벽한 불변성을 제공하지는 않는다.
이유를 간단히 말하면 원본과의 참조는 유지되기 때문이다.
UnmodifiableList의 결과물에서는 변경이 안되지만, 참조가 연결되어있어서, 원본이 변경되면 이에 따른 UnmodifiableList 역시 변경된다. (그래서 원본과 연결을 끊고 싶다면, UnmodifiableList(new ArrayList())) 해주면 된다.
4. Compact 생성자
Record에서는 Compact 생성자를 만들 수 있다.
이때, 헤더에서 정의된 필드가 아닌, 다른 타입과 변수명은 생성자의 파라미터로 사용할 수 없다.

따라서 Money의 경우, 사용자에게 입력받은 문자열을 Money의 생성자로 넘겨줄 수 없다.
이를 개선하기 위해 정적 팩토리 메서드를 고려해봐야한다.
public record Money(
int amount
) {
public static Money from(String amount) {
validateNumberFormat(amount);
return new Money(Integer.parseInt(amount));
}
private static void validateNumberFormat(String amount) {
try {
Integer.parseInt(amount);
} catch (IllegalArgumentException e) {
// 예외 처리
}
}
}
고려해봐야 한다고 말한 이유는 String -> Integer 혹은 String -> Double 과 같은 변환 책임을 도메인에 둘 것인지 의견이 갈리기 때문이다. (InputValidator라는 유틸 함수를 사용한다)
- 참고로 나는 도메인에 두는 게 낫다고 생각한다.
5. record는 언제 사용해야할까?
나만의 기준은 다음과 같다. (26.03 기준)
1. 불변 객체의 필드가 원시타입일 경우.
2. 불변 객체의 필드가 컬렉션인데, 생성 후 불변을 보장해야하는 경우.
3. Dto + 간단한 검증과 변환 로직
2번에서 컬렉션과 불변 보장이라는 조건이 있는 이유가 있다.
블랙잭 미션에서, CardBundle 같은 경우, List<Card>의 일급 컬렉션이며, Card가 계속 List에 추가된다.
이때, CardBundle을 불변 객체로 볼 것인가...? 음... 나는 반대이다.
CardBundle에 새로운 Card가 추가되어도 불변객체를 유지하기 위해서는
return new CardBundle(this.cardbundle.add(card)); 이런 식으로 새로운 CardBundle을 생성해야한다.
즉, 카드 추가를 새로운 상태 생성으로 볼거냐는 관점이다.
도메인 개념 상 현실세계의 CardBundle(손패)는 계속 새로운 카드가 추가될 수 있는데, 개발단에서 불변 객체로 사용한다면, 오해의 여지가 있다고 생각한다. 또한 CardBundle이 아니라 Hand라고 생각할 경우, 내 Hand의 참조가 계속 바뀌는 것도 이상하다.
6. 성능
앞선 기수 테코톡에 record에 다룬 크루가 있다.
해당 테코톡에서 디스어셈블과 JIT Complication 옵션으로 자세히 알아보는 부분이 있는데, 나도 한 번 해봤다.
6 - 1. FinalNumber와 Number 디스어셈블 해보기
컴파일 후, 디스어셈블(역어셈블) 해보면, 자동으로 만들어주는 코드에서 차이를 발견할 수 있었다.
FinalNumber의 equals()와 hashCode()
public boolean equals(java.lang.Object);
descriptor: (Ljava/lang/Object;)Z
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=2
0: aload_1
1: ifnull 15
4: aload_0
5: invokevirtual #13 // Method java/lang/Object.getClass:()Ljava/lang/Class;
8: aload_1
9: invokevirtual #13 // Method java/lang/Object.getClass:()Ljava/lang/Class;
12: if_acmpeq 17
15: iconst_0
16: ireturn
17: aload_1
18: checkcast #8 // class FinalNumber
21: astore_2
22: aload_0
23: getfield #7 // Field symbol:I
26: aload_2
27: getfield #7 // Field symbol:I
30: if_icmpne 37
33: iconst_1
34: goto 38
37: iconst_0
38: ireturn
LineNumberTable:
line 16: 0
line 17: 15
line 19: 17
line 20: 22
StackMapTable: number_of_entries = 4
frame_type = 15 /* same */
frame_type = 1 /* same */
frame_type = 252 /* append */
offset_delta = 19
locals = [ class FinalNumber ]
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
public int hashCode();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #7 // Field symbol:I
4: invokestatic #17 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
7: invokestatic #23 // Method java/util/Objects.hashCode:(Ljava/lang/Object;)I
10: ireturn
LineNumberTable:
line 25: 0
Number의 equals()와 hashCode()
public final int hashCode();
descriptor: ()I
flags: (0x0011) ACC_PUBLIC, ACC_FINAL
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokedynamic #17, 0 // InvokeDynamic #0:hashCode:(LNumber;)I
6: ireturn
LineNumberTable:
line 1: 0
public final boolean equals(java.lang.Object);
descriptor: (Ljava/lang/Object;)Z
flags: (0x0011) ACC_PUBLIC, ACC_FINAL
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: invokedynamic #21, 0 // InvokeDynamic #0:equals:(LNumber;Ljava/lang/Object;)Z
7: ireturn
LineNumberTable:
line 1: 0
차이
두 눈으로 봐도 큰 차이는 코드 수이다.
record로 작성된 Number의 equals() hashCode()의 코드수가 더 적다.
FinalNumber의 경우 컴파일러(javac)가 직접 코드를 작성한 모습을 볼 수 있다.
Number는 invokedynamic을 통해, 컴파일러가 코드를 직접 작성하지 않고, JVM이 런타임 중 동적으로 생성하도록 했다.
이러면 JVM이 스스로 최적화 방법을 택하게 된다.
6 - 2. JVM의 JIT컴파일러의 최적화 과정
직접 작성한 equals()와 record의 equals()를 비교해보려고 다음과 같은 Main 코드를 작성했다.
코드
package pratice;
public class Main {
public static void main(String[] args) {
FinalNumber f1 = new FinalNumber(1);
FinalNumber f2 = new FinalNumber(1);
Number n1 = new Number(1);
Number n2 = new Number(1);
// 웜업하기
int dummy1 = 0;
int dummy2 = 0;
for (int i = 0; i < 100_000; i++) {
dummy1 = checkFinalClass(f1, f2, dummy1);
dummy2 = checkRecord(n1, n2, dummy2);
}
// 시작하기
int sum1 = 0;
int sum2 = 0;
long startFinalClass = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) {
sum1 = checkFinalClass(f1, f2, sum1);
}
long endFinalClass = System.nanoTime();
long startRecord = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) {
sum2 = checkRecord(n1, n2, sum2);
}
long endRecord = System.nanoTime();
System.out.println("Final class: " + (endFinalClass - startFinalClass) / 1_000_000.0 + " ms");
System.out.println("Record: " + (endRecord - startRecord) / 1_000_000.0 + " ms");
System.out.println("Sum verification: " + sum1 + ", " + sum2);
}
private static int checkFinalClass(FinalNumber f1, FinalNumber f2, int currentSum) {
if (f1.equals(f2)) {
return currentSum + 1;
}
return currentSum;
}
private static int checkRecord(Number n1, Number n2, int currentSum) {
if (n1.equals(n2)) {
return currentSum + 1;
}
return currentSum;
}
}
실행 결과
오호라 record가 더 느리다!

인라인 여부

처음에 JIT컴파일러가 FinalNumber의 equals()와 Number의 equals를 보는데,
전자는 35바이트가 넘어 인라인 되지 않고, Number의 equals는 인라인된다.
여기서 inline 이란 메서드 호출 지점에 해당 메서드의 코드를 직접 삽입하여 호출 오버헤드를 제거하는 최적화다.
단, "삽입된 코드" 안에 또 다른 메서드 호출이 있다면, 그 호출들도 각각 인라이닝 가능 여부를 따로 판단한다.
시간이 지나면서 FinalNumber는
코드가 실행되면서 FinalNumber의 equals()가 자주 호출되는 걸 보고 인라인 처리 하는 모습을 확인할 수 있다.
206 181 4 pratice.FinalNumber::equals (39 bytes)
@ 11 java.lang.runtime.ObjectMethods::eq (11 bytes) inline
207 159 2 pratice.FinalNumber::equals (39 bytes) made not entrant
@ 5 java.lang.Object::getClass (0 bytes) (intrinsic)
@ 9 java.lang.Object::getClass (0 bytes) (intrinsic)
! @ 48 java.lang.invoke.MethodHandleImpl::profileBoolean (34 bytes) callee is too large
@ 74 java.lang.invoke.LambdaForm$MH/0x000001cb9400a800::reinvoke (24 bytes) don't inline by annotation
여기서 3번째 칸에 있는 4와 2는 JIT 컴파일러 레벨(티어)을 의미한다. (1-3은 C1 컴파일러, 4는 C2 컴파일러다)
자주 사용되니까, C2 컴파일러에서 equals()를 인라인해 최적화하고, 기존에 C1 컴파일러에 있는 equals()를 폐기하는 과정이다.
시간이 지나면서 Number는
Number는 처음에 equals()가 인라인화 되었지만, equals 내부의 메서드를 인라인하는 과정을 실패하고 있다.
! @ 66 java.lang.invoke.MethodHandleImpl::profileBoolean (34 bytes) callee is too large
@ 93 java.lang.invoke.DelegatingMethodHandle$Holder::delegate (18 bytes) recursive inlining too deep
@ 114 java.lang.invoke.LambdaForm$MH/0x000001cb9400a800::reinvoke (24 bytes) don't inline by annotation
@ 94 java.lang.invoke.LambdaForm$MH/0x000001cb9400a800::reinvoke (24 bytes) don't inline by annotation
record의 equals()는 invokedynamic을 통해 JVM이 생성을 하는데, 이때 해당 메서드에 MethodHandle 체인이 존재하고 그 체인 내부에 profileBean과 reinvoke 부분을 인라인 하지 못해, 결국 메서드 호출을 하게 된다.
그래서
그래서 실험 결과 FinalClass가 Record보다 빠른 것이다.
전자는 처음 인라인되지 못했으나, 시간이 지나면서 JVM이 인라인처리하여 성능이 빨라졌고
record는 처음 인라인했으나 그 내부 메서드들을 인라인에 실패하여 결국 메서드를 두번 호출해야 해 성능이 전자보다 느리게 된 것이다. (그럼에도 큰 차이는 아니다)
6 - 3. 추가 실험
솔직히 profileBean과 reinvoke부분에 인라인에 성공하지 못하는 이유를 모르겠다.
JVM을 따로 공부하진 않고 Gemini와 대화하여 대충 알아가고 있었기 때문이다.
그래서 실제 테코톡에 나와있는 PersonDTO와 Person Record를 예시로 사용해봤다.
그 결과 record의 equals()와 그 내부가 인라인에 성공했다.

그래서 이렇게 record가 성능에 더 빠르게 나오는 결과를 얻을 수 있었다.
근데 record가 느리게 나오는 경우도 있어서 JMH로 측정을 해봤다.
(하는 김에 Number와 Person 도메인 두가지를 모두 실험해봤다)


결과는 record의 승이다.
record의 equals() inline최적화가 된다면, record가 약소하게 더 빠른 걸 확인할 수 있다.
성능 측정은 JMH로 해보는 게 확실하니, 적극적으로 실험해봐야겠다.
'우아한 테크 코스 8기' 카테고리의 다른 글
| 동시성 테스트를 하다 발견한 Docker의 CPU 계산법 (1) | 2026.04.01 |
|---|---|
| Scanner vs BufferedReader (+ 간단한 GC) (0) | 2026.03.23 |
| Enum 캐싱은 정말 빠를까? (0) | 2026.03.19 |
| 우아한 테크 코스 8기 Level 1 - 1주차 (0) | 2026.03.03 |
| [우아한 테크 코스 8기] 최종 합격 (0) | 2026.01.23 |