grep

Engineering

올해에는 DTO에 @Jacksonized 하나 놓아 드려야겠어요

Carlsen여기어때

2025년 7월 30일

원문에서 보기 ↗

단일 필드 @Builder 클래스의 Jackson 역직렬화 이슈 해결하기

바쁜 현대인들을 위한 3줄 요약

들어가며

안녕하세요, 여기어때컴퍼니 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 팀의 해당 이슈들에 대한 입장은 다음과 같습니다:

기본 전제

설계 원칙

일관성 정책

하지만 Jackson의 친절한 기능 때문에 필드가 두 개 이상이면 역직렬화가 잘 되는 일관성 문제가 있었습니다.

Jackson의 파라미터 처리 방식

여기서의 핵심 문제는 Jackson이 생성자를 처리하는 방식에 있습니다.

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

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

❌ 실패 케이스 (우리가 원하는 것)
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를 권장하는 이유

@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을 함께 사용할 때는 다음 원칙을 따르는 것이 좋습니다:

@Jacksonized 어노테이션은 Lombok의 보수적 기능 통합 정책상 experimental기능으로 분류되어 있지만, 1.18.14(2020년) 버전 이후 5년간 널리 사용되고 있습니다. 또한 Lombok 팀도 빌더 기반 Jackson 역직렬화를 위해 사용을 권장합니다.

이 원칙만 지켜도 예상치 못한 역직렬화 오류를 방지하고 코드의 일관성을 확보할 수 있습니다.

앞으로 저와 같은 이슈를 겪고 계신 분들께 도움이 되길 바랍니다.

감사합니다!

참고 자료