본문 바로가기
Java

[Java] 공통 적용 JsonDeserializer 만들어 보기

by 긍고 2024. 9. 23.
반응형

개요


외부 api와 통신하여 작동하는 기능 개발 시, 외부에서 리턴되는 상태 코드 등을 애플리케이션 내부에서 사용하려고 하면 해당 코드를 활용하는데 어려움을 겪고는 한다. 아래는 카카오톡 비즈메시지 센터 API 중, 알림톡 템플릿 정보 조회 결과에 대한 리턴값 중 일부이다.

 

템플릿 검수 결과 리턴값
그림 1. 카카오 템플릿 검수 결과 리턴 값 예시

 

위와 같이 검수 결과가 REG, REQ 등과 같은 한 눈에 의미를 알 수 없는 약어로 축약되어 리턴되는데, 이를 애플리케이션 내부에서 원활하게 사용하기 위해서는 Enum을 선언하고 외부 api 결과값을 해당 Enum에 매핑하여 사용할 수 있다.

 

public interface KakaoCommonType {
    <T> T getCode();
    String getTitle();
}

@RequiredArgsConstructor
public enum KakaoInspectionStatus implements KakaoCommonType {

    REGISTER("REG", "등록"),
    REQUEST("REQ", "심사 요청"),
    APPROVE("APR", "승인"),
    REJECT("REJ", "반려");

    private final String code;
    private final String title;
}

 

외부 api로부터 리턴되는 축약된 값을 저장하기 위한 code 필드를 선언하였고, 그에 대한 부가 설명을 적을 수 있도록 title 필드를 선언하였다. inspectionStatus 외에 다른 값들도 축약형으로 전달될 수 있기 때문에 그러한 값들에 공통 적용될 수 있는 KakaoCommonType을 선언하였다.

 


 

이와 같이 Enum을 선언 후 api 결과값을 매핑하고자 한다면 축약형 코드 값 → 어플리케이션 내부에서 정의한 Enum으로 변환해 주는 작업이 필요한데, 이를 @JsonDeserialize 어노테이션을 이용하여 해결할 수 있다.

 

public class KakaoResultDto {

	@JsonDeserialize(using = ???.class)
	private KakaoInspectionStatus inspectionStatus;	
	...
}

 

@JsonDeserialize 어노테이션은 여러 위치에 사용가능한데, 위와 같이 역직렬화 대상 필드 위에 사용할 수 있다. 이때, 역직렬화에 사용할 Custom deserializer를 구현하여 매개인자로 넘겨주어야 하는데 이번 포스트에서는 Custom deserializer 구현 과정을 정리한다.

 

특정 Enum에만 적용되는 Deserializer


커스텀 deserializer를 구현하는 방법은 생각보다 간단한데 JsonDeserializer를 상속받는 클래스를 만들고 필수 메서드인 deserialize 메서드를 구현한 뒤, 앞서 언급한 @JsonDeserializer에 매개인자로 넘겨주면 된다. 맨 처음에는 KakaoInspectionStatus에만 적용할 수 있는 deserializer를 구현해 보았고 그 결과는 아래와 같다.

 

public class KakaoInspectionStatusDeserializer extends JsonDeserializer<KakaoInspectionStatus> {

    @Override
    public KakaoInspectionStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        for (KakaoInspectionStatus kakaoInspectionStatus : KakaoInspectionStatus.values()) {
            if (kakaoInspectionStatus.getCode().equals(p.getValueAsString())) {
                return kakaoInspectionStatus;
            }
        }
        return null;
    }
}

 

KakaoInspectionStatus는 code값을 갖기 때문에 JsonParser를 통해 읽어온 값과 같은 code값을 갖는 KakaoInspectionStatus enum 값을 값에 할당하도록 구현하였다. 위와 같이 구현하여 결과값을 담는 Dto에 적용한 결과, 의도대로 외부 api로부터 리턴 받은 code 값을 애플리케이션 내부의 Enum값으로 잘 매핑한 것을 확인할 수 있었다.

 

public class KakaoResultDto {

	@JsonDeserialize(using = KakaoInspectionStatusDeserializer.class)
	private KakaoInspectionStatus inspectionStatus;	
	...
}

// {\"inspectionStatus\": \"REG\"}
// -> KakaoResultDto(inspectionStatus: REGISTER)

 

 

문제점


위와 같이 구현하고 난 뒤 개발을 진행하다 보니 아래의 두 가지의 문제점을 인지할 수 있었다.

  1. JsonDeserializer를 직접 상속받아 구현하는 것은 권장되지 않는 방법이다.
  2. KakaoInspectionStatus 외에 KakaoCommonType을 상속받는 다른 Enum에 대해서는 구현한 Deserializer를 사용할 수 없다.

StdDeserializer

첫 번째 문제점은 Deserializer 구현에 사용한 JsonDeserializer를 직접 상속받아 구현하는 것은 자바 권장사항이 아니며, StdDeserializer를 권장하고 있다. 두 클래스는 역할은 같지만 구현 방식이 조금은 상이한 부분이 있어 이 부분에 대한 수정이 필요했다.

그림 2. JsonDeserializer 내부 주석

Custom Deserializer는 해당 클래스(JsonDeserializer)를 직접 상속받아 구현하는 것이 아니라, StdDeserizlier를 대신 상속받아 구현해야 한다.

KakaoCommonType에 대한 적용

위의 코드에서 보면 알 수 있듯이 구현한 Deserializer는 KakaoInspectionStatus의 값들 중에서만 일치하는 code값을 찾으며, 따라서 다른 Enum값에는 적용될 수 없다. 다른 유사한 경우에도 적용하기 위해서는 KakaoCommonType범용적으로 적용될 수 있는 Deserializer의 구현이 필요했다.

 

시행착오


처음에는 단순하게 JsonDeserializer <T>의 T부분에 <T extends Enum <?> & KakaoCommonType을 넣어, Enum값이고 kakaoCommonType을 확장하는 값이 오도록 해서 해당 enum 클래스의 values를 조회하여 code 값 비교 후 일치하는 enum 값을 반환하려고 했다.

public class TestDeserializer<T extends Enum<?> & KakaoCommonType> extends JsonDeserializer<T> {
    
    @Override
    public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        for (T t : T.values()) {
            if (t.getCode().equals(p.getValueAsString())) {
                return t;
            }
        }
        return null;
    }
}

이 방법은 결론적으로 실패했는데, 제네릭 타입인 T에 대해서 values() 메서드를 사용할 수 없었기 때문이다. 평소에 Enum을 활용하면서 values() 메서드를 많이 사용했던 터라 이유가 궁금해서 찾아보았는데 그 이유는 아래와 같았다.


Implicitly added method

우리가 자주 사용하는 Enum의 values()와 valueOf() 메서드는 Enum 클래스 자체에 있는 것이 아니라, 컴파일 타임에 컴파일러에 의해 암묵적으로 static 하게 추가된다. 아래는 Java Tutorials의 Enum에 대한 설명 중 일부이다.

The compiler automatically adds some special methods when it creates an enum. For example, they have a static values method that returns an array containing all of the values of the enum in the order they are declared. This method is commonly used in combination with the for-each construct to iterate over the values of an enum type.

 

컴파일러에 의해 생성된 values 메서드의 경우T [] values()와 같이 선언되는데 제네릭으로 선언한 T가 런타임에 어떤 타입인지 알 수 없기 때문에 Enum을 extend 한 제네릭일지라도 해당 메서드를 호출하지 못한다.


ContextualDeserializer

위와 같은 이유로 단순히 Enum과 KakaoCommonType을 extends 하는 제네릭을 사용할 수 없었고, 런타임에 해당 Deserializer가 적용될 Enum의 타입을 알아야 했다. ContextualDeserializer는 콘텍스트와 리플렉션을 활용하여 런타임에 적용될 클래스 정보를 알 수 있게 해 준다.

따라서 해당 인터페이스를 확장하여 context를 통해 enum의 클래스를 알아낸 뒤, 이를 활용하여 해당 Enum의 code들의 배열을 구해 이전에 구현했던 방식대로 일치하는 code를 가진 enum 값을 반환하도록 구현하였다.

그림 3. ContextualDeserializer 내부 주석

 

구현 결과


public class KakaoCommonTypeDeserializer extends StdDeserializer<Enum<? extends KakaoCommonType>> implements ContextualDeserializer {

    public KakaoCommonTypeDeserializer() {
        this(null);
    }

    public KakaoCommonTypeDeserializer(Class<?> vc) {
        super(vc);
    }

    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
        return new KakaoCommonTypeDeserializer(property.getType().getRawClass());
    }

    @Override
    public Enum<? extends KakaoCommonType> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        Class<? extends KakaoCommonType> enumType = (Class<? extends KakaoCommonType>) this._valueClass;

        KakaoCommonType[] kakaoCommonTypes = enumType.getEnumConstants();
        for (KakaoCommonType kakaoCommonType : kakaoCommonTypes) {
            if (kakaoCommonType.getCode().equals(p.getValueAsString())) {
                return (Enum<? extends KakaoCommonType>) kakaoCommonType;
            }
        }
        return null;
    }
}

 

StdDeserializer의 경우 기본 생성자를 통해 초기화한 뒤에 메서드들이 실행되고, 최초에는 적용될 enum 정보를 알 수 없기 때문에 기본 생성자에 null을 할당하였다.

 

ContextualDeserializer를 구현할 경우 createContextual 메서드를 필수로 구현해야 하는데, 이 메서드는 deserialize() 메서드보다 먼저 실행된다. 이후 해당 메서드의 BeanProperty를 통해 런타임에 적용될 Enum의 클래스 정보를 추출하고, 해당 정보를 통해 StdDeserializer의 생성자를 이용하여 해당 클래스 정보를 필드(_valueClass)로 갖는 새로운 Deserializer 인스턴스를 리턴한다.

 

적용될 Enum의 클래스 정보를 갖고 생성된 새 KakaoCommonTypeDeserializer 인스턴스에서 deserialize() 메서드가 실행되게 되고, 이때 앞서 추출한 클래스 정보를 통해 enum의 리스트를 구할 수 있다.

 

Class <T>의 getEnumConstants()를 호출하면 만약 해당 클래스가 enum type일 경우, enum 타입의 배열을 리턴해 주는데 해당 값들에 대해 반복문을 실행하여 이전과 같은 방식으로 code가 일치하는 enum을 찾아 리턴하게 하여 KakaoCommonType을 확장하는 모든 Enum에 적용될 수 있는 Deserializer를 만들 수 있었다.

댓글