Jackson은 자바 객체와 JSON을 서로 바꿔 주는 라이브러리이고, 스프링이 기본으로 내장해 쓴다. JSON을 받아 객체를 만들 때 Jackson이 고를 수 있는 길은 크게 둘이다.
- 생성자 경로 — 필드 값을 전부 생성자에 넘겨 한 번에 완성된 객체를 만든다
- 기본 생성자 + setter 경로 — 아무 값도 안 받는 생성자로 빈 객체를 먼저 만들고, setter를 하나씩 불러 필드를 채운다
DTO 생성자 표준은 DTO가 생성자 경로로만 만들어지게 하자는 표준이다. 그 원칙 3에 「가장 위험한 것은 조용한 되돌아감이다」라는 절이 있다. 생성자로 만들어지길 바랐는데 Jackson이 아무 말 없이 두 번째 길로 돌아가 버린다는 뜻이다.
@NoArgsConstructor나 @Setter가 하나라도 붙어 있으면, Jackson은
빈 객체를 만들고 필드를 하나씩 채우는 경로를 쓸 수 있다.
에러도, 경고도 없다.
@NoArgsConstructor는 Lombok이 기본 생성자를 만들어 주는 표시이고, @Setter는 필드마다 setter를 만들어 주는 표시다. 이 문장이 표준의 금지 목록 — DTO에 @Setter·@NoArgsConstructor·@Data를 쓰지 않는다 — 의 근거다. 이 글은 이 문장이 실제로 맞는지 돌려 본 실험이고, 표준을 증명하는 방식의 시범 실험 셋 중 하나다. 실험마다 proofs/<슬러그>/ 폴더(슬러그는 실험마다 붙인 영문 짧은 이름이고, 이 실험은 jackson-setter-fallback-silent다)에 돌리기 전에 쓴 주장 파일(claim.md), 명령 하나로 실험을 처음부터 다시 돌리는 셸 스크립트(run.sh), 그 스크립트가 쓴 결과 파일(result.md)을 둔다. result.md 첫머리에는 판정 한 줄과 결과를 한 줄로 줄인 요약 줄이 있다.
주장 — 표준의 ❌ 예시는 경고 없이 기본 생성자와 setter로 만들어진다
돌리기 전에 claim.md에는 이렇게 적었다. 대상은 표준 글이 ❌로 보여 준 예시 class 그대로다 — @Getter는 필드마다 get 메서드를, @AllArgsConstructor는 필드를 전부 받는 생성자를 만들어 준다. 그래서 이 class에는 기본 생성자 + setter 경로와 생성자 경로가 둘 다 열려 있다.
@Getter·@Setter·@NoArgsConstructor·@AllArgsConstructor를 붙인 class는,
Spring Boot가 설정한 ObjectMapper가 예외도 경고도 없이
기본 생성자로 빈 객체를 만들고 값을 채우는 경로로 역직렬화한다.
Spring Boot가 기본으로 설정해 둔 ObjectMapper가 이 JSON 변환을 실제로 수행한다. JSON을 자바 객체로 되돌리는 이 일을 역직렬화라고 부른다. 여기서 표준 글보다 한 걸음 좁게 적었다. 표준은 「쓸 수 있다」인데 나는 「쓴다」로 적었다. 이 차이가 결과에서 그대로 드러났다(「예상과 달랐던 점」).
반증 조건 — 전 인자 생성자로 가거나 경고가 한 건이라도 나면 반증이다
돌리기 전에 적은 선이다. 아래 목록의 말은 다음 「설계」 절에서 자세히 풀고, 여기서는 읽는 데 필요한 만큼만 미리 적는다.
- 두 환경 — Spring Boot 3.5.16과 4.1.1이다
- 부분 입력 —
quantity를 뺀 JSON{"productName":"mouse"}다. 정상 입력은 두 필드를 다 채운{"productName":"mouse","quantity":3}이다 quantity=7/quantity=null— 실험용 class의quantity에 초기값 7을 넣어 두었다. 기본 생성자 경로면 그 7이 살아남고, 전 인자 생성자 경로면null로 덮인다. 그래서 이 값 하나로 어느 경로였는지 가른다-
mouse,3— 만들어진 객체의productName값과quantity값을 쉼표로 이어 적은 것이다 - refuted — 두 환경 중 하나라도 부분 입력에서
quantity=null(전 인자 생성자 경로), 역직렬화 예외, WARN 이상 로그 1건 이상 중 하나가 나온다 - supported — 두 환경 모두 부분 입력
quantity=7, 정상 입력mouse,3, WARN 0건 - inconclusive — 그 밖의 경우
설계 — 필드 초기값 7로 어느 경로로 만들어졌는지 가른다
대상 타입은 표준 글의 ❌ 예시 그대로다. 한 곳만 더했다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class SetterRequest {
private String productName;
private Integer quantity = 7; // ← 경로를 가르는 표식
}
그리고 quantity가 빠진 JSON {"productName":"mouse"}를 넣는다.
기본 생성자 경로 new SetterRequest() → 초기값 7이 산다 → setProductName → quantity=7
전 인자 생성자 경로 new SetterRequest("mouse", null) → 생성자가 null을 넣는다 → quantity=null
위 줄은 기본 생성자 경로다. new SetterRequest()로 빈 객체를 만들면 필드 선언에 적은 초기값 7이 먼저 들어가고, JSON에 있는 productName만 setter로 채우니 quantity는 7로 남는다. 아래 줄은 생성자 경로다. 전 인자 생성자는 인자 두 개를 다 받아야 불릴 수 있는데 JSON에는 quantity가 없으니, 그 자리에 null이 넘어간다고 보고 설계했다(결과의 quantity=null이 이 예상과 맞았다). 생성자 몸통이 this.quantity = quantity로 null을 대입하면서 초기값 7을 덮는다.
Jackson 안에서 어느 메서드가 불렸는지 스택 트레이스처럼 호출 기록을 따라가 보는 방법도 있지만, 그럴 필요 없이 결과값 하나로 경로가 갈린다.
경고는 이렇게 셌다. Logback은 Spring Boot가 기본으로 쓰는 로그 출력 도구다. 역직렬화 두 번을 하는 동안 Logback의 루트 로거에 로그를 메모리 리스트에 모아 두는 appender를 붙이고, 끝난 뒤 모인 기록 중 WARN 이상인 것의 개수를 셌다.
매퍼와 환경은 생성자 감지 실험과 같다.
- Boot가 등록한 매퍼 — 내가
new ObjectMapper()로 만든 것이 아니라 Spring Boot가 미리 설정해 빈으로 만들어 둔 ObjectMapper를 꺼내 썼다. 실험 코드에서는context.getBean(ObjectMapper.class)— Spring이 들고 있는 객체 중 ObjectMapper 타입인 것을 달라는 호출 — 로 꺼냈다. 실제 서비스에서 HTTP 요청에 담겨 온 JSON을 객체로 바꾸는 것이 이 매퍼라서다 - Boot Gradle 플러그인 — Spring Boot가 Gradle 빌드(컴파일을 포함해 실행할 수 있는 형태로 만드는 과정 전체)에 끼워 넣는 부품이다. 실제 프로젝트가 이렇게 빌드하므로 실험도 그렇게 컴파일했다. 이 플러그인이 컴파일할 때
-parameters옵션을 켜는 것은 생성자 감지 실험에서 확인했다 - Boot 3.5.16과 4.1.1 — Boot 3은 Jackson 2를, Boot 4는 Jackson 3을 쓴다. 표준을 쓰는 프로젝트가 어느 쪽인지 모르니 둘 다 쟀다
- Lombok 버전 — Gradle이 읽는 빌드 설정 파일인
build.gradle에는 쓸 라이브러리를 적는데, 거기에 Lombok 버전을 적지 않고, Spring Boot가 버전마다 함께 쓸 라이브러리 버전을 정해 둔 목록(spring-boot-dependencies)에 맡겼다. 두 Boot 모두 1.18.46이 골라졌다
결과 — Boot 3.5는 setter로, Boot 4.1은 전 인자 생성자로 만들었고 경고는 둘 다 0건이었다
run.sh가 쓴 원본 출력이다.
PROBE_ENV boot=3.5.16 lombok=1.18.46
PROBE boot=3.5.16 jackson=2.21.4 java=21.0.9 params=true partial=ok:mouse,7 full=ok:mouse,3 warns=0
PROBE_ENV boot=4.1.1 lombok=1.18.46
PROBE boot=4.1.1 jackson=3.1.5 java=21.0.9 params=true partial=ok:mouse,null full=ok:mouse,3 warns=0
한 줄씩 따라가면 이렇다. PROBE_ENV의 boot=은 그 환경의 Spring Boot 버전이고 lombok=은 함께 쓴 Lombok 버전이다. PROBE의 jackson=은 Boot가 내장한 Jackson 버전, java=는 JDK 버전, params=는 -parameters 컴파일 옵션이 켜져 생성자 파라미터 이름을 읽을 수 있는지다. 이름이 남아 있어야 Jackson이 JSON의 productName 키를 생성자의 productName 파라미터와 짝지을 수 있으니, 생성자 경로가 애초에 가능한 조건인지 확인하려고 찍었다(두 버전 모두 true). partial=은 quantity가 빠진 JSON을 넣었을 때의 결과다 — ok 뒤에 콜론으로 이어 만들어진 객체의 productName값과 quantity값을 순서대로 적었다(ok:mouse,7이면 역직렬화가 성공했고 productName은 mouse, quantity는 7이라는 뜻). full=은 두 필드를 다 채운 JSON을 넣었을 때 같은 형식의 결과이고, warns=는 그동안 찍힌 WARN 이상 로그 수다.
- Boot 3.5.16 —
quantity=7. 기본 생성자와 setter 경로다. 표준 글이 경고한 모양 그대로다 - Boot 4.1.1 —
quantity=null. 전 인자 생성자 경로다. 같은 class인데 경로가 갈렸고, 두 줄에서 달라진 것은 Boot 버전과 함께 바뀐 Jackson 버전(2.21.4 → 3.1.5)이다. Jackson 안의 어느 규칙이 경로를 바꿨는지는 이 실험이 재지 않았다 - 경고 — 두 버전 모두 0건. 정상 입력은 두 버전 모두 값이 다 채워졌다
판정은 refuted다. Boot 4.1.1이 반증 조건의 첫 항목(quantity=null)에 걸렸다.
예상과 달랐던 점 — 반증은 규칙이 아니라 내가 좁혀 적은 주장에서 나왔다
표준 글의 문장을 다시 대보면 이렇다.
| 표준 글의 문장 | Boot 3.5.16 | Boot 4.1.1 |
|---|---|---|
| setter 경로를 쓸 수 있다 | 썼다 | 안 썼다 |
| 에러도 경고도 없다 | 0건 | 0건 |
표준이 경고한 「조용하다」는 두 버전 모두에서 맞았다. 반증이 난 것은 내가 「쓸 수 있다」를 「쓴다」로 좁혀 적었고, Boot 4.1이 다른 경로를 골랐기 때문이다.
그래도 판정을 고치지 않았다. 결과를 보고 반증 조건을 바꾸면 선을 먼저 그은 의미가 사라진다(표준을 증명하는 방식 원칙 8). 설계를 고쳐 다시 재야 한다면 새 실험으로 만든다.
뜻밖에 하나가 더 보였다. Boot 4.1에서는 필드 초기값이 조용히 사라졌다. JSON에 없는 필드가 기본값 7이 아니라 null이 됐다. 같은 DTO가 Boot 버전을 3.5에서 4.1로 올리는 것만으로 다른 값을 갖게 된다. 규칙이 막으려던 「조용한 되돌아감」과는 다른 모양의 조용한 변화다. 이 실험의 주장 밖이라 판정에는 넣지 않았다.
이 결과가 표준에 남기는 것 — 문장을 버전 조건과 함께 다시 적을지 이슈에서 정한다
반증이 나도 표준 글을 바로 고치지 않는다. 실험 쪽이 틀렸을 수도 있어서다. 대신 두 가지를 했다. 표준 글 원칙 3 첫머리에 > 반증: <result.md의 요약 줄> — [증명](이 글 링크) 모양의 인용 줄을 달아(줄 앞의 >는 마크다운에서 인용 표시라, 화면에는 본문과 구분된 인용 상자로 보인다), 그 원칙을 읽는 사람이 반증 결과가 있다는 것을 바로 보게 했다. 그리고 생성자 감지·Lombok final 필드 실험과 함께 GitHub 이슈 하나로 넘겼다. 이 이슈는 기존 규칙과 실측이 서로 어긋난다는 뜻으로 kind: 모순이라고 분류해 둔 안건 이슈다.
이슈에서 가를 것은 둘이다. 실험의 결함 — 주장을 좁혀 적은 것 — 인지, 규칙의 결함 — 경로가 버전마다 달라 「setter로 되돌아간다」는 설명이 낡은 것 — 인지다.
금지 목록 자체는 이 실험만으로는 흔들리지 않는다. 표준 글이 금지 목록을 두는 이유는 「어느 경로로 만들어지든 경고 없이 통과해서, setter가 열려 있어 다른 코드가 상태를 바꿀 수 있는 객체가 아무도 모르게 생긴다」는 데 있다. 그 표준은 「상태가 완성되는 지점은 생성자 하나뿐이다」를 첫 원칙으로 두고, setter를 없애 생성 이후의 상태 변경을 막는다(원칙 1). 만든 뒤에도 setter로 값이 바뀔 수 있으면, 그 값이 언제 누구 때문에 바뀌었는지 코드 전체를 봐야 알 수 있어서다. 그런 객체가 경고 하나 없이 만들어지면 누구도 그 규칙이 깨졌다는 것을 모른다. 경로가 버전마다 달랐어도 경고 없이 통과한다는 부분은 두 버전 모두에서 확인됐다. 흔들린 것은 「setter로 되돌아간다」는 설명 쪽이다.
정리
- 경고는 두 버전 모두 0건이다. 표준이 말한 「조용하다」는 맞았다
- 경로는 버전마다 달랐다. Boot 3.5.16은 기본 생성자+setter, Boot 4.1.1은 전 인자 생성자
- 반증은 「쓸 수 있다」를 「쓴다」로 좁혀 적은 주장에서 났다. 판정은 고치지 않는다
- Boot 4.1에서는 JSON에 없는 필드의 초기값이
null로 덮였다. 주장 밖의 발견이다
자신만의 철학을 만들어가는 중입니다.
댓글남기기