API DTO가 복잡해지는 이유

백엔드 API의 요청과 응답 객체는 처음에는 단순한 필드 묶음으로 시작하지만, 프로젝트가 커질수록 생성자, getter, equals, hashCode, toString 코드가 반복됩니다. Lombok으로 줄일 수는 있지만 컴파일 단계의 생성 코드에 의존하게 되고, 불변성 규칙도 팀마다 달라지기 쉽습니다. Java record는 이런 데이터 전달 객체를 언어 차원에서 간결하게 표현하는 기능입니다. 컴포넌트 선언만으로 private final 필드, 접근자, 생성자, equals, hashCode, toString이 만들어집니다.

record는 모든 객체를 대체하기 위한 문법은 아닙니다. 내부 상태가 계속 바뀌는 엔티티, 프록시가 필요한 ORM 엔티티, 상속 구조가 핵심인 도메인 모델에는 일반 클래스가 더 적합합니다. 반대로 HTTP 요청·응답 DTO, 조회 결과, 메시지 이벤트, 설정 값처럼 생성 후 값을 바꾸지 않아야 하는 데이터에는 잘 맞습니다. 적용 범위를 DTO부터 명확히 잡으면 기존 코드와 충돌 없이 도입할 수 있습니다.

요청 DTO에 불변성과 입력 검증 함께 넣기

record의 기본 생성자는 선언한 모든 필드를 받습니다. 이 생성자를 보완하는 compact constructor를 사용하면 필드 대입 전에 검증과 정규화를 수행할 수 있습니다. 여기서 검증 실패를 빠르게 반환하면 서비스 계층까지 잘못된 데이터가 내려가는 일을 줄일 수 있습니다. 다만 요청 DTO의 검증 규칙이 지나치게 비대해지면 API 계층과 업무 규칙이 섞이므로, 형식 검증과 값 범위 검증 정도로 제한하는 편이 좋습니다.

import java.math.BigDecimal; import java.util.Objects; public record CreateOrderRequest(String customerId, String productCode, int quantity, BigDecimal unitPrice) { public CreateOrderRequest { customerId = requireText(customerId, "customerId"); productCode = requireText(productCode, "productCode").toUpperCase(); if (quantity < 1 || quantity > 1_000) throw new IllegalArgumentException("invalid quantity"); unitPrice = Objects.requireNonNull(unitPrice, "unitPrice"); if (unitPrice.signum() <= 0) throw new IllegalArgumentException("unitPrice must be positive"); } private static String requireText(String value, String name) { if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); return value.trim(); } }

위 코드에서 accessor는 customerId()처럼 필드명과 같은 메서드로 제공됩니다. getCustomerId() 관례를 전제로 한 오래된 라이브러리나 자체 유틸리티가 있다면 먼저 호환성을 확인해야 합니다. Spring Boot와 Jackson을 사용하는 일반적인 REST API에서는 record 생성자 기반 역직렬화를 지원하지만, 운영 중인 프레임워크 버전과 ObjectMapper 설정을 통합 테스트로 검증하는 것이 안전합니다.

엔티티를 API 응답에 직접 노출하지 않기

JPA 엔티티를 그대로 JSON으로 반환하면 지연 로딩, 순환 참조, 의도하지 않은 개인정보 노출 문제가 함께 따라옵니다. record 응답 DTO를 별도로 두고 변환을 명시하면 API 계약이 데이터베이스 구조에서 분리됩니다. 컬럼을 추가하거나 관계를 변경해도 외부 응답을 안정적으로 유지할 수 있고, 필요한 데이터만 선택해 전송량도 줄일 수 있습니다.

public record OrderSummaryResponse(Long orderId, String customerName, String status, int itemCount, BigDecimal totalAmount) { public static OrderSummaryResponse from(Order order) { return new OrderSummaryResponse(order.getId(), order.getCustomer().getName(), order.getStatus().name(), order.getItems().size(), order.totalAmount()); } }

변환 메서드를 record 안에 둘지, 별도 Mapper 클래스에 둘지는 변환 복잡도로 결정하면 됩니다. 한 엔티티에서 필드를 꺼내는 수준이면 from 메서드가 읽기 쉽습니다. 여러 엔티티를 조합하거나 권한별 표현을 나누고, 날짜·통화 포맷 정책까지 적용해야 한다면 Mapper를 분리하는 편이 테스트와 재사용에 유리합니다. 특히 목록 조회에서는 엔티티를 모두 로딩한 후 변환하기보다, 필요한 컬럼만 DTO로 조회하는 projection도 함께 검토해야 합니다.

컬렉션과 중첩 데이터의 불변성 주의점

record 자체는 참조를 재할당할 수 없지만, 내부에 담긴 List나 Map까지 자동으로 불변이 되는 것은 아닙니다. 호출자가 전달한 변경 가능한 컬렉션을 그대로 저장하면, record 생성 이후에도 외부 코드가 컬렉션 내용을 바꿀 수 있습니다. API 응답과 이벤트 메시지처럼 스냅샷 성격의 데이터라면 List.copyOf, Map.copyOf로 방어적 복사를 해야 합니다.

import java.util.List; public record ProductResponse(String code, String name, List<String> tags) { public ProductResponse { tags = tags == null ? List.of() : List.copyOf(tags); } }

배열도 같은 주의가 필요합니다. byte[]나 String[]을 record 컴포넌트로 쓴다면 생성자에서 clone하고 accessor에서도 clone을 반환해야 합니다. 가능하면 배열 대신 불변 컬렉션이나 값 객체로 모델링하는 편이 실수 여지가 적습니다. 또한 민감 정보가 포함된 record는 자동 생성되는 toString에 값이 노출될 수 있으므로, 비밀번호·토큰·주민번호 같은 필드는 DTO 자체에 담지 않거나 로그용 표현을 별도로 설계해야 합니다.

도입 순서와 테스트 기준

처음부터 모든 DTO를 record로 바꾸기보다 신규 API 응답, 내부 이벤트, 조회 전용 DTO부터 도입하는 것이 좋습니다. 변경 전후 JSON 필드명과 null 처리, 날짜 형식이 같다는 계약 테스트를 만들고, OpenAPI 문서와 클라이언트 역직렬화도 함께 확인합니다. Bean Validation을 사용한다면 record 컴포넌트에 @NotBlank, @Positive 같은 제약을 선언해 기존 Controller 검증 흐름을 유지할 수 있습니다. 예외를 어떤 HTTP 상태와 오류 코드로 변환할지도 일관되게 정해야 합니다.

적용 체크리스트

  • 변경되지 않아야 하는 요청·응답·이벤트 DTO부터 record 후보로 선정합니다.
  • compact constructor에는 형식 검증과 정규화만 두고 복잡한 업무 규칙은 서비스 계층에 둡니다.
  • 엔티티를 직접 반환하지 않고 명시적인 응답 DTO 변환을 유지합니다.
  • List, Map, 배열처럼 변경 가능한 참조에는 방어적 복사를 적용합니다.
  • 기존 JSON 계약, 유효성 검증, 로그 노출 여부를 통합 테스트로 확인합니다.