요새 프로젝트를 하다보면 DTO를 작성할때 클래스 기반이 아닌 Record로 코드를 작성하는 경우가 자주 보이고 있다.
물론 나도... 그렇다면 왜 Record로 작성하는 사람들이 점차 많아지고 있는걸까??
Record를 DTO로 사용하는 이유
클래스 기반 DTO를 작성하다 보면 늘 마주치는 게 있다.
Getter, Setter, 생성자, toString, equals, hashCode…
뭐만 만들면 줄줄이 따라붙는 이 보일러플레이트 코드들 말이다.
그런데 Java 16부터 정식으로 등장한 record는 이걸 아주 깔끔하게 해결해준다.
그리고 이게 단순한 문법 편의성 수준이 아니라, 설계 관점에서도 꽤 괜찮은 선택지가 된다.
DTO란 무엇인가?
DTO(Data Transfer Object)는 말 그대로 데이터를 전달하기 위한 객체다.
서비스 → 컨트롤러, 컨트롤러 → 뷰, 또는 API 응답 등 계층 간 데이터를 깔끔하게 옮길 때 쓴다.
보통 이런 식이다:
@Getter
@Setter
@Builder
public class UserDto {
private Long userId;
private String nickname;
private String email;
private String profileImg;
}
여기까지는 흔하다.
근데 이게 쌓이다 보면 클래스마다 Getter, Setter, Builder, toString, equals, hashCode를 죄다 달고 있어야 한다.
사실상 로직은 1도 없는데 코드만 괜히 길어지는 셈이다.
Record가 등장한 이유
record는 불변(immutable)한 데이터를 간결하게 표현하기 위해 만들어졌다.
필드만 선언하면, 나머지는 자동으로 해결된다.
public record UserDto(Long userId, String nickname, String email, String profileImg) {}
이 한 줄만으로 다음이 전부 생성된다:
- 모든 필드를 private final로 선언
- 생성자
- Getter (필드명 그대로 메서드로 제공됨)
- equals, hashCode, toString
즉, “데이터 전달만 하는 객체”라면 record가 DTO보다 압도적으로 간결하다.
왜 record를 DTO로 쓰는 게 좋은가
1. 불변성을 명시적으로 보장한다
DTO는 보통 Setter가 있기 때문에 객체 상태를 쉽게 바꿀 수 있다.
근데 record는 필드가 전부 final이라 생성 이후 수정이 불가능하다.
UserDto dto = new UserDto(1L, "junho", "test@test.com", "img.png");
// dto.nickname = "changed"; // ❌ 컴파일 에러
API 응답이나 서비스 간 데이터 전달에서 불변 객체는 안전하고, 예측 가능하다.
멀티스레드 환경에서도 동기화 걱정을 덜 수 있다.
2. 보일러플레이트 코드 제거
DTO 만들 때마다 Getter, Setter, toString, equals, hashCode, Builder 달고 있는 거 솔직히 귀찮다.
record는 그냥 필드만 적으면 끝이다.
public record PostDto(Long id, String title, String content) {}
이걸로도 equals, hashCode, toString이 전부 깔끔하게 생성된다.
가독성도 좋아지고 코드 라인 수도 줄어든다.
3. 의도가 명확하다
record를 보면 딱 보인다.
“아, 이건 데이터 전달용 객체구나.”
로직이 붙어있지 않고 필드만 존재하니까 역할이 명확해진다.
반면 DTO는 Lombok이나 메서드가 얹히기 시작하면 “이게 진짜 DTO 맞나?” 싶을 때가 종종 있다.
커스터마이징은 불편하지 않을까?
record는 기본적으로 불변성을 지키는 설계라 Setter나 상태 변경 로직은 추가할 수 없다.
하지만 필요한 경우, 정적 팩토리 메서드나 정적 변수로 어느 정도 확장이 가능하다.
예를 들어 엔티티에서 DTO로 변환할 때 Builder 대신 이렇게 쓸 수 있다
public record UserDto(Long userId, String nickname, String email, String profileImg) {
public static UserDto of(User user) {
return new UserDto(
user.getId(),
user.getNickname(),
user.getEmail(),
user.getProfileUrl()
);
}
}
이렇게 하면 DTO 생성 로직이 명확해지고, 필드가 바뀌어도 of()만 수정하면 된다.
의미 전달도 깔끔하고 유지보수도 편하다.
무조건 record로 바꾸면 좋을까?
꼭 그렇진 않다..! 아래 케이스는 DTO가 더 낫다.
- 필드가 너무 많아서 생성자 매개변수가 길어질 때
- DTO 내부에서 커스텀 로직이나 검증이 필요한 경우
- Java 16 미만 버전을 사용 중일 때
이런 경우는 여전히 클래스로 DTO를 작성하는 게 낫다.
record는 깔끔하지만 확장성은 상대적으로 제한적이다.
마무리
| 항목 | 클래스 기반 DTO | Record 기반 DTO |
| 가변성 | 가변 (Setter 제공 가능) | 불변 (final 필드) |
| 코드 길이 | 길고 반복적 | 짧고 간결 |
| 커스터마이징 | 용이 | 제한적 |
| 불변성 보장 | 직접 설계 필요 | 기본적으로 보장 |
| 버전 | 모든 버전 가능 | Java 16 이상 필요 |
정리하자면..
- 데이터 전달만 하는 객체라면 record로 바꾸는 게 훨씬 깔끔하고 안전하다.
- 로직이 붙거나 필드가 많다면 여전히 DTO 클래스로 가는 게 낫다.
- 그리고 정적 팩토리 메서드(of) 패턴과 함께 쓰면 유지보수성도 챙길 수 있다.