DTO 생성자 표준 원칙 3은 이렇게 적었다.

record는 canonical constructor를 자동으로 쓰지만, class는 명시해야 한다.

record는 선언한 필드를 그대로 받는 생성자를 자동으로 갖는다 — 이걸 canonical constructor라 부른다. record Point(int x, int y)라고만 적어도 자바가 그 생성자를 알아서 만들어 주는 식이다. class는 이런 생성자가 저절로 생기지 않으니, Jackson에게 “역직렬화(JSON처럼 줄로 늘어선 데이터를 다시 자바 객체로 되돌리는 일)할 때 이 생성자를 써라”라고 명시해야 한다는 뜻이다.

왜 명시가 필요하다고 봤는지는 Jackson이 객체를 만드는 방법에서 나온다. Jackson은 기본 생성자로 빈 객체를 먼저 만든 뒤 setter로 값을 하나씩 채우는 길도 쓴다(이 원칙을 함께 시험한 setter 실험에서 Spring Boot 3.5 환경이 실제로 그 길로 만들었다). 이 표준의 DTO는 기본 생성자도 setter도 없으니 그 길이 막혀 있고, 남은 길은 인자를 받는 생성자뿐이다. 생성자로 만들려면 JSON의 "productName" 값을 생성자의 몇 번째 인자에 넣을지 알아야 하는데, 컴파일하면 파라미터 이름이 지워질 수 있다(아래 「예상과 달랐던 점」에서 다시 나온다). 그래서 @JsonCreator로 “이 생성자를 써라”, @JsonProperty("productName")로 “이 인자는 JSON의 이 이름과 짝이다”라고 적어 주자는 것이다.

그래서 모든 Request DTO(클라이언트가 서버로 보낸 HTTP 요청의 JSON을 받아 담는 DTO)의 생성자에 @JsonCreator와 @JsonProperty를 붙이는 것이 규칙이 됐다. 그런데 「명시하지 않으면 안 된다」는 돌려본 적이 없는 문장이었다. 표준을 증명하는 방식을 정하고 고른 첫 시범이 이 원칙이고, 이 글은 그 실험 셋 중 하나다. 나머지 둘은 setter가 있을 때 Jackson이 어느 길로 만드는지 본 setter 실험과, final 필드에 Lombok 애너테이션을 붙이면 컴파일이 막히는지 본 Lombok 실험이다.


주장 — @JsonCreator가 없으면 class는 다인자 생성자로 역직렬화되지 않는다

돌리기 전에 주장을 한 문장으로 좁혔다.

@JsonCreator 없이 인자 둘 이상의 생성자만 가진 class는,
Spring Boot가 설정한 ObjectMapper가 그 생성자로 역직렬화하지 못한다.

「Spring Boot가 설정한」을 넣은 이유는 표준을 쓰는 곳이 Spring 프로젝트이기 때문이다. ObjectMapper는 자바 객체와 JSON을 서로 바꿔 주는 Jackson 라이브러리의 핵심 클래스다. 요청에 실려 온 JSON(요청의 본문, @RequestBody로 받는 부분)을 Controller 메서드가 받는 Request 객체로 바꿀 때, 그 일을 하는 것은 내가 new ObjectMapper()로 만든 매퍼가 아니라 Boot가 미리 만들어 컨텍스트(빈들을 등록해 관리하는 Spring의 실행 공간)에 넣어 둔 매퍼다. 「등록」은 Spring이 그 객체를 하나 만들어 두고 필요한 곳에 꺼내 주는 것을 말한다. Boot가 만든 매퍼는 new ObjectMapper()와 붙어 있는 모듈(매퍼가 할 줄 아는 일을 늘리는 부품)이 다를 수 있다 — 실제로 이 실험에서 그 차이가 결과를 갈랐다(아래 「예상과 달랐던 점」). 그래서 설정 없는 매퍼로 재면 실제 프로젝트와 다른 것을 재게 된다.

근거 종류는 1(기술 사실)이다. 이 번호는 표준을 증명하는 방식 원칙 1이 근거를 네 종류로 나눈 분류의 첫째다. 같은 입력을 넣고 되는지 안 되는지만 보면 된다.


반증 조건 — 두 Boot 중 하나라도 값이 채워지면 반증이다

claim.md는 실험 폴더(proofs/jackson-class-constructor-detection/)에 돌리기 전에 써 두는 파일이다. 주장과, 어떤 결과가 나오면 주장이 틀린 것으로 칠지를 적는다. 결과를 보고 나서 기준을 정하면 어떤 결과든 주장대로 읽히기 때문에, 결과를 보기 전에 이 파일에 기준선을 그었다.

입력은 {"productName":"mouse","quantity":3}이다. 두 환경은 Spring Boot 3.5.16과 4.1.1이다(왜 둘인지는 다음 섹션). 판정 이름은 영어 셋이다 — refuted(반증됨), supported(주장대로), inconclusive(판정 불가).

  • refuted — 두 환경 중 하나라도 예외 없이 끝나고 productName=mouse, quantity=3이 채워진다
  • supported — 두 환경 모두 예외로 실패한다
  • inconclusive — 환경이 뜨지(앱이 실행되지) 않았거나, 예외 없이 끝났는데 값이 다르다

「하나라도」로 정한 이유는 표준이 버전을 가리지 않고 「명시해야 한다」고 말하기 때문이다. 한 버전에서라도 명시 없이 되면 그 문장은 참이 아니다.


설계 — Boot가 등록한 매퍼로 3.5.16과 4.1.1에서 같은 클래스를 역직렬화한다

역직렬화할 대상 클래스는 가장 단순하게 두었다.

// Jackson 어노테이션도 기본 생성자도 setter 도 없다. 생성자는 이것 하나다
public class NoCreatorRequest {
    private final String productName;
    private final Integer quantity;

    public NoCreatorRequest(String productName, Integer quantity) {
        this.productName = productName;
        this.quantity = quantity;
    }
    // getter 둘
}
  • 첫 줄 주석 — Jackson 애너테이션(@JsonCreator 같은 @ 표시)이 하나도 없다. 기본 생성자와 setter도 없어서 앞에서 말한 「빈 객체를 만들고 setter로 채우는」 길이 막혀 있다. Jackson이 쓸 수 있는 길은 아래 생성자 하나뿐이다
  • private final 필드 둘 — final이라 생성자에서 한 번 값을 넣으면 바뀌지 않는다
  • 2인자 생성자 — 받은 두 값을 필드에 그대로 넣기만 한다. 실험이 보려는 대상이다. Jackson이 이 생성자를 스스로 찾아 "mouse"와 3을 넣는지 본다
  • getter 둘 — 역직렬화가 끝난 뒤 값이 제대로 들어갔는지 꺼내 확인하는 데 쓴다

Lombok을 쓰지 않았다. 이 실험은 Jackson이 생성자를 찾는지만 본다. Lombok이 끼면 결과가 어느 쪽 동작인지 가를 수 없다.

환경은 둘이다. 표준을 쓰는 프로젝트의 Boot 버전을 모르고, Boot 4부터 기본 Jackson이 2에서 3으로 바뀌어 결과가 갈릴 수 있었다. Boot는 버전마다 함께 쓸 Jackson 버전을 정해 두는데, Boot 3은 Jackson 2를, Boot 4는 Jackson 3을 고른다. 두 버전은 패키지 이름도 다르다 — Jackson 2의 클래스는 com.fasterxml로, Jackson 3의 클래스는 tools.jackson으로 시작한다. import 하는 이름이 달라서 실험 코드도 두 버전용을 따로 두었다(app/src/jackson2, app/src/jackson3).

둘 다 Boot Gradle 플러그인을 적용해 컴파일했다. 실제 프로젝트가 그렇게 빌드하기 때문이다. 이 플러그인을 쓰느냐가 결과를 바꿀 수 있다는 것은 「예상과 달랐던 점」에서 드러난다 — 플러그인이 파라미터 이름을 남기는 컴파일 옵션을 켠다.

판정에 쓰지 않고 기록만 한 값이 둘 있다. 설정 없는 매퍼의 결과와 생성자 파라미터 이름이 바이트코드(javac가 .class 파일에 쓰는, JVM이 읽어 실행하는 명령)에 남았는지다. 파라미터 이름은 컴파일 옵션에 따라 .class 파일에 남기도 하고 지워지기도 한다. 결과가 주장과 다를 때 왜 다른지를 가르려고 넣었다.

재현은 proofs/jackson-class-constructor-detection/run.sh 하나다. run.sh는 셸 스크립트로, 이것 하나만 실행하면 두 버전을 차례로 빌드해 돌리고 결과 파일까지 쓴다. Docker 안에서 JDK 21·Gradle 8.14.3으로 돈다.


결과 — 두 Boot 모두 성공했고 설정 없는 Jackson 2 매퍼만 실패했다

run.sh가 쓴 원본 출력이다. 한 줄이 Boot 버전 하나이고, 이름=값 칸의 뜻은 이렇다.

  • boot · jackson · java — 그 환경의 Spring Boot · Jackson · 자바 버전
  • params — 생성자 파라미터 이름이 .class 파일에 남았는가(true면 남았다)
  • spring — Boot가 컨텍스트에 넣어 둔 매퍼로 역직렬화한 결과. ok:mouse,3은 예외 없이 끝나고 두 값이 채워졌다는 뜻이다. 판정은 이 칸으로만 한다
  • plain — 설정 없는 매퍼(Jackson 2는 new ObjectMapper(), Jackson 3은 JsonMapper.builder().build())로 같은 일을 한 결과. 기록만 한다. fail: 뒤는 던진 예외 이름이다
PROBE boot=3.5.16 jackson=2.21.4 java=21.0.9 params=true spring=ok:mouse,3 plain=fail:InvalidDefinitionException
PROBE boot=4.1.1 jackson=3.1.5 java=21.0.9 params=true spring=ok:mouse,3 plain=ok:mouse,3

판정은 refuted다. Boot가 준 매퍼는 두 버전 모두 @JsonCreator 없이 2인자 생성자로 값을 채웠다.

jackson=2.21.4는 처음에 이상해 보였다. 3.5 줄의 첫 버전인 Boot 3.5.0은 Jackson 2.19를 쓰기 때문이다. Boot는 함께 쓸 라이브러리 버전 목록을 spring-boot-dependencies라는 pom(Maven 형식의 빌드 설정 파일)으로 내놓고, 실험의 Gradle 빌드도 build.gradle에서 platform("org.springframework.boot:spring-boot-dependencies:<Boot 버전>")으로 이 목록을 가져다 쓴다. 그래서 Boot 3.5.16의 그 pom을 열어보니, Jackson 라이브러리들의 버전을 한꺼번에 정하는 값 jackson-bom.version이 2.21.4였다. 3.5.0에서 3.5.16처럼 맨 끝 자리(패치 버전)만 올라가는 동안 Jackson도 올라간 것이고, 실험이 엉뚱한 버전을 부른 것이 아니다.


예상과 달랐던 점 — 표준의 문장은 설정 없는 Jackson 2 매퍼에서만 맞았다

plain 열이 답을 준다. Jackson 2를 설정 없이 쓰면 InvalidDefinitionException(Jackson이 “이 타입을 어떻게 만들어야 할지 모르겠다”고 던지는 예외다. 역직렬화에 쓸 생성자를 못 찾았을 때 나온다)으로 실패한다. 표준 글의 문장은 이 조건에서는 참이다.

Boot 3.5에서 달라지는 이유로 확인한 것은 둘이다.

  • params=true — Boot Gradle 플러그인이 -parameters로 컴파일해 생성자 파라미터 이름이 남았다. -parameters는 자바 컴파일러 옵션으로, 이 옵션을 켜면 컴파일된 클래스 파일에 “몇 번째 파라미터 이름이 productName이다”라는 정보가 그대로 남는다. 이 옵션이 없으면 그 이름은 지워지고 arg0·arg1처럼 번호로만 남는다. JavaPluginAction은 Boot가 Gradle 빌드에 자바 컴파일 설정을 끼워 넣는 내부 코드다 — 그 안에 -parameters를 켜는 인자가 있다(4.1 소스로 확인. 3.5는 출력값으로만 확인)
  • spring-boot-starter-json 3.5.16의 pom이 jackson-module-parameter-names를 끌어온다. 스타터는 Boot가 관련 라이브러리를 한데 묶어 둔 의존성 꾸러미이고, 「끌어온다」는 스타터 하나를 적으면 그 pom에 적힌 다른 jar(클래스 파일을 묶은 라이브러리 파일)도 같이 들어온다는 뜻이다(전이 의존). jackson-module-parameter-names는 생성자 파라미터 이름을 읽게 해 주는 Jackson 모듈이다

이름이 남아 있고 그 이름을 읽는 모듈이 있으니 Jackson이 생성자 인자를 JSON 필드에 짝지을 수 있었다. 모듈은 jar가 들어와 있는 것만으로는 안 되고 매퍼에 추가(등록)되어야 켜진다. Boot가 매퍼에 모듈을 등록하는 경로 자체는 확인하지 않았다. 결과가 그 설명과 맞을 뿐이다.

Boot 4.1에서는 설정 없는 Jackson 3 매퍼도 성공했다. 파라미터 이름 지원이 Jackson 3 기본에 들어간 것으로 보이지만 이것도 확인하지 않았다.

반증된 범위도 적어둔다. 표준 글 원칙 3에는 문장이 셋 들어 있다고 볼 수 있다 — 「class는 명시해야 한다」, 명시하면 Jackson이 「@JsonCreator를 붙인 그 생성자로만 만든다」, 「record는 자동이다」. 이 중 무너진 것은 「명시하지 않으면 안 된다」는 필요성이다. 나머지 둘은 이 실험이 재지 않았다.


이 결과가 표준에 남기는 것 — 규칙은 그대로 두고 모순 이슈로 넘긴다

표준을 증명하는 방식 원칙 8은 반증이 나와도 결과를 지우지 않고 발행한 뒤 이슈에서 규칙을 다시 따지라고 정했다. 반증 한 건으로 규칙을 고치지 않는 것은 실험 쪽이 틀렸을 수도 있기 때문이다. 그대로 규칙을 바로 고치지 않았다. 표준 글 원칙 3에 > 반증:으로 시작하는 인용 줄(이 결과의 요약 한 줄과 이 글로 가는 링크)을 달고, 이걸 다룰 GitHub 이슈를 하나 열었다. kind: 모순은 이 블로그가 이슈에 붙이는 분류표 중 하나로, “두 표준이 서로를 부정한다”는 뜻이다 — 실험 결과가 표준 글의 문장과 어긋났다는 걸 이슈 제목만 보고도 알 수 있게 붙인다.

이슈에서 따질 것은 @JsonCreator를 계속 붙일 이유가 필요성 말고도 남는가다. 생성자가 둘 이상일 때 어느 것을 쓸지, 컴파일 옵션이 빠진 빌드에서 어떻게 실패하는지(빌드할 때 컴파일 오류로 잡히지 않고, 서버가 떠 있는 중에 클라이언트 요청이 실제로 와서 역직렬화가 일어나는 순간에야 드러나는지) 같은 것들이다. 이유가 남으면 문장만 고치고, 없으면 규칙을 고친다.


정리

  • Boot가 준 매퍼는 3.5.16과 4.1.1 모두 @JsonCreator 없이 2인자 생성자로 역직렬화했다
  • 표준의 문장은 설정 없는 Jackson 2 매퍼에서만 참이었다. -parameters와 파라미터 이름 모듈이 차이를 만든 것으로 보인다 — 등록 경로는 확인하지 않았다
  • 반증된 것은 「명시해야 한다」는 필요성이다. 명시했을 때의 동작은 재지 않았다
  • 규칙은 모순 이슈에서 따진 뒤에 고친다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기