
우리는 Lombok의 @Builder 패턴과 Jackson 라이브러리의 역직렬화를 함께 사용하여 DTO를 자주 생성한다.
필드가 단 하나뿐인 DTO에서는 역직렬화 에러가 발생하는데, 그 이유와 해결방법에 대해 살펴보고자 한다.
@Builder
public class SingleFieldDto {
    private String id;
}
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)
위와 같은 오류가 뜨고 있습니다.
외부 API를 역직렬화하는 DTO입니다. 필드가 하나밖에 없지만
저희는 @Builder를 쓰고 싶고, Record 클래스는 사용하고 싶지 않습니다.
먼저, (1)Jackson의 역직렬화 우선순위 와 (2)Lombok @Builder에 대해 알아봅시다!
❓ Jackson 라이브러리
Jackson 라이브러리는 HTTP 요청/응답 계층에서 사용되는 라이브러리이다.
클라이언트에서 HTTP 요청에 담겨진 JSON(데이터)을 변환할 때,
서버에서 HTTP 응답으로 JSON(데이터)을 변환할 때에 사용된다.
@JsonCreator 어노테이션이 붙은 생성자나 팩토리 메서드를 가장 먼저 찾는다.
@ConstructorProperties 어노테이션(@NoArgs-, @AllArgs-, @RequiredArgs-)이 붙은 생성자를 두 번째로 찾는다.
→ public/protected 생성자에만 @ConstructorProperties를 붙인다.
파라미터 이름을 기반으로 매핑 가능한 생성자를 세 번째로 찾는다.
이때 ParameterNamesModule과 -parameters 컴파일 옵션이 필요하며,
Spring Boot 2.0 이상에서는 자동으로 설정된다.
마지막으로 public 기본 생성자와 setter 또는 필드 주입을 찾는다.
setter가 있으면 setter를 호출하고, 없으면 리플렉션으로 필드에 직접 값을 주입한다.
@Builder
public class UserDto {
    private String id;
    
    // Lombok이 자동 생성
    /* package-private */ UserDto(String id) { 
        this.id = id; 
    }
}
@Builder 어노테이션을 사용하면, 내부적으로 동작할 때, all-args 생성자가 필요하다.
때문에 위와같이 패키지내에서만 접근가능한 all-args 생성자를 생성한다.

여기서 패키지내에서 접근가능한 all-args 생성자는 개발자도 쓰지 말고, 오직 Lombok팀이 @Builder만 쓰게 만든 장치이다.
하지만, 예상했듯이 Jackson라이브러리가 설계의도에 벗어나서 "private-package all-args 생성자"를 참조하여 DTO를 역직렬화한다는 것이다!
✨ 이에 대해 Lombok 팀은 다음과 같이 이야기한다.
@Builder를 클래스에 적용하는 것은@AllArgsConstructor(access = AccessLevel.PACKAGE)를 추가하고 그 생성자에 @Builder를 적용하는 것을 의미합니다.@Builder만 사용한 멀티 필드 클래스에서는
{ "id": "123", "name": "John" } 같은 객체를 정상 바인딩반면 @Builder만 사용한 단일 필드 클래스에서는
{ "id": 1 } 같은 객체 형태는 바인딩하지 않아 실패하는 것이였다.❌ 실패 케이스 (우리가 원하는 것)
JSON: {"id": "123"}
에러: Delegate Creator는 객체를 받을 수 없음
⚠️ Delegate Mode가 처리할 수 있는 형태
하지만 이건 우리가 원하는 API 형태가 아님
JSON: "123" (단순 문자열)
멀티 필드
JSON: {"id": "123", "name": "John"} → 성공!
@Builder
@Jacksonized
public class SingleFieldDto {
    private String id;
}
@Builder
@NoArgsConstructor  // no-arg + 필드 주입 방식으로 우회
@AllArgsConstructor // @Builder가 내부적으로 all-args 생성자 필요
public class SingleFieldDto {
    private String id;
}
@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 팀이 의도한 설계와 어긋나는 동작이다.
@NoArgs + @AllArgs 패턴은 빌더 패턴의 핵심 이점인 “단일 생성 방식을 통한 일관성”을 해치게 되므로, 단일필드든 멀티필드든 관계없이 빌더기반 역직렬화에 명시적으로 @Jacksonized를 추가하는 것을 권장한다.
Lombok팀 의견
@Jacksonized 어노테이션은 Lombok의 보수적 기능 통합 정책상 experimental기능으로 분류되어 있지만, 1.18.14(2020년) 버전 이후 5년간 널리 사용되고 있습니다. 또한 Lombok 팀도 빌더 기반 Jackson 역직렬화를 위해 사용을 권장합니다.
[여기어때 기술블로그] 올해에는 DTO에 @Jacksonized 하나 놓아 드려야겠어요
Lombok @Builder 공식문서