Engineering
올해에는 DTO에 @Jacksonized 하나 놓아 드려야겠어요
Carlsen여기어때
2025년 7월 30일
원문에서 보기 ↗단일 필드 @Builder 클래스의 Jackson 역직렬화 이슈 해결하기

바쁜 현대인들을 위한 3줄 요약
- 필드가 하나뿐인 클래스에서 @Builder 사용 시 JSON 변환 오류 발생
- Jackson이 단일 필드를 특별하게 처리하기 때문
- 해결책: @Jacksonized 어노테이션 추가
들어가며
안녕하세요, 여기어때컴퍼니 BFF개발팀 칼슨입니다.
외부 API를 연동할 때 Lombok의 @Builder와 Jackson 역직렬화를 함께 사용하는 경우가 많습니다. 대체로 별다른 문제 없이 동작하지만 필드가 단 하나뿐인 DTO에서는 의외의 역직렬화 에러가 발생하곤 합니다. 이번 글에서는 그 원인을 파헤치고, 간단한 설정만으로 오류를 해결하는 방법을 소개하겠습니다.
이런 에러 본 적 있으신가요?
필드가 하나뿐인 DTO를 만들었는데 이런 에러가 나온 적 있으신가요?
@Builder
public class SingleFieldDto {
private String id;
}
{"id": "123"}
org.springframework.web.client.RestClientException:
Error while extracting response for type [SingleFieldDto] and content type [application/json];
nested exception is org.springframework.http.converter.HttpMessageNotReadableException:
JSON parse error:
Cannot construct instance of `SingleFieldDto` (although at least one Creator exists):
cannot deserialize from Object value (no delegate- or property-based Creator)
분명히 JSON도 맞고, 클래스도 정상인데 왜 안 될까요? 🤔
왜 이런 일이 생기는 것일까요?
이 문제를 이해하려면 Jackson의 역직렬화 우선순위와 Lombok @Builder에 대해 함께 살펴봐야 합니다.
Jackson 역직렬화 우선순위
Jackson은 JSON을 Java 객체로 변환할 때 다음 우선순위를 따릅니다.
@JsonCreator 어노테이션이 붙은 생성자나 팩토리 메서드를 가장 먼저 찾습니다.
@ConstructorProperties 어노테이션이 붙은 생성자를 두 번째로 찾습니다.
파라미터 이름을 기반으로 매핑 가능한 생성자를 세 번째로 찾습니다. 이때 ParameterNamesModule과 -parameters 컴파일 옵션이 필요하며, Spring Boot 2.0 이상에서는 자동으로 설정됩니다.
public no-arg 생성자와 setter 또는 필드 주입을 마지막으로 사용합니다. setter가 있으면 setter를 호출하고, 없으면 리플렉션으로 필드에 직접 값을 주입합니다.
Lombok @Builder의 동작 방식
@Builder는 빌더 패턴을 자동 생성해 주는 편리한 어노테이션입니다. 내부적으로 all-args 생성자가 필요하기 때문에 package-private all-args 생성자를 생성합니다.
@Builder
public class UserDto {
private String id;
// Lombok이 자동 생성
/* package-private */ UserDto(String id) {
this.id = id;
}
}
이와 관련해서 Lombok 프로젝트에서 #1099번 이슈와 #2943번 이슈가 제기됐는데, 바로 단일 필드에서의 역직렬화 오류입니다.
Lombok 팀의 입장
Lombok 팀의 해당 이슈들에 대한 입장은 다음과 같습니다:
기본 전제
- @Builder를 클래스에 적용하는 것은 @AllArgsConstructor(access = AccessLevel.PACKAGE)를 추가하고 그 생성자에 @Builder를 적용하는 것과 같습니다.
설계 원칙
- @Builder가 붙은 클래스의 생성자는 사용자가 직접 호출하도록 의도되지 않았습니다.
- 빌더 패턴을 사용할 때는 빌더를 통해서만 객체를 생성해야 합니다.
- package-private 생성자는 “내부 구현”으로 간주합니다.
일관성 정책
- Lombok은 public/protected 생성자에만 @ConstructorProperties를 추가하는 정책을 갖고 있습니다.
- package-private나 private 생성자는 “외부 프레임워크가 사용할 생성자”가 아니라고 봅니다.
하지만 Jackson의 친절한 기능 때문에 필드가 두 개 이상이면 역직렬화가 잘 되는 일관성 문제가 있었습니다.
Jackson의 파라미터 처리 방식
여기서의 핵심 문제는 Jackson이 생성자를 처리하는 방식에 있습니다.
@Builder만 사용한 멀티 필드 클래스에서는:
- Spring Boot의 기본 설정으로 ParameterNamesModule이 등록
- 컴파일 시 -parameters 옵션 (Spring Boot가 자동 처리)
- Jackson이 properties-based 모드에서 여러 파라미터 생성자의 파라미터 이름을 JSON 필드명과 매핑
{ "id": "123", "name": "John" }같은 객체를 정상 바인딩
반면 @Builder만 사용한 단일 필드 클래스에서는:
- 단일 파라미터 생성자는 Jackson이 기본적으로 delegate 모드로 간주
- JSON이 스칼라(
"foo"또는123)일 때만 동작 { "id": 1 }같은 객체 형태는 바인딩하지 않아 실패
❌ 실패 케이스 (우리가 원하는 것)
JSON: {"id": "123"}
에러: Delegate Creator는 객체를 받을 수 없음
⚠️ Delegate Mode가 처리할 수 있는 형태
JSON: "123" (단순 문자열)
하지만 이건 우리가 원하는 API 형태가 아님
멀티 필드
JSON: {"id": "123", "name": "John"} → 성공!
해결 방법
필드가 하나라면
위에서 살펴봤듯이 단일 필드 클래스에서는 추가적인 조치가 필요합니다. @AllArgsConstructor만 붙이면 단일 파라미터를 포함하는 생성자이기 때문에 delegate 모드로 간주되어 똑같이 실패하기 때문입니다.
해결 방법을 하나하나 살펴보겠습니다:
방법 1: @Jacksonized 사용 (Lombok 1.18.14+)
@Builder
@Jacksonized // 권장
public class SingleFieldDto {
private String id;
}
방법 2: NoArgs + AllArgs 조합
@Builder
@NoArgsConstructor // no-arg + 필드 주입 방식으로 우회
@AllArgsConstructor // @Builder가 내부적으로 all-args 생성자 필요
public class SingleFieldDto {
private String id;
}
방법 3: 수동 @JsonCreator
@Builder
public class SingleFieldDto {
private String id;
@JsonCreator
public SingleFieldDto(@JsonProperty("id") String id) {
this.id = id;
}
}
필드가 두 개 이상이라면
멀티 필드 클래스는 @Builder만 써도 정상 동작합니다. Spring Boot 환경에서 Jackson이 ParameterNamesModule과 -parameters 컴파일 옵션을 통해 package-private 생성자의 파라미터 이름을 인식할 수 있기 때문입니다. (앞에서 언급했던 친절한 기능이 이걸 뜻합니다.)
하지만 이는 Lombok 팀이 의도한 설계(package-private 생성자는 내부 전용)와 어긋나는 동작입니다. 따라서 명시적으로 @Jacksonized를 추가하는 것을 권장합니다.
멀티 필드 클래스에서도 @Jacksonized를 권장하는 이유
- 의도의 명확한 표현: Jackson 역직렬화가 필요함을 코드로 명시
- 일관성 있는 코딩 스타일: 단일/멀티 필드 구분 없이 동일한 패턴 적용
- 설계 원칙 준수: Lombok 팀이 의도한 방식(빌더 전용)과 일치
- 코드 리뷰어의 이해도 향상: 왜 이 어노테이션이 있는지 명확함
@Builder
@Jacksonized // 이렇게 하길 희망합니다.
public class MultiFieldDto {
private String id;
private String name;
}
현재 문제점과 개선 방식
현재 저희 팀 코드에서는 두 번째 방법(@NoArgsConstructor + @AllArgsConstructor)을 사용하고 있었습니다. 역직렬화는 잘 동작하지만 이 방식에는 몇 가지 문제점이 있습니다.
기존 방식의 문제점:
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SingleFieldDto {
private String id;
// 문제점:
// 1. 생성자 2개 생성 (과도함)
// 2. Builder 있는데 직접 생성 가능 (안티패턴)
}
// 의도하지 않은 생성 방식
new SingleFieldDto(); // no-args constructor
new SingleFieldDto("123"); // all-args constructor
// 의도된 생성 방식
SingleFieldDto.builder()
.id("123")
.build(); // builder
수정한 방식:
@Builder
@Jacksonized
public class SingleFieldDto {
private String id;
// 장점:
// 1. 명확하고 간결
// 2. Builder만 사용 가능 (일관성)
// 3. Jackson 역직렬화 완벽 지원
}
@Jacksonized 내부 동작
@Jacksonized를 사용하면 Lombok이 다음과 같이 컴파일합니다:
// 컴파일 후
@JsonDeserialize(builder = SingleFieldDto.SingleFieldDtoBuilder.class)
public class SingleFieldDto {
private String id;
@JsonPOJOBuilder(withPrefix = "")
public static class SingleFieldDtoBuilder {
// builder 메서드들...
}
}
이 모든 처리는 컴파일 타임에 이루어지므로 런타임 성능에 유의미한 영향은 없습니다.
결론
Lombok의 @Builder와 Jackson을 함께 사용할 때는 다음 원칙을 따르는 것이 좋습니다:
- 모든 Builder 클래스에 @Jacksonized 적용: 단일 필드든 멀티 필드든 관계없이 빌더 기반 역직렬화를 명시적으로 활성화해 일관된 동작과 코드 의도를 보장합니다.
- @NoArgsConstructor + @AllArgsConstructor 조합 지양: 이 방식은 public 생성자들을 불필요하게 노출하여 빌더 외의 객체 생성 방법을 허용합니다. 빌더 패턴의 핵심 이점인 “단일 생성 방식을 통한 일관성”을 해치게 됩니다.
@Jacksonized 어노테이션은 Lombok의 보수적 기능 통합 정책상 experimental기능으로 분류되어 있지만, 1.18.14(2020년) 버전 이후 5년간 널리 사용되고 있습니다. 또한 Lombok 팀도 빌더 기반 Jackson 역직렬화를 위해 사용을 권장합니다.
이 원칙만 지켜도 예상치 못한 역직렬화 오류를 방지하고 코드의 일관성을 확보할 수 있습니다.
앞으로 저와 같은 이슈를 겪고 계신 분들께 도움이 되길 바랍니다.
감사합니다!
참고 자료