글 작성자: 개발섭

TL; DR

등록기라서 사실 요약할 내용은 없다. D2 추천 방식으로 해당하는 Enum Type을 좀 더 쉽게 Deserializing하는 방식을 체크해보고 싶었다.

도입 이유

1. Enum Type의 사용 이유

일단 개발하다보면, 100% 확률로 Enum Type을 사용할 수 밖에 없기때문에 해당하는 타입을 Enum화 해서 관리하는게 좋다. 실제로 실무에서는 DB에 저장되는 형태랑, 실제로 Json 타입으로 주는 형태랑 다를 경우가 많다. 즉, 해당하는 걸 매번 Enum의 키값으로 지정하기에 부담스러울때가 종종 있기 때문이다.

일단 Enum 타입을 만들고, 해당하지 않은 타입으로 전달하게 되면 대략 이런 오류가 발생하기 마련이다.

import lombok.Getter;  
import lombok.RequiredArgsConstructor;  
import lombok.ToString;  

@ToString  
@RequiredArgsConstructor  
@Getter  
public enum BerryType {  

    CANDY("c01", "캔디 타입"),  
    PILL("c02", "알약 타입"),  
    SWEETY("c03", "달달한 타입");  

    private final String code;  
    private final String description;  
}

org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type ENUM from String "c01": not one of the values accepted for Enum class: [SWEETY, CANDY, PILL]]

Enum 타입의 Type String만 사용시 문제점

근데 이제 전달은 Code로 전달될수 있기 때문에 해당하는 Deserialize를 해주려면... 어노테이션을 포함해서 처리하는 방법도 있다, 하지만, 이러한 방식은 결국 놓치지는 지점이 생길 수 밖에 없다. 결과적으로 팀내 공유를 아무리 잘한다고 해도 실제로 사내 개발자가 그게 있는지를 모르는 경우가 있기 때문이다.
각 개발자마다의 성향이 있고, 그 스타일대로 움직이기 때문에 해당하는 코드스타일을 완전하게 따라할 수 없을 수도 있기 때문이다.

그렇다면, 이런방식을 탈피하기 위해서는 어떤게 좋을까?

간단한 Sample Type Enum을 만들어서 구성해보았다. 그 예제를 보자

import lombok.Getter;  
import lombok.RequiredArgsConstructor;  
import lombok.ToString;  

@ToString  
@RequiredArgsConstructor  
@Getter  
public enum BerryType {  

    CANDY("c01", "캔디 타입"),  
    PILL("c02", "알약 타입"),  
    SWEETY("c03", "달달한 타입");  

    private final String code;  
    private final String description;  


    public static BerryType by(String code) {  
        for (BerryType value : BerryType.values()) {  
            if (value.getCode().equalsIgnoreCase(code)) {  
                return value;  
            }  
        }  
        return null;  
    }  
}

Module을 써보자

모듈을 Bean으로만 등록해서 사용하면, SpringBoot의 자동 옵션 설정중 Jackson관련 옵션인 JacksonAutoConfiguration를 활용하여, 해당하는 ObjectMapper를 자동으로 등록하는 방식으로 사용한다. 대신 CustomSerializer가 등록되는 방식을 Module로 등록하면 좋다.

일단 Module을 등록하는 방법은 매우 쉽다.

1. 모듈을 구성하고 Bean으로 등록하기

@Bean  
public Module berryTypeModule() {  
    return new Module() {  
        @Override  
        public String getModuleName() {  
            return "berryTypeModule";  
        }  

        @Override        public Version version() {  
            return Version.unknownVersion();  
        }  

        @Override        public void setupModule(SetupContext setupContext) {  
            log.info("setUp modules!!!");  
            setupContext.addDeserializers(new Deserializers.Base() {  
                final Map<ClassKey, JsonDeserializer<?>> cache = new ConcurrentHashMap<>();  

                @Override  
                public JsonDeserializer<?> findEnumDeserializer(Class<?> type, DeserializationConfig config, BeanDescription beanDesc) throws JsonMappingException {  
                    if (BerryType.class.isAssignableFrom(type)) {  
                        JsonDeserializer<?> deserializer = new BerryTypeDeserializer();  
                        addDeserializer(type, deserializer);  
                        return deserializer;
                    }  
                    return null;  
                }  

                @Override  
                public boolean hasDeserializerFor(DeserializationConfig config, Class<?> valueType) {  
                    return cache.containsKey(new ClassKey(valueType));  
                }  


                public void addDeserializer(Class<?> forClass, JsonDeserializer<?> deserializer) {  
                    ClassKey key = new ClassKey(forClass);  
                    cache.put(key, deserializer);  
                }  
              });  
        }  
    };  
}

코드를 요약하면 대략 다음과 같다. 해당하는 Jackson의 모듈의 Serializer를 찾는 모듈을 통해 Enum Deserializer를 찾게된다.
그래서 해당하는 타입과 같은 타입이 있는 경우 → 해당 타입을 등록해서 사용해줄 수 있는데, Naver D2 예제의 경우는 해당하는 코드를 Cache와 같은 Map을 활용해서 해당하는 Deserializer가 중복 등록되는 경우를 방지하고, 처리할 수 있게 했다.

2. DeSerializer를 등록하기

순서가 살짝 바뀐거 같긴하지만 해당하는 JsonDeserializer<?> deserializer = new BerryTypeDeserializer(); 와 같이 해당하는 Enum Type을 정상적으로 변경할 수 있는 Deserializer를 구축해야한다.

import com.fasterxml.jackson.core.JacksonException;  
import com.fasterxml.jackson.core.JsonParser;  
import com.fasterxml.jackson.databind.DeserializationContext;  
import com.fasterxml.jackson.databind.JsonDeserializer;  
import com.fasterxml.jackson.databind.JsonNode;  
import com.kakaohealthcare.cslee.domain.BerryType;  
import lombok.extern.slf4j.Slf4j;  

import java.io.IOException;  

@Slf4j  
public class BerryTypeDeserializer extends JsonDeserializer<BerryType> {  

    @Override  
    public BerryType deserialize(JsonParser jp, DeserializationContext dctx) throws IOException, JacksonException {  
        JsonNode jsonNode = jp.getCodec().readTree(jp);  
        String text = jsonNode.asText();  
        log.info("text text::: {}", text);  
        return BerryType.by(text);  
    }  

}

따른건 없고, 그냥 Text로 변환되어진, 해당하는 값을 Enum Type으로 치환해주는 방식으로 구현하면 된다.
즉 Deserializer는 BerryType으로 전달된 String값 → c01이라는 값을 받아서 → Enum을 찾아서 by라는 스태틱메소드를 통해서 Enum을 찾는 방식을 취한다

해당하는 방식이 된다면, Controller나 Response에 @JsonDeserialize(using = BerryTypeDeserializer.class)라는 어노테이션 없어도 동일하게 Enum Type으로 변환되어진다.
어노테이션 있을때와 완전하게 동일한 방식으로 처리되기 때문에 실수할 수 있는 여지가 적어진다.

즉, 이런 방식을 취하면 Enum Type이 늘어날 수록 Custom Module만 여러개 등록해주면 되는 방식으로 형태로 Deserializer를 등록하면 된다.

추가적으로 해당하는 내용을 Module이 어떤 방식으로 등록되어지는지를 확인해보자.

Module이 등록되는 방식

일단 기본적으로 Bean으로만 등록될뿐 Serializer가 자동으로 등록되지 않는다.

//JacksonAutoConfiguration#L190

StandardJackson2ObjectMapperBuilderCustomizer(JacksonProperties jacksonProperties,  
       Collection<Module> modules) {  
    this.jacksonProperties = jacksonProperties;  
    this.modules = modules;  //모듈을 처음에 해당하는 곳에서 잡아주는데...
}

 


모듈을 까보면 막상 6개밖에 없다. (Bean으로 등록된 커스텀 Deserializer Module은 없음)
사용하는 순간에만 쓰기때문에 메모리를 사용하는 케이스가 없어진다.

그럼 어떤 순간에 우리는 해당하는 Module이 등록될까?
간단한 테스트 코드를 구현해봤다

@Test
void tes2() throws JsonProcessingException {  
        ObjectMapper mapper = new ObjectMapper();  
        mapper.registerModule(berryTypeModule);  
        String json  = """  
        {                  "berryType": "c01",                  
                            "test": "",
                            "abc": ""                
        }
        """;  
        DeserializeBody body = mapper.readValue(json, DeserializeBody.class);  
        log.info("{}", body);  
    }

해당하는 모듈을 따로 등록하지 않아도, 자동으로 mapper.registerModule처럼 동작하는 상황은 다음과 같다.

BasicDeserializerFactory에서 Type중에 변환가능한 타입이 있는 경우 Deserializer를 찾아보면서, 해당하는 모듈의 커스텀 Serializer가 있는지를 찾아본다.
모듈에서 오버라이드한 findEnumDeserializer를 통해서 해당하는 Enum타입의 Deseriailzer를 찾게되는데..

/// BasicDeserializerFactory.java#L1672

public JsonDeserializer<?> createEnumDeserializer(DeserializationContext ctxt,  
        JavaType type, BeanDescription beanDesc)  
    throws JsonMappingException  
{  
    final DeserializationConfig config = ctxt.getConfig();  //시리얼라이즈 옵션과
    final Class<?> enumClass = type.getRawClass();  //Enum타입의 로우클래스를 통해서 커스텀 이넘타입을 찾아보려한다.
    // 23-Nov-2010, tatu: Custom deserializer?  
    JsonDeserializer<?> deser = _findCustomEnumDeserializer(enumClass, config, beanDesc);

일단 찾아볼때 다음과 같이 작동하는데..

/// BasicDeserializerFactory.java#L2256
protected JsonDeserializer<?> _findCustomEnumDeserializer(Class<?> type,  
        DeserializationConfig config, BeanDescription beanDesc)  
    throws JsonMappingException  
{  
    for (Deserializers d  : _factoryConfig.deserializers()) {  
        JsonDeserializer<?> deser = d.findEnumDeserializer(type, config, beanDesc);  //여기서 해당하는 Enum 타입을 시리얼 라이저를 찾게된다. 
        //당연하게되 Type이 해당 Enum 타입이므로..
        if (deser != null) {  
                return deser;   // 여기서 Deserializer가 등록됨
        }    }    return null;  
}

해당하는 Deserializer가 자동으로 등록된다.

해당 방식의 장점. LAZY Loading

모듈 자체는 처음 initializing할때는 등록되진 않지만 이후 Controller단에서 ObjectMapper를 확인해보면 해당하는 Module이 등록되어있다.


또한 Deserializer도 이렇게 해당하는 타입이 없는 Path를 먼저 찌르게된다면?


error를 먼저 찌르고? → Deserializer를 찾아보면 다음처럼 나오게된다


Moudle빈으로 등록된 Deserializer의 값이 없는데
이걸 /deserialize를 다시 접근하면 해당하는 Deserializer가 있는것이 보인다.


그리고 해당하는 캐시에 올라간 Deserializer는 이제 다시 /error path를 접근해도 사라지지 않는다.


즉, 해당하는 Path를 직접적으로 쓰지 않는 경우 해당 deserializer를 아예 등록하지 않는 방식으로 처리되어지기때문에 메모리 효율성이 좋고, 코드적으로 구성이 좋아서 추천할 방법으로 보여진다.

TMI

찾다보니까 재밌는것도 찾았는데, 결국 우리가 여러타입의 Deserializer가 기본 값이 여기에 적용되어있는데


골머리를 썩이는 LocalDateTime처럼 시간 날짜 타입의 키값들이 많이 들어있다는 점도 신기했다