[SPRING] @Builder는 @Jacksonized를 좋아해

wannabeing·2025년 9월 9일
0

SPRING

목록 보기
17/17
post-thumbnail

우리는 Lombok의 @Builder 패턴Jackson 라이브러리의 역직렬화를 함께 사용하여 DTO를 자주 생성한다.
필드가 단 하나뿐인 DTO에서는 역직렬화 에러가 발생하는데, 그 이유와 해결방법에 대해 살펴보고자 한다.

(1) 상황

@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에 대해 알아봅시다!


1-1) Jackson 라이브러리 역직렬화 우선순위

❓ Jackson 라이브러리

Jackson 라이브러리는 HTTP 요청/응답 계층에서 사용되는 라이브러리이다.
클라이언트에서 HTTP 요청에 담겨진 JSON(데이터)을 변환할 때,
서버에서 HTTP 응답으로 JSON(데이터)을 변환할 때에 사용된다.

  1. @JsonCreator 어노테이션이 붙은 생성자나 팩토리 메서드를 가장 먼저 찾는다.

  2. @ConstructorProperties 어노테이션(@NoArgs-, @AllArgs-, @RequiredArgs-)이 붙은 생성자를 두 번째로 찾는다.
    public/protected 생성자에만 @ConstructorProperties를 붙인다.

  3. 파라미터 이름을 기반으로 매핑 가능한 생성자를 세 번째로 찾는다.
    이때 ParameterNamesModule과 -parameters 컴파일 옵션이 필요하며,
    Spring Boot 2.0 이상에서는 자동으로 설정된다.

  4. 마지막으로 public 기본 생성자와 setter 또는 필드 주입을 찾는다.
    setter가 있으면 setter를 호출하고, 없으면 리플렉션으로 필드에 직접 값을 주입한다.


1-2) @Builder 동작방식에 대해 맛만보자

@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가 붙은 클래스의 생성자(packate-private)을 사용자가 직접 호출하는 것은 설계의도가 아닙니다.
  • Lombok은 public/protected 생성자에만 생성자 어노테이션을 추가하는 정책을 갖고 있습니다.
    Lombok은 public/protected 생성자에만 @ConstructorProperties를 추가하는 정책을 갖고 있습니다.
    package-private나 private 생성자는 “외부 프레임워크가 사용할 생성자”가 아니라고 봅니다.
  • 하지만 Jackson의 친절한 기능 때문에 필드가 두 개 이상이면 해당 생성자가 사용되어서 역직렬화가 잘되는 일관성 문제가 있습니다.

두줄 요약

  • 롬복은 “공식 입구(빌더)”를 만들고, “비밀 뒷문(패키지 생성자)”도 같이 만들었다.
  • 원래 이 뒷문은 내부 직원(빌더)만 쓰라고 만든 건데, Jackson 같은 착한 택배 기사(Jackson)가 이 뒷문도 열어버리는 상황이 생겨버림

1-3) Jackson이 생성자를 처리하는 방식이 문제였다!

@Builder만 사용한 멀티 필드 클래스에서는

  • Spring Boot의 기본 설정으로 ParameterNamesModule이 등록
  • 컴파일 시 -parameters 옵션 (Spring Boot가 자동 처리)
    → ✅ Jackson 라이브러리 역직렬화 2번째 순위에 해당
  • Jackson이 properties-based 모드에서 여러 파라미터 생성자의 파라미터 이름을 JSON 필드명과 매핑
  • { "id": "123", "name": "John" } 같은 객체를 정상 바인딩

반면 @Builder만 사용한 단일 필드 클래스에서는

  • 단일 파라미터 생성자는 Jackson이 기본적으로 delegate 모드로 간주
  • JSON이 기본 데이터타입(스칼라)일때만 동작
  • { "id": 1 } 같은 객체 형태는 바인딩하지 않아 실패하는 것이였다.
❌ 실패 케이스 (우리가 원하는 것)
JSON: {"id": "123"}
에러: Delegate Creator는 객체를 받을 수 없음

⚠️ Delegate Mode가 처리할 수 있는 형태
하지만 이건 우리가 원하는 API 형태가 아님
JSON: "123" (단순 문자열)

멀티 필드
JSON: {"id": "123", "name": "John"} → 성공!

(2) 여러 해결 방법

2-1) @Jacksonized 사용 (권장)

@Builder
@Jacksonized
public class SingleFieldDto {
    private String id;
}
  • 의도의 명확한 표현: Jackson 역직렬화가 필요함을 코드로 명시
  • 일관성 있는 코딩 스타일: 단일/멀티 필드 구분 없이 동일한 패턴 적용
  • 설계 원칙 준수: Lombok 팀이 의도한 방식(빌더 전용)과 일치
  • 코드 리뷰어의 이해도 향상: 왜 이 어노테이션이 있는지 명확함

2-2) NoArgs + AllArgs 사용

@Builder
@NoArgsConstructor  // no-arg + 필드 주입 방식으로 우회
@AllArgsConstructor // @Builder가 내부적으로 all-args 생성자 필요
public class SingleFieldDto {
    private String id;
}
  • 동작은 하지만 생성자 2개임 → 과도한 생성자 개수
  • 기본생성자, public한 all-args 생성자 두개가 생성된다.
  • 빌더가 있는데 직접 생성할 수 있는건 안티패턴이다.

2-3) @JsonCreator

@Builder
public class SingleFieldDto {
    private String id;
    
    @JsonCreator
    public SingleFieldDto(@JsonProperty("id") String id) {
        this.id = id;
    }
}

(3) 결론

이 모든 처리는 컴파일 타임에 이루어지므로 런타임 성능에 유의미한 영향은 없다고한다.

멀티 필드 클래스는 @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 공식문서

profile
wannabe---ing

0개의 댓글