Jackson 역직렬화 및 DTO 설계시 주의할 점

LinkinPark·2025년 2월 25일

Json을 객체로 변환하는 과정 및 DTO 작성시 주의사항

Jackson라이브러리를 통한 Json to Object 과정에서, DTO 설계할 때 놓칠 수 있을만한 부분을 끄적여보았다.
주의사항만 적으면 이해가 잘 안될 것 같아, Json to Object 과정까지 간략하게 설명해두었다.

1. JSON 파싱

Jackson의 ObjectMapper가 입력받은 JSON 문자열을 파싱하여 내부적으로 트리나 스트림 형태로 변환한다.

2. POJO 인스턴스 생성

파싱된 JSON 데이터를 기반으로 목표 POJO(Plain Old Java Object)의 인스턴스를 생성한다. 이때 Jackson은 기본 생성자 또는 @JsonCreator 어노테이션을 사용해 객체를 생성한다.

기본적으로는 기본 생성자가 필요하며, 이를 통해 객체의 빈 인스턴스를 만들고 이후 필드를 채운다.

그렇기 때문에, 혹여나 다음과 같이

@Data
public class RequestSignInDTO {
	private String email;
 	private String password;
     
    @Builder
    public RequestSignInDTO(String email, String password){}
}

다음과같이 Builder어노테이션을 통해 파라미터가 있는 생성자를 명시하면 기본생성자가 생성되지 않기 때문에, Controller의 파라미터로 해당 DTO를 사용하는 경우 Exception이 발생하므로 주의해야 한다.

@NoArgsConstructor 어노테이션을 사용하거나, 기본생성자를 명시해주면 된다.

3. 데이터 매핑 및 설정

JSON의 각 프로퍼티 값을 객체의 필드에 매핑한다. 이 과정에서 Jackson은 setter 메소드나 리플렉션을 사용해 값을 할당하며, 프로퍼티 이름과 필드 이름이 일치하는지 확인한다.

private final Object vanillaDeserialize(JsonParser p,
        DeserializationContext ctxt, JsonToken t) throws IOException {
    // 기본 생성자를 통해 빈 객체를 생성
    final Object bean = _valueInstantiator.createUsingDefault(ctxt);
    
    // JSON이 필드 이름을 가지고 있는 경우
    if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
        // 현재 빈 객체를 JsonParser에 할당하여, 커스텀 시리얼라이저에서 참조 가능하게 함
        p.assignCurrentValue(bean);
        // 현재 JSON 필드 이름을 가져옴
        String propName = p.currentName();
        
        // JSON의 모든 필드에 대해 순회
        do {
            // 값에 접근하기 위해 토큰을 다음으로 이동
            p.nextToken();
            // 해당 프로퍼티 이름에 매핑되는 SettableBeanProperty를 찾음
            SettableBeanProperty prop = _beanProperties.find(propName);
            
            if (prop != null) {
                // 매핑되는 프로퍼티가 있으면 해당 값을 역직렬화하여 빈 객체에 설정
                try {
                    prop.deserializeAndSet(p, ctxt, bean);
                } catch (Exception e) {
                    wrapAndThrow(e, bean, propName, ctxt);
                }
            } else {
                // 매핑되는 프로퍼티가 없는 경우, unknown 프로퍼티 처리 로직 호출
                handleUnknownVanilla(p, ctxt, bean, propName);
            }
        } while ((propName = p.nextFieldName()) != null);
    }
    return bean;
}

위 코드는 Jackson 라이브러리 2.18.2의 BeanDeserializer 클래스 내 코드의 일부이다.
보면 SettableBeanProperty를 찾아 해당 프로퍼티 값을 역직렬화한 후 bean에 설정하는 과정을 보여준다.

deserializeAndSet메서드를 따라가다보면

    @Override
    public Object createUsingDefault(DeserializationContext ctxt) throws IOException
    {
        if (_defaultCreator == null) { // sanity-check; caller should check
            return super.createUsingDefault(ctxt);
        }
        try {
            return _defaultCreator.call();
        } catch (Exception e) { // 19-Apr-2017, tatu: Let's not catch Errors, just Exceptions
            return ctxt.handleInstantiationProblem(_valueClass, null, rewrapCtorProblem(ctxt, e));
        }
    }
    
    @Override
    public final Object call() throws Exception {
        // 31-Mar-2021, tatu: Note! This is faster than calling without arguments
        //   because JDK in its wisdom would otherwise allocate `new Object[0]` to pass
        return _constructor.newInstance((Object[]) null);
    }

다음과 같이 create하는 과정에서 리플랙션을 사용하는 것을 알 수 있다.

리플랙션을 통한 객체 생성시, 보통 기본생성자를 호출하므로, 기본생성자는 작성해주는게 좋다.

더 따라가다보면 native method인 newInstance0() method를 찾을 수 있지만, 너무 자세하면 길어지니 필요한 부분만 발췌했다.

즉, Jackson은 먼저 JSON을 파싱하고, 기본 생성자를 통해 객체를 생성한 후, JSON의 각 값을 해당 객체의 필드에 할당하는 방식으로 동작한다.

이런 이유로 기본 생성자가 없거나, JSON 프로퍼티와 매칭되는 setter가 없으면 변환 과정에서 오류가 발생할 수 있으므로 주의해야한다.

마지막으로

물론 기본생성자 없이 Jackson라이브러리에 특정 생성자를 통한 객체생성을 하게 해주는 방법도 있다.
위에서 언급했지만,@JsonCreator 어노테이션을 사용하면 된다.

또 getter, setter가 있는데 왜 리플랙션이 필요하냐는 생각이 들 수도 있을 것 같다.
기본적으로 동적으로 인스턴스 생성에도 리플랙션이 필요하기도하고, getter/setter가 없는 경우도 필드에 직접 접근하기 위해 리플랙션을 쓰기도 한다.
개인적으로 핵심적이라고 생각하는건, 사전에 타입정보를 모르는 상태로 객체 생성을 해야하기 때문에 동적으로 객체 조작에 유용한 리플랙션을 채택한게 아닌가 싶다.

profile
It's work, but touch it

0개의 댓글