CATEGORY

카테고리 (672)
AI (70)
Language & Specs (260)
FrameWork (36)
Library (20)
App (41)
Git (10)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (17)
IT (120)
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

Language & Specs/Java

자바 record dto — 실무에서 걸리는 5가지

반응형

자바 record를 DTO로 바꿀 때 실제로 걸리는 건 다섯 가지입니다. ① 접근자가 getName()이 아니라 name()이고, ② 기본 생성자가 없지만 Jackson은 그래도 역직렬화하며, ③ JSON에 필드가 빠져도 예외 대신 null(기본형은 0)이 들어가고, ④ @NotBlank 같은 검증은 그대로 먹지만, ⑤ JPA 엔티티로는 쓸 수 없습니다. 아래 내용은 Java 21.0.1 · Jackson 2.17.3 · Hibernate Validator 8.0.1에서 직접 돌려 확인한 결과입니다.

1. 자바 record dto, 최소 형태부터

record는 JEP 395 기준 Java 16에서 정식이 됐습니다(14·15는 프리뷰). DTO 하나가 한 줄로 끝납니다.

public record UserDto(Long id, String name, List<String> roles) {}

이 한 줄로 생성자·접근자·equals·hashCode·toString이 전부 만들어집니다. 리플렉션으로 실제 생성된 멤버를 찍어 보면 이렇습니다.

System.out.println(Arrays.stream(UserDto.class.getDeclaredMethods())
        .map(Method::getName).sorted().toList());
System.out.println(new UserDto(1L, "kim", List.of("USER")));

// 실행 결과 (Java 21.0.1)
// [equals, hashCode, id, name, roles, toString]
// UserDto[id=1, name=kim, roles=[USER]]

2. getName()이 없다 — 접근자 이름이 다르다

가장 먼저 막히는 지점입니다. record의 접근자는 필드 이름 그대로라 getName()이 아니라 name()입니다. 위 실행 결과에도 getName은 없고 name만 있습니다.

boolean has = Arrays.stream(UserDto.class.getMethods())
        .anyMatch(m -> m.getName().equals("getName"));
System.out.println(has);   // false

user.name();     // 이렇게 씁니다
// user.getName();  → 컴파일 에러
⚠️ 이 때문에 자바빈 getter 규약에 기대는 도구(오래된 템플릿 엔진, 일부 매핑 라이브러리, 리플렉션으로 getXxx를 찾는 코드)와 붙일 때 값이 비어 보일 수 있습니다. Jackson은 record를 따로 지원하므로 해당 없습니다(3번 참고).

3. 기본 생성자가 없는데 Jackson은 왜 되나

record는 기본 생성자가 없습니다. 확인해 보면 예외가 납니다.

try {
    UserDto.class.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
    System.out.println("기본 생성자 없음");   // 이쪽으로 들어옵니다
}

그런데 Jackson은 역직렬화가 됩니다. Jackson이 record의 정식 생성자(canonical constructor)를 알아보고 그대로 호출하기 때문입니다. 별도 설정이나 jackson-module-parameter-names 없이 동작합니다.

ObjectMapper om = new ObjectMapper();
System.out.println(om.version());                                  // 2.17.3

System.out.println(om.writeValueAsString(new UserDto(1L, "kim", List.of("USER"))));
// {"id":1,"name":"kim","roles":["USER"]}

System.out.println(om.readValue("{\"id\":1,\"name\":\"kim\",\"roles\":[\"USER\"]}", UserDto.class));
// UserDto[id=1, name=kim, roles=[USER]]

이름이 다른 JSON 키는 record 컴포넌트에 @JsonProperty를 그대로 붙이면 됩니다.

public record SnakeDto(@JsonProperty("user_name") String userName) {}

om.readValue("{\"user_name\":\"kim\"}", SnakeDto.class);
// SnakeDto[userName=kim]

참고로 JSON에 모르는 필드가 섞여 있으면 record든 일반 클래스든 똑같이 UnrecognizedPropertyException이 납니다. 실제로 위 DTO에 "x":9를 끼워 넣으니 그대로 터졌습니다. 이 예외를 무시하도록 바꾸는 방법은 Jackson UnrecognizedPropertyException 해결에 정리해 뒀습니다. 날짜 필드를 담을 때 배열로 나오는 문제는 Jackson LocalDateTime 직렬화 쪽을 보세요.

4. 필드가 빠지면 예외가 아니라 null·0이 들어간다

여기가 실무에서 제일 조용하게 사고 나는 곳입니다. 요청 JSON에 필드가 없어도 예외 없이 통과하고, 참조형은 null, 기본형은 0이 채워집니다.

record Item(String sku, int qty) {}

om.readValue("{\"id\":1}", UserDto.class);      // UserDto[id=1, name=null, roles=null]
om.readValue("{\"sku\":\"A\"}", Item.class);     // Item[sku=A, qty=0]
JSON 상태 참조형 필드 기본형 필드(int) 그래서 위험한가
키가 아예 없음 null 0 ⚠️ 예외가 안 나서 그대로 저장될 수 있음
모르는 키가 추가됨 UnrecognizedPropertyException ✅ 바로 터져서 눈에 띔

막는 방법은 두 가지입니다. 검증 애너테이션을 붙이거나(5번), compact 생성자에서 직접 던지는 겁니다. compact 생성자는 매개변수 목록을 다시 안 써도 되는 record 전용 문법입니다.

record Age(int value) {
    Age {                                   // 괄호 없는 compact 생성자
        if (value < 0) throw new IllegalArgumentException("age must be >= 0: " + value);
    }
}

new Age(-1);
// java.lang.IllegalArgumentException: age must be >= 0: -1

5. @NotBlank 같은 검증은 그대로 먹는다

record 컴포넌트에 Bean Validation 애너테이션을 그냥 붙이면 됩니다. 별도 설정 없이 동작했습니다.

record SignUpDto(@NotBlank String email, @Min(0) int age) {}

Validator v = Validation.buildDefaultValidatorFactory().getValidator();
v.validate(new SignUpDto("  ", -1))
 .forEach(x -> System.out.println(x.getPropertyPath() + " -> " + x.getMessage()));

// 실행 결과 (Hibernate Validator 8.0.1.Final, 한국어 로케일)
// age -> 0 이상이어야 합니다
// email -> 공백일 수 없습니다
💡 4번의 "필드 누락 시 null" 문제는 이걸로 막습니다. 요청 DTO를 record로 만들 때는 @NotNull·@NotBlank를 빠짐없이 붙이는 게 사실상 필수입니다.

6. record는 JPA 엔티티로 쓸 수 없다

이건 취향 문제가 아니라 규격 문제입니다. Jakarta Persistence 3.1 §2.1은 엔티티 클래스 조건을 이렇게 못 박습니다.

  • "The entity class must have a no-arg constructor." — 기본 생성자가 있어야 한다
  • "The entity class must not be final." — final이면 안 된다

그런데 record는 둘 다 위반합니다. 확인해 보면 이렇습니다.

System.out.println(Modifier.isFinal(UserDto.class.getModifiers()));  // true
System.out.println(UserDto.class.getSuperclass().getName());         // java.lang.Record
// getDeclaredConstructor() → NoSuchMethodException

그래서 record는 엔티티가 아니라 그 바깥에서 씁니다. 즉 컨트롤러의 요청·응답 DTO, 서비스 반환값, JPQL new 구문이나 Querydsl Projections.constructor로 받는 조회 전용 결과에 맞습니다. 엔티티를 그대로 응답에 실었다가 겪는 문제들(JPA merge vs persist 차이에서 다룬 준영속 상태 같은 것)도 record DTO로 끊어 주면 줄어듭니다.

// 조회 전용 결과를 record 로 바로 받기
@Query("select new com.example.dto.UserDto(u.id, u.name) from User u")
List<UserDto> findAllDto();

7. record는 "얕은" 불변이다 — 리스트는 안 막힌다

record의 필드는 재할당이 안 될 뿐, 필드가 가리키는 객체 내부까지 막아 주진 않습니다. 리스트를 담아 두고 그대로 add하면 들어갑니다.

var u = new UserDto(1L, "kim", new ArrayList<>(List.of("USER")));
u.roles().add("ADMIN");
System.out.println(u.roles());   // [USER, ADMIN]  ← 그냥 들어갑니다

막으려면 compact 생성자에서 방어적 복사를 합니다.

record Safe(String name, List<String> roles) {
    Safe { roles = List.copyOf(roles); }   // 불변 리스트로 바꿔 담는다
}

new Safe("kim", new ArrayList<>(List.of("USER"))).roles().add("ADMIN");
// java.lang.UnsupportedOperationException

자주 묻는 질문 (FAQ)

Q. record는 자바 몇 버전부터 쓸 수 있나요?
정식은 Java 16입니다(JEP 395). 14·15에서는 프리뷰라 --enable-preview가 필요했습니다. Spring Boot 3.x는 Java 17 이상을 요구하므로 그냥 쓰면 됩니다.

Q. Lombok @Data DTO를 record로 바꿔도 되나요?
값을 담아 넘기기만 하는 DTO라면 대체할 수 있습니다. 다만 setter가 필요하거나(record는 값을 못 바꿉니다), 필드가 많아 빌더가 꼭 필요하거나, 상속이 필요하면 맞지 않습니다. record는 java.lang.Record를 이미 상속하고 있어 다른 클래스를 상속할 수 없습니다(위 실행 결과의 getSuperclass()).

Q. 값 하나만 바꾸고 싶으면요?
setter가 없으므로 새 인스턴스를 만듭니다. var u2 = new UserDto(u.id(), "lee", u.roles()); 처럼 쓰거나, 자주 쓰면 record 안에 withName(String) 같은 메서드를 직접 하나 두는 편이 읽기 좋습니다.

Q. 필드 이름이 스네이크 케이스인 API와 붙이려면요?
컴포넌트에 @JsonProperty("user_name")를 붙이면 됩니다(3번에서 확인). 전체를 한 번에 바꾸려면 ObjectMapper에 PropertyNamingStrategies.SNAKE_CASE를 설정합니다.

마무리

정리하면 자바 record dto는 요청·응답 DTO와 조회 전용 결과에는 잘 맞고, 엔티티·수정이 필요한 객체에는 안 맞습니다. 실무에서 실제로 발목 잡는 건 문법이 아니라 접근자 이름(name()), 필드 누락 시 조용히 들어오는 null·0, 그리고 얕은 불변 이 셋입니다. 요청 DTO에는 검증 애너테이션을 빠짐없이 붙이고, 컬렉션을 담을 때는 compact 생성자에서 List.copyOf로 복사해 두면 대부분 막힙니다.


📚 참고 출처 (2026년 8월 18일 확인)
· JEP 395: Records (OpenJDK)
· Jakarta Persistence 3.1 — §2.1 The Entity Class

동작 확인 환경: Java 21.0.1 LTS · Jackson 2.17.3 · Hibernate Validator 8.0.1.Final

반응형

COMMENTS