본문 바로가기

Programming/JAVA

Java Record 실전 가이드 — DTO 클래스는 이제 그만 만들자

추천캐릭터 2026. 7. 22. 08:00
728x90

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 자체가 불변을 보장하는 건 필드 참조까지다. ListDate 같은 가변 객체를 필드로 두면, 외부에서 그 객체 내부를 바꿔 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부터 먼저 떠올려보자.

728x90