Java: DTO(Class) vs Record(Java 14+)

아투·2026년 5월 30일

Java

목록 보기
9/16
post-thumbnail

1. Overview

자바 아키텍처에서 DTO(Data Transfer Object)는 애플리케이션의 계층(Layer) 간 데이터 전송을 위해 설계된 핵심 객체이다. 전통적으로 자바의 일반 클래스(Class)를 활용해 DTO를 구현해 왔으나, JDK 14(정식 도입은 JDK 16)부터 데이터 캐리어로서의 역할을 완벽히 수행하기 위해 설계된 Record 명세가 도입되었다.

두 기술은 데이터를 보유한다는 목적은 같으나, 불변성(Immutability)의 강제 여부, 보일러플레이트(Boilerplate) 코드의 유무, 그리고 언어 수준에서의 시맨틱(Semantic) 정의에서 극명한 차이를 보인다.


2. 정의 및 탄생 배경

전통적인 DTO 클래스 (Traditional Class-based DTO)

전통적인 DTO는 데이터의 캡슐화를 위해 필드를 private으로 선언하고, 이에 접근하기 위한 Getter/Setter 메서드, 그리고 객체의 동등성 비교 및 로깅을 위한 equals(), hashCode(), toString()을 직접 구현하거나 Lombok 라이브러리에 의존하여 생성하는 방식을 취해왔다.

자바 레코드 (Java Record)

Record는 자바 언어의 스펙 차원에서 "데이터를 데이터로서 다루겠다"는 선언적 의도를 담아 도입된 새로운 형태의 클래스 타입이다. 불변 데이터 객체를 생성하는 과정에서 발생하는 반복적인 자바의 보일러플레이트 코드를 제거하고, 컴파일러가 데이터 전달자에게 필요한 공통 메서드를 자동으로 추상화하여 생성하도록 설계되었다.

Record가 탄생한 이유

  1. 보일러플레이트 코드 최소화: Lombok(@Getter, @ToString 등)과 같은 외부 라이브러리 의존성을 줄이고, 순수 자바 스펙만으로 생산성을 극대화하기 위함이다.
  2. 신뢰할 수 있는 불변성(Immutability) 확보: 멀티스레드 환경이나 reactive 패러다임이 주를 이루는 현대 백엔드 아키텍처에서 데이터 전송 중 값이 변조되는 부작용(Side-effect)을 원천 차단한다.
  3. 의도의 명확성(Semantic Clarity): 코드를 읽는 개발자나 컴파일러에게 "이 객체는 비즈니스 로직을 포함하지 않는 pure 데이터 덩어리"임을 명확히 인지시킨다.

3. 기술적 비교 (Comparison)

비교 항목일반 DTO 클래스 (Class)자바 레코드 (Record)
불변성 (Immutability)개발자의 선택 (Setter 유무, final 선언 여부로 결정)강제됨 (모든 필드가 암묵적으로 private final)
상속 (Inheritance)가능 (일반 클래스 및 인터페이스 상속 가능)불가능 (암묵적으로 java.lang.Record를 상속하며 final 클래스임)
자동 생성 메서드없음 (개발자가 수동 생성 혹은 Lombok 필요)constructor, equals(), hashCode(), toString(), getter 자동 생성
Getter 메서드 명명 규칙getXxx() 형태 (JavaBeans 규약 준수)필드명과 동일한 xxx() 형태
컴팩트 생성자 (Compact)지원하지 않음지원함 (입력 매개변수 검증 및 전처리에 특화)

4. 구현 예제 (Code Examples)

기존 일반 클래스 기반 DTO (Lombok 미사용 시)

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 + '\'' + '}';
    }
}

자바 Record 기반 DTO

public record UserResponseDto(
    Long id, 
    String name, 
    String email
) {
    // 필요한 경우 컴팩트 생성자를 통해 유효성 검증 로직 추가 가능
    public UserResponseDto {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수값입니다.");
        }
    }
}

Record를 사용하면 한 줄의 선언만으로 위 일반 클래스의 생성자, Getter, equals, hashCode, toString이 컴파일 시점에 자동으로 생성된다.


5. Spring 프레임워크에서의 DTO와 Record 사용

Spring Boot 3.0(Spring Framework 6) 이상부터는 자바 17 버전 이상이 강제됨에 따라 Record의 활용도가 비약적으로 상승했다.

1) HTTP 요청/응답 매핑 (Jackson Serialization)

Spring MVC 및 WebFlux에서 외부 바인딩을 위해 사용하는 Jackson 라이브러리(2.12 버전 이후)는 Record를 완벽하게 지원한다.

  • Request Body 바인딩: 클라이언트가 전송한 JSON 데이터가 @RequestBody를 통해 Record DTO로 변환될 때, Jackson은 내부적으로 Record의 전체 인자 생성자(Canonical Constructor)를 리플렉션으로 호출하여 객체를 생성한다. Setter가 없어도 정상적으로 바인딩이 수행된다.
  • Query Parameter 바인딩: @ModelAttribute 등을 통한 쿼리 파라미터 매핑 시에도 Spring 6.x 버전부터는 Record 객체의 생성자를 기반으로 매핑을 유연하게 처리한다.

2) JPA(Hibernate)와의 연동 및 제약 사항

JPA 영속성 계층과 연동할 때는 명확한 한계와 주의점이 존재한다.

  • @Entity로 사용 불가능: JPA 엔티티는 프록시 객체 생성을 위해 지연 로딩(Lazy Loading) 시 서브클래싱 기법을 사용한다. 따라서 엔티티 클래스는 final이 아니어야 하며, 인자가 없는 기본 생성자(No-arg Constructor)가 필수적이다. Record는 final 클래스이며 구조적으로 기본 생성자를 가질 수 없으므로 JPA 엔티티로 사용할 수 없다.
  • DTO Projection으로의 활용 (권장):
    JPQL이나 Querydsl을 통해 DB에서 데이터를 조회하여 바로 DTO로 변환하는 프로젝션(Projection) 용도로는 Record가 가장 이상적이다.
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);
}

6. 아키텍처적 선택 기준 (Selection Criteria)

두 기술의 전환 타이밍과 선택 기준은 명확한 아키텍처적 규칙을 기반으로 정의되어야 한다.

Record 사용을 강력히 권장하는 경우

  • API Request / Response DTO: 외부 시스템과 주고받는 메시지 스펙은 한 번 생성되면 전송 과정에서 변하지 않아야 하므로 Record의 불변성과 잘 맞아떨어진다.
  • CQRS 패턴의 Read Model: 조회 성능 최적화를 위해 읽기 전용으로 데이터를 뽑아 계층 간 전송할 때 가장 안전하고 간결하다.
  • 내부 컴포넌트 간 데이터 전달: 서비스 계층 내부 혹은 다른 모듈 간 데이터를 묶어서 넘길 때 바인딩용 클래스를 만들 장황함을 상쇄한다.

일반 Class 기반 DTO 사용이 불가피한 경우

  • 비즈니스적 요구로 상속(Inheritance) 계층 구조가 필요한 경우: 공통 DTO 속성을 부모 클래스에 두고 상속받아 확장해야 하는 구조 체계라면 Record는 사용할 수 없다.
  • 데이터의 가변성(Mutability)이 필수적인 경우: 비즈니스 로직 흐름에 따라 객체 내부의 필드 값을 지속적으로 변경(Setter 혹은 내부 메서드를 통한 상태 변경)해야 하는 레거시 형태의 객체 구조라면 클래스를 유지해야 한다.
  • 일부 레거시 라이브러리와의 호환성: 명확히 JavaBeans 규약(getXxx())을 강제하는 프레임워크나 툴을 연동해야 하는 환경이라면 일반 클래스가 안전하다.

7. 마무리 (결론 및 요약)

  • Java Record는 불변 데이터를 다루기 위한 자바 언어 차원의 정체성이며, 보일러플레이트 코드를 근본적으로 제거하여 코드 가독성과 유지보수성을 극대화한다.
  • 일반 클래스 DTO는 가변성이나 상속이 필요한 특수 구조(JPA 엔티티 등)에 제한적으로 사용하고, 현대 아키텍처의 API 통신 및 데이터 전송 레이어에서는 불변성이 보장되는 Record를 디폴트(Default)로 채택하는 것이 시스템의 안정성과 생산성 측면에서 우수하다.

0개의 댓글