record는 Java 14에서 프리뷰로 등장해 Java 16에서 정식 기능이 되었다. 디컨스트럭션 패턴은 Java 21부터다.
작업을 하다보면 intelliJ가 람다를 메서드 참조로 변경을 추천(?)할 때 처럼 클래스를 record로 추천해주는 경우를 종종 보았다. 그리고 인프런의 강의를 들으면서 record를 사용하는 강사님들을 보았다. 작업을 진행하니 DTO을 만들 때 record를 사용하면 편해서 요즘 작업할 때 적극 사용하고 있다
// 람다 str -> str.toUpperCase() str -> System.out.println(str) // 메서드 참조 String::toUpperCase System.out::println
기존에 사용하던 DTO
자주 사용하던 DTO (Lombok을 자주 사용하였지만 여기서는 순수 Java 코드)를 IDE를 활용하여 작성
public final class MemberClassDto {
private final String name;
private final int age;
public MemberClassDto(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
@Override
public boolean equals(Object object) {
if (object == null || getClass() != object.getClass()) return false;
MemberClassDto that = (MemberClassDto) object;
return age == that.age && Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "MemberClassDto{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
public record MemberRecordDto(String name, int age) {
}
private으로 캡슐화를 통해 내부 구현을 숨기고, 값 변경을 통제해 불변식과 유효성을 유지한다. 결합도와 실수를 줄이며, 보안과 동시에 안전성도 높여준다.
final 필드는 생성 후 재할당을 막아 불변과 불변식 유지로 버그를 줄인다. 스레드 안전성 (JMM final field semantics)으로 초기값 가시성이 보장되어 스레드 안전하고, equals/hashCode 안정으로 컬렉션 키에도 안전하다. 의도가 명확하고 최적화에도 유리하다.
따라서 불변 객체(Value Object)로 사용하기에 적합하다.
100% 필수는 아니지만 대부분의 불변 객체에서는 toString(), equals(), hashCode()가 필수에 가깝다.
toString(): 디버깅과 로깅에 용이하다. 불변 객체는 주로 데이터 중심 클래스이므로 상태 확인이 자주 필요하며 의미 있는 toString()이 있으면 디버깅이 쉬워진다.
equals(): 내용이 같으면 같다고 판단해야 한다. 불변 객체는 상태가 한 번 정해지면 바뀌지 않으므로, 내용으로 객체를 비교하는 게 합리적이다. ==는 참고(주소)를 비교하지만, 불변 객체는 값 기반 비교가 필요하다.
hashCode(): HashMap, HashSet 등에서 안전하게 사용하기 위해서 필요하다. equals()가 true인 객체들은 항상 같은 hashCode()를 반환해야 하고, 불변 객체는 값이 바뀌지 않기 때문에 해시값도 고정된다. 그러면 해시 컬렉션에 안정적으로 키로 사용이 가능해진다.
Java Record 특징
1. 불변성 (Immutability)
객체 지향 프로그래밍에 있어서 불변객체는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다.
public record MemberRecordDto(String name, int age) {
}
MemberRecordDto member = new MemberRecordDto("혁", 100);
// person.name = "혁";
// 모든 필드가 private final로 생성됨
다만 record가 보장하는 불변성은 얕은 불변(shallow immutable) 이다. 참조를 바꾸지 못하게 막아줄 뿐, 참조가 가리키는 객체의 내부까지 막아주지는 않는다
public record TeamDto(String name, List<String> members) {
}
List<String> members = new ArrayList<>();
members.add("혁");
TeamDto team = new TeamDto("백엔드", members);
members.add("민"); // 외부에서 리스트를 건드리면
team.members().size(); // 2 — record 내부도 같이 바뀐다
team.members().add("수"); // accessor로 꺼내서 바꿔도 마찬가지
컴포넌트가 컬렉션이나 배열, 혹은 가변 객체라면 컴팩트 생성자에서 방어적 복사를 해야 진짜 불변이 된다
public record TeamDto(String name, List<String> members) {
public TeamDto {
members = List.copyOf(members); // 복사 + 불변 리스트로 감싼다
}
}
List.copyOf()는 null 원소를 허용하지 않으니 null이 섞일 수 있는 데이터라면 Collections.unmodifiableList(new ArrayList<>(members))를 쓴다
배열은 한 가지가 더 걸린다. 자동 생성된 equals()는 배열 컴포넌트를 Objects.equals()로 비교하는데, 이건 내용이 아니라 참조를 비교한다. 내용이 같은 배열 두 개를 가진 record가 서로 다르다고 판정된다는 뜻이다. 배열이 필요하면 List로 바꾸는 편이 안전하다
2. 자동 생성 메서드들
public record MemberRecordDto(String name, int age) {
}
MemberRecordDto member = new MemberRecordDto("혁", 100);
member.name() // getter 대신 accessor
member.age() // getAge()가 아닌 age()
member.toString(); // "MemberRecordDto[name=혁, age=100]"
member.equals(otherMember) // 모든 필드 비교
member.hashCode() // 모든 필드 기반
3. 암묵적으로 final
// Record는 자동으로 final 클래스
public final class MemberRecordDto extends Record {
private final String name;
private final int age;
}
4. 극단적인 간결성
Lombok을 사용해도 보일러플레이트 코드는 여전히 반복적으로 발생하며, 여러 개의 어노테이션을 함께 사용해야 하는 번거로움이 있다.
@Value를 활용하면 더욱 간소화 할 수 있다고 들었지만, 사용해 본 적은 없다.
@Getter
@AllArgsConstructor
@EqualsAndHashCode
@ToString
public final class MemberClassDto() {
private final String name;
private final int age;
}
public record MemberRecordDto(String name, int age) {
}
5. 바이트코드 크기
record의 equals(), hashCode(), toString()은 메서드 본문이 컴파일러에 의해 하나하나 생성되지 않는다. 대신 invokedynamic 명령 하나로 남고, 런타임에 java.lang.runtime.ObjectMethods가 컴포넌트 목록을 받아 구현을 만들어준다. 그래서 손으로 쓴 클래스나 Lombok이 생성한 클래스보다 클래스 파일이 작다
// javap -c 로 열어보면 세 메서드 모두 이런 형태다
public final boolean equals(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: invokedynamic #xx, 0 // InvokeDynamic #0:equals
7: ireturn
대신 오해하면 안 되는 부분이 있다
- 인스턴스 생성 비용은 일반 클래스와 같다. record라고 해서 객체 할당이 더 싸지지 않는다
- JVM에 record 전용 최적화 경로는 없다. 힙에 안 올라가는 값 타입은 Project Valhalla의 value class 이야기이고, record와는 별개다. (물론 나중에 둘이 만날 가능성은 있다)
- indy는 첫 호출 때 링킹 비용이 한 번 발생한다. 그 뒤로는 일반 메서드 호출과 차이가 없다
정리하면 성능 때문에 record를 쓰는 게 아니라, 간결함과 안전함 때문에 쓰는 것이고 바이트코드가 작아지는 건 덤이다
6. 상속 제약
// Record는 다른 클래스 상속 불가능
public record MemberRecordDto(String name, int age) extends Object {}
// Record를 상속 불가능
public record MemberDetailRecordDto(String address) extends MemberRecordDto {}
// 인터페이스는 구현 가능
public record MemberRecordDto(String name, int age) implements Serializable {}
Record에서 상속이 불가능한 이유
- 목적이 단순 데이터 전달이기 때문 (불변 객체)
- 생성자, equals, hashCode 등 자동 생성 메서드가 상속 시 충돌 우려
- 불변성과 단순성을 지키기 위해 상속 계층을 허용하지 않음
Record가 인터페이스 구현이 가능한 이유
- 인터페이스는 구현 없는 계약만 정의하므로 구조적 충돌 없음
- 상태를 공유하지 않아서 불변성에도 영향 없음
- 다형성을 통해 일관된 방식으로 처리 가능
상속은 상태를 공유하고 구현체를 상속받아 복잡성을 증가시키며 불변성을 위험에 빠뜨린다. 반면 인터페이스는 계약만 정의하고 구현을 직접 제공하므로 단순성을 유지하면서 불변성을 보장한다
Record는 데이터 보관에 집중하고, 인터페이스를 통해 기능적 계약만 추가로 제공할 수 있도록 설계되었다. 이는 단순함과 안정성을 유지하면서도 필요한 유연성을 제공하는 균형잡힌 설계이다.Record가 상속을 허용하지 않는 것은 복잡성을 피하고 본래 목적인 순수한 데이터 전달에 집중하기 위함이며, 인터페이스 구현을 허용하는 것은 기능적 확장성을 제공하되 핵심 설계 원칙은 해치지 않기 위함이다
7. 컴팩트 생성자
public record Temperature(double celsius) {
// 매개변수 없는 특별한 생성자
public Temperature {
if (celsius < -273.15) {
throw new IllegalArgumentException("절대영도 이하 불가능");
}
// 필드 할당은 자동으로 처리됨
}
}
8. 제네릭 지원
record도 타입 파라미터를 가질 수 있다. API 공통 응답 객체를 만들 때 특히 잘 맞았다
public record ApiResponse<T>(String code, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("200", "OK", data);
}
public static <T> ApiResponse<T> fail(String code, String message) {
return new ApiResponse<>(code, message, null);
}
}
ApiResponse<MemberRecordDto> response = ApiResponse.ok(member);
Pair<K, V> 같은 것도 한 줄이면 끝난다
public record Pair<K, V>(K key, V value) {
}
9. 정적 메서드와 인스턴스 메서드 추가 기능
record는 인스턴스 필드를 추가할 수 없을 뿐, static 필드와 메서드는 자유롭게 추가할 수 있다
public record MemberRecordDto(String name, int age) {
private static final int ADULT_AGE = 19; // static 필드는 가능
// 인스턴스 메서드
public boolean isAdult() {
return age >= ADULT_AGE;
}
// 정적 팩토리 메서드
public static MemberRecordDto of(Member entity) {
return new MemberRecordDto(entity.getName(), entity.getAge());
}
}
자동 생성되는 accessor를 직접 덮어쓸 수도 있다. 다만 이건 값이 두 번 보이는 셈이라 웬만하면 컴팩트 생성자에서 정규화하는 쪽이 낫다
public record MemberRecordDto(String name, int age) {
// 이렇게 accessor를 덮어쓰기보다는
@Override
public String name() {
return name.trim();
}
}
public record MemberRecordDto(String name, int age) {
// 컴팩트 생성자에서 한 번만 정리하는 게 낫다
public MemberRecordDto {
name = name.trim();
}
}
엔티티를 DTO로 바꾸는 코드가 여기저기 흩어지기 쉬운데, 정적 팩토리 메서드를 record 안에 두니 변환 책임이 한곳에 모여서 편했다
10. 직렬화 지원
implements Serializable을 붙이면 직렬화된다. 여기까지는 일반 클래스와 같은데, 복원 방식이 다르다
일반 클래스는 역직렬화할 때 생성자를 거치지 않고 리플렉션으로 필드를 직접 채운다. 그래서 생성자에 검증 로직을 넣어놨어도 조작된 바이트 스트림으로 불변식이 깨진 객체를 만들어낼 수 있다. record는 항상 정규 생성자(canonical constructor)를 통해 복원되므로 컴팩트 생성자의 검증이 그대로 적용된다
public record Temperature(double celsius) implements Serializable {
public Temperature {
if (celsius < -273.15) {
throw new IllegalArgumentException("절대영도 이하 불가능");
}
}
}
// 역직렬화 시에도 위 검증을 반드시 통과한다
대신 readObject(), writeObject() 같은 훅으로 직렬화 형식을 커스터마이징할 수는 없다. record의 직렬화 형태는 컴포넌트 목록으로 고정되어 있다
11. 리플렉션 지원
record인지 판별하고, 컴포넌트 목록을 꺼내는 API가 따로 있다
Class<?> clazz = MemberRecordDto.class;
clazz.isRecord(); // true
for (RecordComponent component : clazz.getRecordComponents()) {
component.getName(); // name, age
component.getType(); // String, int
component.getAccessor(); // name(), age() 메서드
}
컴포넌트가 선언 순서 그대로 보장된다는 점이 핵심이다. 일반 클래스의 getDeclaredFields()는 순서를 보장하지 않는다. Jackson이 별도 애노테이션 없이 record를 매핑할 수 있는 것도, MapStruct 같은 도구가 record를 잘 다루는 것도 이 API 덕분이다
12. 어노테이션 적용
컴포넌트에 붙인 애노테이션은 @Target에 맞춰 필드, 생성자 파라미터, accessor 메서드로 알아서 전파된다. 그래서 Bean Validation을 그냥 붙이면 된다
public record MemberCreateRequest(
@NotBlank(message = "이름은 필수입니다")
String name,
@Min(value = 0, message = "나이는 0 이상이어야 합니다")
int age
) {
}
@PostMapping("/members")
public ApiResponse<Void> create(@Valid @RequestBody MemberCreateRequest request) {
...
}
@Target(ElementType.FIELD)만 지정된 애노테이션은 필드에만 붙고 accessor에는 안 붙는다. 프레임워크가 getter를 기준으로 읽는 경우 동작하지 않을 수 있으니, 그럴 땐 @field:처럼 대상을 명시하거나 애노테이션 문서를 확인해야 한다
패턴 매칭과의 조합
Java 21부터 record 디컨스트럭션 패턴이 정식으로 들어왔다. sealed interface와 묶으면 컴파일러가 분기 누락까지 잡아준다.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
double area = switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
};
case Circle c -> Math.PI * c.radius() * c.radius() 대신 값을 바로 꺼내 쓸 수 있고, permits에 새 구현체를 추가하면 switch에서 컴파일 에러가 난다. 빠뜨릴 수가 없다
[추가] 실무에서 마주친 것들
JPA 엔티티로는 쓸 수 없다
JPA는 기본 생성자와 프록시를 위한 상속을 요구하는데 record는 둘 다 불가능하다. 대신 조회 결과를 담는 프로젝션 DTO로는 아주 잘 맞는다.
public record MemberSummary(String name, int age) {
}
@Query("select new com.example.MemberSummary(m.name, m.age) from Member m")
List<MemberSummary> findSummaries();
Spring Data JPA의 인터페이스 프로젝션 자리에 record를 그대로 넣어도 동작한다
값 일부만 바꾼 사본을 만들기 번거롭다
Java에는 아직 wither가 없어서 직접 만들어야 한다. 컴포넌트가 많으면 꽤 귀찮다
public record MemberRecordDto(String name, int age) {
public MemberRecordDto withAge(int age) {
return new MemberRecordDto(this.name, age);
}
}
컴포넌트가 다섯 개를 넘어가면 Lombok @Builder를 정규 생성자에 붙이는 것도 방법이다. record에도 문제없이 붙는다
Jackson 역직렬화
Jackson 2.12부터 record를 지원한다. Spring Boot 2.6 이상이면 별도 설정 없이 @RequestBody로 받힌다. 컴파일 옵션 -parameters가 필요한데 Spring Boot Gradle/Maven 플러그인이 기본으로 켜준다. 직접 빌드 스크립트를 짜는 프로젝트라면 이 옵션이 빠져서 파라미터 이름을 못 찾는 경우가 있으니 확인해두는 게 좋다