Java Record 실전 가이드 — DTO 클래스는 이제 그만 만들자
결론부터
데이터만 담는 불변 클래스(DTO, VO)를 만들 때 getter, 생성자,
equals(), hashCode(), toString()을
매번 손으로 치거나 Lombok에 의존하고 있다면, Java 16부터 정식 도입된
record를 쓰는 게 정답이다. record는 이 다섯 가지를 컴파일러가
자동으로 만들어주는 불변 데이터 클래스 전용 문법이다. Lombok 의존성 없이
표준 문법만으로 같은 효과를 낸다.
record 이전과 이후, 코드량 차이
기존 방식으로 이름·이메일을 담는 불변 클래스를 만들면 이렇다.
public final class Member {
private final String name;
private final String email;
public Member(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member member = (Member) o;
return Objects.equals(name, member.name)
&& Objects.equals(email, member.email);
}
@Override
public int hashCode() { return Objects.hash(name, email); }
@Override
public String toString() {
return "Member{name='" + name + "', email='" + email + "'}";
}
}
record로 바꾸면 이 전체가 한 줄이다.
public record Member(String name, String email) { }
생성자, name()/email() 접근자(주의: getName()이
아니라 name()이다), equals(), hashCode(),
toString()이 전부 자동 생성된다. 그리고 모든 필드는
기본적으로 final이라 record 자체가 불변을 강제한다.
실전 활용 1. API 응답 DTO
Controller 응답처럼 값만 실어 나르면 되는 곳이 record의 가장 확실한 활용처다.
public record MemberResponse(Long id, String name, String email) {
public static MemberResponse from(Member member) {
return new MemberResponse(member.getId(), member.getName(), member.getEmail());
}
}
@GetMapping("/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
return MemberResponse.from(memberService.findById(id));
}
실전 활용 2. 생성자에서 유효성 검증(Compact Constructor)
record도 생성 시점에 검증 로직을 넣을 수 있다. 필드 목록을 다시 안 써도 되는 compact constructor 문법을 쓰면 된다.
public record Money(long amount, String currency) {
public Money {
if (amount < 0) {
throw new IllegalArgumentException("금액은 음수일 수 없습니다: " + amount);
}
currency = currency.toUpperCase(); // 필드 값 정규화도 가능
}
}
실전 활용 3. 여러 값을 한 번에 리턴하기
메서드가 여러 값을 리턴해야 할 때 억지로 Map이나 배열을 쓰지 말고, 그 자리에서 바로 지역 record를 정의해서 반환 타입으로 쓸 수 있다(Java 16+).
public SearchResult search(String keyword) {
record Stats(int totalCount, long elapsedMillis) {}
long start = System.currentTimeMillis();
List<Post> posts = postRepository.search(keyword);
Stats stats = new Stats(posts.size(), System.currentTimeMillis() - start);
return new SearchResult(posts, stats.totalCount(), stats.elapsedMillis());
}
주의사항
-
record는 상속이 안 된다. 모든 record는 암묵적으로
java.lang.Record를 상속하고 있어 다른 클래스를 extends할 수 없다. 인터페이스 구현(implements)은 가능하다. -
가변 필드를 담으면 반쪽짜리 불변이 된다. record 자체가
불변을 보장하는 건 필드 참조까지다.
List나Date같은 가변 객체를 필드로 두면, 외부에서 그 객체 내부를 바꿔 record의 상태를 변경할 수 있다. 방어적 복사가 필요하면 compact constructor에서 처리한다. - JPA 엔티티로는 쓰지 않는다. JPA는 기본 생성자와 필드 변경(setter 또는 리플렉션 기반 변경)을 전제로 하는데, record는 둘 다 구조적으로 막고 있다. 엔티티는 기존 클래스로, record는 DTO 계층에만 쓰는 걸로 역할을 나누는 게 안전하다.
-
Bean 규약을 기대하는 라이브러리와 안 맞을 수 있다. 접근자
이름이
getName()이 아니라name()이라, Bean 프로퍼티 명명 규칙에 의존하는 오래된 라이브러리(일부 리플렉션 기반 매핑 도구 등)에서는 별도 설정이 필요할 수 있다. Jackson·Spring은 최신 버전에서 record를 잘 지원하므로 웬만한 경우엔 문제없다.
Lombok과 뭐가 다른가
Lombok의 @Value도 비슷한 결과를 만들지만, Lombok은 애노테이션
프로세서가 바이트코드를 조작하는 외부 라이브러리고, record는
JDK 표준 문법이다. 신규 프로젝트에서 불변 데이터 클래스가
목적이라면 별도 의존성이 필요 없는 record가 더 가벼운 선택이다. 다만
@Builder처럼 record가 기본 제공하지 않는 기능이 필요하면
Lombok을 record 위에 부분적으로 얹어 쓰는 것도 방법이다.
마무리
DTO나 값 객체를 만들 때마다 보일러플레이트를 손으로 치고 있었다면, 그건 컴파일러가 대신 해줄 수 있는 일을 사람이 하고 있었던 것뿐이다. record는 Java 16부터 정식 기능이라 최신 LTS(21, 25) 환경에서는 고민 없이 쓸 수 있다. 다음에 응답 DTO나 값 타입 클래스를 만들 일이 생기면, 클래스 대신 record부터 먼저 떠올려보자.
'Programming > JAVA' 카테고리의 다른 글
| Java Optional orElse() 함정: 값이 있어도 DB 저장 코드가 실행되는 이유 (0) | 2026.07.31 |
|---|---|
| Java 25 ScopedValue란? ThreadLocal 대안과 요청 컨텍스트 전달 방법 (0) | 2026.07.27 |
| 자바 서버가 흔적도 없이 사라졌다: OOM Killer와 JVM의 메모리 치킨게임 (0) | 2026.07.19 |
| JDK 21 vs 25, 2026년 LTS 선택 기준 정리 (0) | 2026.07.07 |
| Java 25 LTS 핵심 정리: 백엔드 개발자가 알아야 할 주요 변화 (0) | 2026.06.10 |