테스트 강의를 들으며 토이 프로젝트를 진행하던 중 흥미로운 현상을 발견했다.
HTTP 요청에서 Java Object <-> JSON 직렬화/역직렬화 과정은 필수적인데, 이전에 Redis 캐싱을 구현하며 겪었던 이슈가 이번에도 생각나 정리를 해보려 한다.
상황은 단순했다. 주문 요청 API를 만들고 있었고 코드는 다음과 같다.
@PostMapping("/api/v1/orders/new")
public OrderResponse createOrder(@RequestBody OrderCreateRequest request) {
LocalDateTime registeredDateTime = LocalDateTime.now();
return orderService.createOrder(request, registeredDateTime);
}
@RequestBody로 매핑되는 DTO(OrderCreateRequest)의 내부는 이렇게 생겼다.
@Getter
public class OrderCreateRequest {
private List<String> productNumbers;
@Builder
private OrderCreateRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
}
강의 코드를 따라 작성할 때는 몰랐는데, 다시 보니 의문이 생겼다.
"어라? 기본 생성자(
@NoArgsConstructor)가 없는데 어떻게 역직렬화가 되는 거지?"
내가 알기로 Jackson 라이브러리가 JSON 데이터를 자바 객체로 만들 때(역직렬화) 가장 먼저 기본 생성자를 호출하고, 그 뒤에 Getter나 Setter(혹은 리플렉션)를 사용한다고 알고 있었기 때문이다.
하지만 위 코드는 private으로 선언된 인자 있는 생성자만 존재하는데도 요청이 정상적으로 동작했다.
-parameters)결론부터 말하자면, Spring Boot가 빌드 시점에 개입했기 때문이다.
Jackson은 역직렬화 시 기본 생성자가 없으면, 인자가 있는 생성자를 찾아 데이터를 넣으려고 시도한다. 하지만 문제는 컴파일된 .class 파일에는 파라미터의 이름이 남지 않는다는 점이다. 파라미터 이름이 arg0 같이 변환되므로, JSON의 키("productNumbers")와 매칭할 수 없어 실패해야 정상이다.
하지만 Spring Boot는 이를 해결하기 위해 두 가지 장치를 자동으로 해준다.
-parameters): Java 8부터 지원되는 기능으로, 컴파일 시 파라미터 이름을 클래스 파일에 남겨준다. Reacting to the Java PluginParameterNamesModule): Jackson이 위에서 남겨진 파라미터 이름을 읽을 수 있도록 ParameterNamesModule을 자동으로 등록한다.실제로 Spring Boot의 JacksonAutoConfiguration을 보면 아래와 같이 모듈을 등록하는 코드가 있다.
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {
//...
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ParameterNamesModule.class)
static class ParameterNamesModuleConfiguration {
@Bean
@ConditionalOnMissingBean
ParameterNamesModule parameterNamesModule() {
return new ParameterNamesModule(JsonCreator.Mode.DEFAULT);
}
}
//...
}
확실한 검증을 위해 build.gradle에서 컴파일 옵션을 제거해 보았다.
tasks.withType(JavaCompile) {
options.compilerArgs.remove("-parameters")
}
내 예상대로라면
1. -parameters 옵션이 사라짐.
2. Jackson이 생성자의 파라미터 이름을 못 읽음 (arg0으로 인식).
3. 역직렬화 실패 에러 발생.
이어야 했다.
예상한대로 InvalidDefinitionException이 발생했다.
사실 Jackson에서 단일 인자 생성자는 '값 위임(Delegating)'인지 '프로퍼티 매칭(Properties)'인지 판단하기 어려운 모호한 대상이라고 한다.
내 코드가 어노테이션(@JsonProperty) 없이 동작한 이유는 ParameterNamesModule이 파라미터 이름(productNumbers)을 알려주었고, Jackson은 파라미터 이름과 JSON을 매칭하여, '이건 프로퍼티 매칭 방식이구나'라고 추론해낸 것이다.
하지만 컴파일 옵션이나 모듈의 추론에 의존하기보다, 모던 자바의 트렌드에 맞춰 record를 사용하면 이 문제를 더깔끔하게 해결할 수 있을 것이다.
public record OrderCreateRequest(
List<String> productNumbers
) {
}
// 컴파일러가 실제로 만든 코드
public final class OrderCreateRequest {
private final List<String> productNumbers; // final 필드
// Jackson은 이 생성자를 호출
public OrderCreateRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
// ... getter, equals, hashCode 등
}
record를 사용하면 다음과 같은 장점이 있다.
private final로 선언되며, 생성 시점에 값이 꽉 채워진 상태로 생성된다.record는 컴파일 시 파라미터 이름 정보가 클래스 파일에 표준 스펙으로 저장된다. 따라서 -parameters 옵션이나 별도의 설정 없이 Jackson이 파라미터 이름을 인식할 수 있다.@Getter, @Builder, 생성자 등) 없이도 코드가 매우 간결해진다.이번 트러블슈팅을 통해 Jackson의 역직렬화 전략 우선순위를 알게 되었다.
-parameters 옵션 혹은 @ConstructorProperties를 활용.마음 편하게 기본 생성자 + Setter(Getter) 조합을 쓸 수도 있다. 하지만 "생성자 주입 방식"을 사용하면 객체가 생성되는 시점에 값이 꽉 채워지므로 불변 객체를 안전하게 만들 수 있다는 큰 장점이 있다.
특히 Java16 이상을 사용한다면 DTO를 record로 전환하는것도 좋은 것 같다.
+) 추가로 알게된 점 (Spring DI) (25.12.16)
Spring이 의존성 주입(DI)을 할 때 빈의 이름을 찾는 과정도 이와 유사하다고 한다. 과거에는 중복된 이름의 빈이 있을경우, @Qualifier나 @Primary가 필수였지만, 이제는 파라미터 이름 전략(ParameterNameDiscoverer)을 통해 빈을 똑똑하게 주입받을 수 있다. 이 부분은 추후에 더 깊게 공부해 봐야겠다.
저도 며칠 전에 getter만 두고 기본 생성자를 정의하지 않았었던 DTO를 우연찮게 보게 되었는데 "어 이게 왜 되는거지?" 라는 생각이 들었었습니다. 동작하는데 문제가 있지 않았기 때문에 의구심만 느낀 채로 넘어갔었는데 Jackson이 파라미터 이름을 읽는다고는 상상도 못해봤습니다. 항상 깊이있는 지식을 공유해주셔서 감사합니다^^