Link
Today
Total
12-23 08:10
Archives
관리 메뉴

초보개발자 긍.응.성

BeanSerializerModifier를 이용한 직렬화 커스터마이징하기 본문

Spring/Jackson

BeanSerializerModifier를 이용한 직렬화 커스터마이징하기

긍.응.성 2024. 6. 16. 11:14
반응형

Spring 프레임워에서 Jackson 라이브러리를 사용하다 보면 Enum 타입에 대하여 변환 시 특별한 처리를 해주어야 할 때가 있는데, 이유는 기본적으로 ObjectMapper가 Enum 타입을 직렬화 시 타입 값의 이름으로 직렬화되기 때문입니다(내부적으로는 toString()을 통해서이나 모든 Enum의 오버라이드 하지 않은 toString 은 name을 반환합니다). 일반적으로 Enum 타입의 “상수” 값은 개발자가 이해하기 쉬운 문자열로 네이밍되고, 실제로 사용할 때는(클라이언트 응답 구성, 외부 API 서버 응답 파싱 등) 이름과는 다른 코드값으로 사용해야 합니다.

이러한 작업을 위해 특정 필드를 원하는 값으로 직렬화하려면 @JsonSerializer 애노테이션과 함께 커스텀 한 JsonSerializer를 구현하여야 하며, 이러한 일은 실제로 매우 자주 일어나는 작업입니다. 하지만 다루는 타입이 많아지며 이에 따라 사용하는 Enum 클래스가 추가된다면, 매번 이에 따른 JsonSerializer, JsonDeserializer를 구현해주어야 하는 번거로움이 존재합니다.

이번 글에서는 이러한 번거로움을 조금 덜 수 있는 방법에 대하여 정리해 보았습니다.

1. Enum 타입에 대한 직렬화

예를 들어 다음과 이자에 대한 Enum 객체가 존재한다고 합시다.

public enum InterestType {
    SIMPLE(Period.ofMonths(12)), <em>// 단리</em>
    COMPOUND_MONTH(Period.ofMonths(1)), <em>// 월 복리</em>
    COMPOUND_QUARTER(Period.ofMonths(3)), <em>// 3개월 복리</em>
    COMPOUND_HALF_YEAR(Period.ofMonths(6)), <em>// 6개월 복리</em>
    COMPOUND_YEAR(Period.ofMonths(12)); <em>// 연 복리</em>

    private final Period period;

    ...
}

현재는 비즈니스 로직 처리를 위해 이자 종류에 따른 기간(period) 값만 갖고 있습니다. 하지만 이 상수 값을 클라이언트에게 응답으로 내리거나 외부 API의 응답으로부터 파싱할때는 다른 값을 사용하고 싶다면 어떻게 할까요?

public enum InterestType {
    SIMPLE(Period.ofMonths(12), "S", 10), // 단리
    COMPOUND_MONTH(Period.ofMonths(1), "CM", 20), // 월 복리
    COMPOUND_QUARTER(Period.ofMonths(3), "CQ", 21), // 3개월 복리
    COMPOUND_HALF_YEAR(Period.ofMonths(6), "CG", 22), // 6개월 복리
    COMPOUND_YEAR(Period.ofMonths(12), "CY", 23); // 연 복리

    private final Period period;
    private final String responseCode;
    private final int serverCode;

    ...
}
 

클라이언트 응답으로 직렬화되어야 할 때는 responseCode 값을 기준으로, 외부 API 응답을 역직렬화할 때는 serverCode 값을 기준으로 변환되어야 하는 상황일때,

이를 만족시키기 위해선 responseCode로는 직렬화하는 JsonSerializer를, serverCode로 역직렬화하는 JsonDeserializer를 각각 구현하여야 합니다. 또한, 클라이언트 응답을 위한 객체엔 @JsonSerializer 애노테이션을, 외부 API 응답을 매핑하는 객체엔 @JsonDeserializer 애노테이션을 붙여주어야 합니다.

2. 새로운 Enum 타입의 등장

새로운 타입이 등장하였습니다. 세금에 대한 Enum 객체가 추가되었을때를 가정해 봅시다.

public enum TaxType {
    DEFAULT(new BigDecimal("0.154"), true, "D", 1), // 일반 과세
    PREFERENTIAL(new BigDecimal("0.095"), false, "P", 2), // 우대 과세 - 2015년 폐지
    FREE(BigDecimal.ZERO, true, "F", 0); // 비과세

    private final BigDecimal rate;
    private final boolean available;
    private final String responseCode;
    private final int serverCode;

    ...
}

이자 타입과 같이 직렬화와 역직렬화에 사용되어야 할 값이 함께 정의되었는데요, 이전과 같이  Serializer, Deserializer를 구현하고 각가의 필드에 애노테이션을 달아주는 작업이 필요하게 됩니다.

실제 업무엔 훨씬 더 많은 필드를 가진 Enum이 있을뿐더러, 정의할 Enum 타입 자체가 많습니다. 그렇다면 이러한 상황에 대하여 위의 작업들을 반복할 수 밖에 없는데요, 더 간단하게 적용할 수 있는 방법이 없을까요?

3. 직렬화 중복 작업 제거

Jackson Module

Jackson Module은 Jackson 확장을 위한 간단한 인터페이스입니다.

Jackson은 SimpleModule을 기본적인 모듈의 구현체로 제공하는데요, 개발자는 이 모듈에 변환과정에 필요한 BeanSerializerModifier, BeanDeserializerModifier를 추가하여 새로운 데이터 타입에 대한 변환을 커스터마이징 할 수 있습니다.

SimpleModule myEnumModule = new SimpleModule("myEnumModule", Version.unknownVersion());
myEnumModule.setSerializerModifier(new MyEnumSerializerModifier());
myEnumModule.setDeserializerModifier(new MyEnumDeserializerModifier());

objectMapper.registerModule(myEnumModule);

BeanSerializerModifier

public abstract class BeanSerializerModifier {
    
    public List<BeanPropertyWriter> changeProperties(SerializationConfig config,
            BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
        return beanProperties;
    }

    public List<BeanPropertyWriter> orderProperties(SerializationConfig config,
            BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
        return beanProperties;
    }

    public BeanSerializerBuilder updateBuilder(SerializationConfig config,
            BeanDescription beanDesc, BeanSerializerBuilder builder) {
        return builder;
    }
    
    public JsonSerializer<?> modifySerializer(SerializationConfig config,
            BeanDescription beanDesc, JsonSerializer<?> serializer) {
        return serializer;
    }

BeanSerializerModifier는 Jackson의 직렬화 과정을 수정하고 변경할 수 있습니다. 더 자세히는 BeanSerializerFactory가 BeanSerializer 인스턴스를 구성하는 과정의 라이프사이클마다 동작을 커스터마이징 할 수 있습니다.

먼저 직렬화하려는 객체의 Enum 필드에 대하여 어떻게 중복 작업을 줄이며 직렬화 수 있을지에 대해 고려해 봅시다.

class Deposit {
    @EnumSerialize("getResponseCode")
    InterestType interestType;
    ...
}

public @interface EnumSerialize {
    /* enum 객체를 serialize 할때 호출할 메서드 */
    String value();
}

Deposit 객체 중 이자 타입 Enum 은 해당 Enum의 responseCode 필드의 값으로 직렬화하려 합니다.

만약 Deposit 객체의 interestType 필드에 애노테이션을 통해 직렬화될 때 사용되어야 할 메서드(InterestType.getResponseCode) 를 명시할 수 있고, 직렬화 할 필드에 적용된 애노테이션 정보를 참조할 수 있다면 명시된 메서드를 통해 직렬화할 수 있습니다.

이렇게 한다면 각 Enum과 직렬화되어야 할 값마다 Serializer를 구현할 필요가 없을 것입니다.

BeanSerializerModifier의 updateBuilder 메서드는 직렬화 대상 오브젝트의 모든 속성(필드) 정보가 파악된 후 수행되며 직렬화를 위한 builder를 수정할 수 있는 메서드입니다. builder는 직렬화할 객체에 대한 정보를 갖고 있는데요, 이 메서드를 구현하여 Enum 필드가 존재하는지, 존재한다면 애노테이션에 명시된 값(메서드)을 통하여 직렬화하도록 동작을 커스터마이징 할 수 있습니다.

이에 대한 구현입니다.

public class EnumSerializerModifier extends BeanSerializerModifier {

    @Override
    public BeanSerializerBuilder updateBuilder(SerializationConfig config, BeanDescription beanDesc, BeanSerializerBuilder builder) {

        // 각 필드에 대하여
        builder.getProperties().stream()
                .filter(beanPropertyWriter -> beanPropertyWriter.getType().isEnumType()) // Enum 필드만 필터링한다
                .forEach(beanPropertyWriter -> {
                    Optional.ofNullable(beanPropertyWriter.getAnnotation(EnumSerialize.class))
                            // EnumSerialize 애노테이션이 적용되었다면 지정된 메서드를 통하도록 직접 JsonSerializer 를 구현한다
                            .ifPresent(annotation -> beanPropertyWriter.assignSerializer(new JsonSerializer<>() {
                                @Override
                                public void serialize(Object obj, JsonGenerator generator, SerializerProvider serializerProvider) {
                                    try {
                                        String result = invokeMethod(obj, beanPropertyWriter.getType().getRawClass(), annotation.value()); // 리플렉션 API를 이용하여 애노테이션에 지정된 메서드를 호출한 결과를 세팅한다
                                        if (Objects.isNull(result)) {
                                            generator.writeNull(); // "null" 문자열로 직렬화
                                        } else {
                                            generator.writeString(result); // 메서드 호출 결과 문자열로 직렬화
                                        }
                                    } catch (IOException e) {
                                        // write log message...
                                    }
                                }
                            }));
                });

        return builder;
    }

    private String invokeMethod(Object obj, Class<?> enumClass, String method) {
        try {
                return String.valueOf(enumClass.getMethod(method).invoke(obj));
            }
        } catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
            logger.error("{} enum serialization failed unexpectedly", enumClass.getSimpleName(), e);
        }
        return null;
    }
}

EnumSerializerModifier를 모듈에 등록, 모듈을 ObjectMapper에 등록하면 EnumSerialize 애노테이션이 달린 필드를 직렬화할 때 EnumSerializerModifier의 구현대로 직렬화하게 됩니다.

@Test
void test_enumSerializerModifier() throws JsonProcessingException {
    SimpleModule module = new SimpleModule("myEnumModule", Version.unknownVersion());
    module.setSerializerModifier(new EnumSerializerModifier());

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(module);

    Deposit1 deposit = new Deposit1(InterestType.SIMPLE);

    System.out.println(objectMapper.writeValueAsString(deposit)); // 출력 - {"interestType":"S"}
}

4. 직렬화 성능 개선

위의 invokerMethod 메서드는 직렬화할 값(obj), 변환할 Enum 클래스 정보(enumClass), 직렬화에 사용할 메서드 명(method)을 파라미터로 받아 리플렉션 API를 사용하여 메서드를 호출합니다..

리플렉션은 오버헤드가 존재하는 기능이며, 위의 구현으로는 EnumSerialize 애노테이션을 가진 필드를 직렬화할 때마다 리플렉션 API가 호출되게 됩니다. 이는 캐시를 이용한다면 성능을 개선할 수 있습니다.

// 캐시
private final Map<SerializerParams, String> cache = new ConcurrentHashMap<>();

private String invokeMethod(Object obj, Class<?> enumClass, String method) {
    var serializerParams = new SerializerParams(obj, enumClass, method);
    try {
        if (cache.containsKey(serializerParams)) {
            return cache.get(serializerParams);
        } else {
            String result = String.valueOf(enumClass.getMethod(method).invoke(obj));
            cache.put(serializerParams, result);
            return result;
        }
    } catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
        logger.error("{} enum serialization failed unexpectedly", enumClass.getSimpleName(), e);
    }
    return null;
}

static class SerializerParams {
    Object object;
    Class<?> enumClass;
    String methodName;

    public SerializerParams(Object object, Class<?> enumClass, String methodName) {
        this.object = object;
        this.enumClass = enumClass;
        this.methodName = methodName;
    }

    // equals(), hashCode()
}

리플렉션으로 인한 오버로드가 캐시를 적용하였지만 ConcurrentHashMap도 동시성 보장으로 인해 성능이 뛰어나지 않을 것 같아서, 이에 대해서 얼마나 실제로 개선이 되는지 확인해 보았습니다.

테스트 환경으로는 이자와 세금 Enum 필드를 갖는 객체를 두고 모든 종류의 타입을 가지며, EnumSerialize 애노테이션은 제공하는 모든 메서드에 대해서 테스트할 수 있도록 비슷한 종류의 객체를 15종류 만들었습니다.

이자 Enum으로 캐시 할 수 있는 직렬화 종류가 15개, 세금 Enum으로 캐시 할 수 있는 직렬화 종류 9개, 총 직렬화 결과 24개를 캐시가능합니다. 15종류의 객체별로 100만 번 직렬화하여 시간을 측정하였습니다. (objectMapper.writeValueAsString() 호출 1500만 회)

테스트 결과 80%로 수행속도가 빨라진 것을 확인할 수 있었습니다.

5. 정리

이번 글에서는 Enum 직렬화를 위해 일일이 JsonSerializer를 구현하지 않고 BeanSerializerModifier를 지정한 메서드로 직렬화하는 방법을 살펴보았습니다.

다음 글에서는 반대 상황인 Enum으로 역직렬화를 개선하는 방법에 대해 알아보겠습니다.

반응형
Comments