자바 아키텍처에서 DTO(Data Transfer Object)는 애플리케이션의 계층(Layer) 간 데이터 전송을 위해 설계된 핵심 객체이다. 전통적으로 자바의 일반 클래스(Class)를 활용해 DTO를 구현해 왔으나, JDK 14(정식 도입은 JDK 16)부터 데이터 캐리어로서의 역할을 완벽히 수행하기 위해 설계된 Record 명세가 도입되었다.
두 기술은 데이터를 보유한다는 목적은 같으나, 불변성(Immutability)의 강제 여부, 보일러플레이트(Boilerplate) 코드의 유무, 그리고 언어 수준에서의 시맨틱(Semantic) 정의에서 극명한 차이를 보인다.
전통적인 DTO는 데이터의 캡슐화를 위해 필드를 private으로 선언하고, 이에 접근하기 위한 Getter/Setter 메서드, 그리고 객체의 동등성 비교 및 로깅을 위한 equals(), hashCode(), toString()을 직접 구현하거나 Lombok 라이브러리에 의존하여 생성하는 방식을 취해왔다.
Record는 자바 언어의 스펙 차원에서 "데이터를 데이터로서 다루겠다"는 선언적 의도를 담아 도입된 새로운 형태의 클래스 타입이다. 불변 데이터 객체를 생성하는 과정에서 발생하는 반복적인 자바의 보일러플레이트 코드를 제거하고, 컴파일러가 데이터 전달자에게 필요한 공통 메서드를 자동으로 추상화하여 생성하도록 설계되었다.
@Getter, @ToString 등)과 같은 외부 라이브러리 의존성을 줄이고, 순수 자바 스펙만으로 생산성을 극대화하기 위함이다.| 비교 항목 | 일반 DTO 클래스 (Class) | 자바 레코드 (Record) |
|---|---|---|
| 불변성 (Immutability) | 개발자의 선택 (Setter 유무, final 선언 여부로 결정) | 강제됨 (모든 필드가 암묵적으로 private final) |
| 상속 (Inheritance) | 가능 (일반 클래스 및 인터페이스 상속 가능) | 불가능 (암묵적으로 java.lang.Record를 상속하며 final 클래스임) |
| 자동 생성 메서드 | 없음 (개발자가 수동 생성 혹은 Lombok 필요) | constructor, equals(), hashCode(), toString(), getter 자동 생성 |
| Getter 메서드 명명 규칙 | getXxx() 형태 (JavaBeans 규약 준수) | 필드명과 동일한 xxx() 형태 |
| 컴팩트 생성자 (Compact) | 지원하지 않음 | 지원함 (입력 매개변수 검증 및 전처리에 특화) |
public final class UserResponseDto {
private final Long id;
private final String name;
private final String email;
public UserResponseDto(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserResponseDto death = (UserResponseDto) o;
return Objects.equals(id, death.id) && Objects.equals(name, death.name) && Objects.equals(email, death.email);
}
@Override
public int hashCode() {
return Objects.hash(id, name, email);
}
@Override
public String toString() {
return "UserResponseDto{" + "id=" + id + ", name='" + name + '\'' + ", email='" + email + '\'' + '}';
}
}
public record UserResponseDto(
Long id,
String name,
String email
) {
// 필요한 경우 컴팩트 생성자를 통해 유효성 검증 로직 추가 가능
public UserResponseDto {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("이름은 필수값입니다.");
}
}
}
Record를 사용하면 한 줄의 선언만으로 위 일반 클래스의 생성자, Getter, equals, hashCode, toString이 컴파일 시점에 자동으로 생성된다.
Spring Boot 3.0(Spring Framework 6) 이상부터는 자바 17 버전 이상이 강제됨에 따라 Record의 활용도가 비약적으로 상승했다.
Spring MVC 및 WebFlux에서 외부 바인딩을 위해 사용하는 Jackson 라이브러리(2.12 버전 이후)는 Record를 완벽하게 지원한다.
@RequestBody를 통해 Record DTO로 변환될 때, Jackson은 내부적으로 Record의 전체 인자 생성자(Canonical Constructor)를 리플렉션으로 호출하여 객체를 생성한다. Setter가 없어도 정상적으로 바인딩이 수행된다.@ModelAttribute 등을 통한 쿼리 파라미터 매핑 시에도 Spring 6.x 버전부터는 Record 객체의 생성자를 기반으로 매핑을 유연하게 처리한다.JPA 영속성 계층과 연동할 때는 명확한 한계와 주의점이 존재한다.
@Entity로 사용 불가능: JPA 엔티티는 프록시 객체 생성을 위해 지연 로딩(Lazy Loading) 시 서브클래싱 기법을 사용한다. 따라서 엔티티 클래스는 final이 아니어야 하며, 인자가 없는 기본 생성자(No-arg Constructor)가 필수적이다. Record는 final 클래스이며 구조적으로 기본 생성자를 가질 수 없으므로 JPA 엔티티로 사용할 수 없다.public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT new com.example.dto.UserResponseDto(u.id, u.name, u.email) FROM User u WHERE u.id = :id")
Optional<UserResponseDto> findDtoById(@Param("id") Long id);
}
두 기술의 전환 타이밍과 선택 기준은 명확한 아키텍처적 규칙을 기반으로 정의되어야 한다.
getXxx())을 강제하는 프레임워크나 툴을 연동해야 하는 환경이라면 일반 클래스가 안전하다.