애플리케이션을 개발하며 Request와 Response DTO를 많이 정의합니다. DTO는 그저 데이터를 옮기는 용도이므로 한 번 생성 후 서비스 로직에서는 그대로 사용만 할 뿐 수정할 일이 거의 없습니다. 그러던중 자바의 record를 사용하면 불변성도 확보되니 좋겠다고 생각해 몇 가지 DTO는 record로 정의해보기도 했습니다.
// 예시: 키워드 응답 DTO를 record로 정의
public record KeywordResponse(
String identifier,
String name,
String category,
Double latitude,
Double longitude
) {
public static KeywordResponse from(Keyword keyword) {
return new KeywordResponse(
keyword.getIdentifier(),
keyword.getName(),
keyword.getCategory(),
keyword.getCoordinates().getLatitude(),
keyword.getCoordinates().getLongitude()
);
}
}
이처럼 record를 사용하면 필드가 final로 고정되고, 자동으로 생성자&Getter 등이 만들어지면 setter 메서드는 가질수 없는게 강제되니 불변을 보장합니다.
그렇다면 항상 'record'를 사용하는게 좋을까? 언제 Class로 정의하고 언제 Record로 정의해야할까' 라는 고민이 생겼습니다.
그래서 본격적으로 Dto를 정의할때 상황에 따라 언제 class를 사용하고 언제 record를 사용해야할지 고민해보려고 합니다.
Record란?
Record는 불변(Immutable) 데이터를 간결하고 읽기 쉽게 담는 데 초점을 맞추고 있으며, 기존 클래스(DTO 등)에서 필요했던 반복적인 코드를 많이 줄여줍니다.
DTO에서는 생성자, Getter, equals(), hashCode(), toString() 메서드를 직접 작성해야 합니다. 하지만 Record를 사용하면 Java가 이 모든 것들을 자동으로 생성해 줍니다. 덕분에 간단한 불변 객체, 즉 데이터만 담는 역할을 하는 객체를 만들 때 매우 유용합니다.
주요 특징
- 불변성: 일반적으로 변경할 수 있는 DTO와는 다르게 한 번 Record를 만들면 그 데이터를 변경할 수 없습니다.
- 간결한 문법: 필드만 선언하면 Java가 자동으로 생성자, Getter, equals(), hashCode(), toString() 메서드를 만들어 줍니다.
- Setter 없음: Record는 불변 객체이기 때문에 Setter 메서드를 제공하지 않습니다.
확실히 여기까지 봤을 때 API를 응답받고, 전달하는 DTO의 역할로는 불변성이 보장되는 Record를 사용하는것이 좋아보입니다.
그러면 항상 Record를 사용하면 되지 않느냐?
Record가 등장한 이유를 함께 살펴보면 좋습니다.
Record는 “데이터 전달과 표현을 단순화하기 위해” 등장했다.
기존 Java 클래스는 너무 장황했습니다. equals(), hashCode(), toString(), getter 등 반복 코드가 엄청 많았고 이를 매번 적용해야했습니다. 그러므로 값 자체를 표현하는 용도의 class가 필요했던 것이지요. '객체의 특성인 행동을 담지 않고 오로지 데이터 그 자체를 표현하기 위한 객체가 필요했다' 라고 이해하면 좋을것 같습니다. 그렇기 때문에 '불변성' 이라는 특성이 따라오게 된거고요.
즉, '데이터를 표현하는 객체'라는 점에서 DTO로 Record가 딱 맞아 떨어진 것입니다.
- 프로세스 간에 데이터를 전달하는 객체로 데이터 자체만을 표현하면 되고,
- Getter등과 같은 반복적인 코드도 제거하고,
- 명확한 사용 의도를 표현할 수 있기 때문입니다.
그렇다면 Record는 간단하게 값을 표현할때 사용하면 됩니다.
그런데 왜 항상 Record를 쓸 수는 없을까?
1) 계층형 구조
DTO가 단순히 평면 데이터만 다루는 경우는 드뭅니다. 대부분은 객체 안에 객체, 리스트, 맵 등 중첩 구조를 가지며 이를 표현합니다.
@Getter
@AllArgsConstructor
public class RoomCreateRequest {
@NotNull
@ValidI18nMap
private Map<String, String> nameI18n;
@ValidSequentialDisplayOrder
private List<ImageUploadDto> galleryImages = new ArrayList<>();
@Valid
private List<RoomFacilityDto> facilities = new ArrayList<>();
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal pricePerNight;
public void validate() {
validateMainImage();
validateDisplayOrder();
ensureDefaultFacility();
}
private void validateMainImage() {
long count = galleryImages.stream().filter(ImageUploadDto::isMainImage).count();
if (count != 1) {
throw new IllegalArgumentException("대표 이미지는 정확히 한 개여야 합니다.");
}
}
private void validateDisplayOrder() {
var orders = galleryImages.stream()
.map(ImageUploadDto::getDisplayOrder)
.sorted()
.toList();
for (int i = 0; i < orders.size(); i++) {
if (orders.get(i) != i) {
throw new IllegalArgumentException("displayOrder는 0부터 연속된 숫자여야 합니다.");
}
}
}
private void ensureDefaultFacility() {
if (facilities.isEmpty()) {
facilities.add(new RoomFacilityDto("WiFi"));
}
}
}
이런 구조를 Record로 표현하면?
public record RoomCreateRequestRecord(
@NotNull
@ValidI18nMap
Map<String, String> nameI18n,
@ValidSequentialDisplayOrder
List<ImageUploadDto> galleryImages,
@Valid
List<RoomFacilityDto> facilities,
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
BigDecimal pricePerNight
) {
public RoomCreateRequestRecord {
// 1. null 방어
if (galleryImages == null) galleryImages = new ArrayList<>();
if (facilities == null) facilities = new ArrayList<>();
// 2. 대표 이미지 검증 (mainImage == true)
long mainImageCount = galleryImages.stream()
.filter(ImageUploadDto::isMainImage)
.count();
if (mainImageCount != 1) {
throw new IllegalArgumentException("대표 이미지는 정확히 한 개여야 합니다.");
}
// 3 displayOrder 연속성 검증
var orders = galleryImages.stream()
.map(ImageUploadDto::getDisplayOrder)
.sorted()
.toList();
for (int i = 0; i < orders.size(); i++) {
if (orders.get(i) != i) {
throw new IllegalArgumentException("displayOrder는 0부터 연속된 숫자여야 합니다.");
}
}
// 4. 기본 시설 추가 (불변성 때문에 불가능)
if (facilities.isEmpty()) {
facilities.add(new RoomFacilityDto("WiFi")); // Record 필드는 불변 → 예외 발생
}
// 5. 불변 복사
galleryImages = List.copyOf(galleryImages);
facilities = List.copyOf(facilities);
nameI18n = Map.copyOf(nameI18n);
}
}
여기서 문제되는 요소들은 다음과 같습니다.
| 검증 책임 집중 | 클래스처럼 메서드로 분리할 수 없고, 모든 로직이 생성자에 몰림 |
| 가독성 저하 | 초기화, 검증, 복사 로직이 섞여서 의도가 흐려짐 |
결과적으로, Record는 단순한 구조 자체에는 문제가 없지만, 비즈니스 규칙이 생기는 순간 생성자가 커지면서 로직의 덩어리로 변해버힙니다.
다음 예시는 실제로 매우 복잡한 Record의 예시입니다.
public record RoomCreateRequestRecord(
List<ImageUploadDto> galleryImages,
List<RoomFacilityDto> facilities
) {
public RoomCreateRequestRecord {
if (galleryImages == null) galleryImages = new ArrayList<>();
// displayOrder 정렬 및 대표 이미지 보정
boolean hasMain = galleryImages.stream().anyMatch(ImageUploadDto::mainImage);
galleryImages = IntStream.range(0, galleryImages.size())
.mapToObj(i -> {
ImageUploadDto original = galleryImages.get(i);
return new ImageUploadDto(
original.url(),
i, // displayOrder 재조정
hasMain ? original.mainImage() : (i == 0) // 첫 번째를 대표로 지정
);
})
.toList();
galleryImages = List.copyOf(galleryImages);
}
}
이렇게 되어있다면 어떤 검증들을 어떻게 하는건지, 쉽게 눈에 들어오지 않고 이해하기도 어렵습니다. 그렇기에 가독성, 직관성, 유지보수성이 모두 하락하게됩니다.
결론
Record는 생긴 의도 그대로 데이터 자체를 간단하게 표현할 때 사용해야 더 빛이 바랍니다. 오히려 불변성을 위한다고 복잡한 Class를 Record로 변환한다면 문제가 생길 것입니다.
Record는 '불변성을 위해서 사용한 다는 것 보다는 데이터를 간단하게 표혈 할 때 사용하는것이 맞다고 생각합니다.
'개발 > 서버' 카테고리의 다른 글
| [프차천국] Spring Cache + JPA Lazy 로딩 조합에서 발생한 동시성 문제 해결하기( with Thundering Herd) (5) | 2025.11.25 |
|---|---|
| [프차천국] 랜덤 프랜차이즈 추천 API 3.7초 → 37ms까지 줄이기(후보 풀 캐싱 + Batch IN + Hibernate Batch Fetch) (2) | 2025.11.25 |
| 개발 회고 : 예외가 발생하면 로그 레벨은 error로 설정해야할까? (0) | 2025.10.18 |
| 왜 PostgreSQL을 선택했는가: 다국어 서비스를 위한 JSONB 활용 (0) | 2025.10.16 |
| STAYONE KOREA 프로젝트: 왜 결제 요청시각과 완료시각을 ‘두 쌍으로’ 저장해야 했을까 (0) | 2025.10.14 |
댓글