기본 생성자가 없는데 JSON 역직렬화가 된다고?

콜 파머가 될 남자·2025년 12월 17일

에러 정리 및 해결

목록 보기
5/6

테스트 강의를 들으며 토이 프로젝트를 진행하던 중 흥미로운 현상을 발견했다.

HTTP 요청에서 Java Object <-> JSON 직렬화/역직렬화 과정은 필수적인데, 이전에 Redis 캐싱을 구현하며 겪었던 이슈가 이번에도 생각나 정리를 해보려 한다.

기본 생성자가 없는 DTO

상황은 단순했다. 주문 요청 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으로 선언된 인자 있는 생성자만 존재하는데도 요청이 정상적으로 동작했다.


Spring Boot의 마법 (-parameters)

결론부터 말하자면, Spring Boot가 빌드 시점에 개입했기 때문이다.

Jackson은 역직렬화 시 기본 생성자가 없으면, 인자가 있는 생성자를 찾아 데이터를 넣으려고 시도한다. 하지만 문제는 컴파일된 .class 파일에는 파라미터의 이름이 남지 않는다는 점이다. 파라미터 이름이 arg0 같이 변환되므로, JSON의 키("productNumbers")와 매칭할 수 없어 실패해야 정상이다.

하지만 Spring Boot는 이를 해결하기 위해 두 가지 장치를 자동으로 해준다.

  1. 컴파일 옵션 추가 (-parameters): Java 8부터 지원되는 기능으로, 컴파일 시 파라미터 이름을 클래스 파일에 남겨준다. Reacting to the Java Plugin
  2. Jackson 모듈 등록 (ParameterNamesModule): 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이 발생했다.


Record를 활용한 완벽한 불변 객체

사실 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를 사용하면 다음과 같은 장점이 있다.

  1. 완벽한 불변성: 모든 필드가 private final로 선언되며, 생성 시점에 값이 꽉 채워진 상태로 생성된다.
  2. 명시적인 파라미터 정보: record는 컴파일 시 파라미터 이름 정보가 클래스 파일에 표준 스펙으로 저장된다. 따라서 -parameters 옵션이나 별도의 설정 없이 Jackson이 파라미터 이름을 인식할 수 있다.
  3. 간결함: lombok(@Getter, @Builder, 생성자 등) 없이도 코드가 매우 간결해진다.

정리 및 결론

이번 트러블슈팅을 통해 Jackson의 역직렬화 전략 우선순위를 알게 되었다.

  1. 기본 생성자가 있으면 가장 우선 사용.
  2. 없으면 인자 있는 생성자 사용 시도.
  • 이때 파라미터 이름을 알기 위해 -parameters 옵션 혹은 @ConstructorProperties를 활용.

마음 편하게 기본 생성자 + Setter(Getter) 조합을 쓸 수도 있다. 하지만 "생성자 주입 방식"을 사용하면 객체가 생성되는 시점에 값이 꽉 채워지므로 불변 객체를 안전하게 만들 수 있다는 큰 장점이 있다.

특히 Java16 이상을 사용한다면 DTO를 record로 전환하는것도 좋은 것 같다.

+) 추가로 알게된 점 (Spring DI) (25.12.16)
Spring이 의존성 주입(DI)을 할 때 빈의 이름을 찾는 과정도 이와 유사하다고 한다. 과거에는 중복된 이름의 빈이 있을경우, @Qualifier@Primary가 필수였지만, 이제는 파라미터 이름 전략(ParameterNameDiscoverer)을 통해 빈을 똑똑하게 주입받을 수 있다. 이 부분은 추후에 더 깊게 공부해 봐야겠다.

profile
꾸준함 빼면 시체

2개의 댓글

comment-user-thumbnail
2025년 12월 21일

저도 며칠 전에 getter만 두고 기본 생성자를 정의하지 않았었던 DTO를 우연찮게 보게 되었는데 "어 이게 왜 되는거지?" 라는 생각이 들었었습니다. 동작하는데 문제가 있지 않았기 때문에 의구심만 느낀 채로 넘어갔었는데 Jackson이 파라미터 이름을 읽는다고는 상상도 못해봤습니다. 항상 깊이있는 지식을 공유해주셔서 감사합니다^^

1개의 답글