VO(또는 래핑 객체)의 직렬화 문제

임현규·2022년 12월 19일
0

Vo 객체를 표현 계층에 사용하다.

이번 프로젝트를 진행하면서 VO의 직렬화에 대해서 고민을 했다. 기본적으로 Controller를 통해서 데이터를 주고 받을 때 Dto 객체를 활용한다. Dto 객체 자체는 문제가 없었지만 이번에 DDD를 처음 적용하면서 표현 영역을 구성하는 데 문제가 생겼다. 원시 객체를 VO로 감싸고 VO와 Entity를 활용해 도메인 영역을 구성했는데 표현 계층에서 Dto의 활용하는데 고민이 많았다.

표현 영역에 Entity를 노출하는 것은 별로 좋지 않다. Entity는 도메인 문제를 해결하기 위해 객체 상태에 대한 메서드를 제공한다. 그러나 표현 영역에서는 그저 Json 데이터의 직렬화 또는 역직렬화를 수행하고, entity 객체의 메서드 보다는 json을 래핑한 객체의 상태를 생성하거나, 단순히 가져오는 역할을 표현 계층에서 수행하기 때문이다.

하지만 Vo는 객체를 굳이 Dto로 래핑하기엔 너무나 반복 작업이 많고 Vo는 비즈니스적인 의미를 지니긴 하지만 표현 계층에서 사용해도 문제가 없다고 생각했다. 그 이유는 Vo는 도메인 문제를 해결하기 위해 스스로 상태를 변경하지 않고(상태 자체가 Vo의 정체성) 객체를 생성하기 위한 Validation과 연산 기능 또는 객체를 표현하기 위한 기능을 제공하는 데 활용하기 때문이다.

Spring에서 표현 계층에 Vo객체 사용시 문제점

Vo는 여러 객체를 모아두기도 하지만 래핑 객체로써 보통 하나의 필드와 단일 인자로 구성된 생성자를 가지도록 활용할 수 있다. 근데 이것을 단순히 Jackson 라이브러리의 ObjectMapper를 활용해 직렬화를 수행하면 몇 가지 문제점이 발생한다.

#1. getter없이 직렬화를 하면 예외를 발생한다.

    @EqualsAndHashCode
    public class Money {

        private static final String NOT_ALLOW_NEGATIVE_VALUE = "금액은 음수를 허용하지 않습니다.";
        private static final int ZERO = 0;
        
        private final int value;
		
        public Money(int value) {
            this.value = value;
        }
        
        private static void validateMoney(int value) {
            if (value < ZERO) {
                throw new IllegalArgumentException(NOT_ALLOW_NEGATIVE_VALUE);
            }
        }

        @Override
        public String toString() {
            return String.valueOf(value);
        }
    }

위의 코드는 Vo이면서 래핑 객체이다. getter는 존재하지 않는다. 그 이유는 Money 자체가 value를 리턴하면 금액은 음수를 허용하지 않는다라는 비즈니스 로직이 깨질 위험이 있기 때문이다.

그러나 위와 같은 객체를 만들고 직렬화를 수행하면 다음과 같은 오류가 발생한다.

예외에는 BeanSerializer를 생성하기 위해 기준이 되는 프로퍼티를 발견하지 못하기 때문이다.

기본적으로 Jackson은 2.13 버전 기준으로 프로퍼티에 접근하기 위해 다음과 같은 정책을 가진다.

getter 메서드와 field에 직접 접근하기 위해서는 오로지 Public만 접근이 가능하다. 그 이외에 생성자(기본 생성자, 단일 인자 생성자)나 setter의 경우는 어떤 접근 제어자든 언제든지 접근이 가능하다.

직렬화의 경우 어떻게든 프로퍼티 정보를 알아야하기 때문에 getter나 field에 직접 접근을 시도하고 해당 정책과 일치하지 않다면 프로퍼티를 얻어올 수 없기 때문에 예외를 발생한다.

#2. 클래스를 재귀적으로 접근해서 값 형태가 아닌 Json 객체 형태로 직렬화를 수행한다.

원시 타입을 래핑한 객체의 경우, 직렬화시 Primitive한 형태로 출력을 원하지만 ObjectMapper는 재귀적으로 수행하면서 depth가 있는 json 객체를 리턴한다.

문제의 리턴값

{
  "value":100
}

위와 같은 상태로 직렬화되면 Json 객체가 복잡해질 우려가 있다.
다음 예제로 살펴보자

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
class OrderDto {
	private Money money;
    private Amount amount;
}

위와 같은 Dto가 있다고 가정 했을 때 나오는 Json은 다음과 같다.

{
  "money": {
    "value": 100 
  },
  "amount": {
  	"value": 3
  }
}

해당 json이 지금은 depth가 얇기 때문에 간단해 보일지 몰라도 depth가 조금 더 깊어지면 복잡하게 느껴질 수 있다. 이는 Api를 가져다 쓰는 입장에서는 굉장히 불편할 수 있는 형태이다.

{
  "money": 100,
  "amount": 3
}

래핑 객체를 단순히 원시 타입과 같이 직렬화 한다면 조금 더 직관적이다.

문제 해결 방법

#1 @JsonSerialize과 커스텀 Serializer를 활용하기

StdSerializer를 상속받아 커스텀 Serializer 객체를 만들고 jsonSerialize 어노테이션을 활용한다.

public class MoneySerializer extends StdSerializer<Money> {

    protected MoneySerializer() {
        this(null);
    }
    
    protected MoneySerializer(Class<Money> t) {
        super(t);
    }

    @Override
    public void serialize(Money value, JsonGenerator gen, SerializerProvider provider)
        throws IOException {

        gen.writeString(value.toString());
    }
}

그 이후 Money 클래스에 @JsonSerialize(using = MoneySerializer.class)를 입력하면

BeanSerializeFactory에서 @JsonSerialize 어노테이션이 등록된 클래스 또는 필드를 찾아서 해당 객체에 자동으로 적용한다.

장점:

  • 적용하는 방법이 쉽다. 그리고 해당 클래스가 아닌 필드에 적용하면 그 해당 필드의 클래스만 직렬화를 적용하도록 커스터마이징 할 수 있다.

단점

  • 매번 적용하려는 경우 추가하는 클래스마다 serializer를 만들고 클래스에 어노테이션을 달아줘야한다. 래핑 객체가 많은 경우 실수로 달지 않아서 원치 않는 리턴 값이나 오류가 발생할 수 있다.
  • 적용하려는 클래스가 jackson 라이브러리에 의존하게 된다. 개발시 계층을 분리하는 이유는 각 계층 간의 의존성을 분리하고 응집도를 높이기 위해서이다. 이 경우도 비슷하게 생각할 수 있다. Vo나 원시 객체의 경우 jpa를 사용하는 경우 개발의 편의성을 위해 어쩔수 없이 jpa에 의존하게 되지만 거기에 jackson이 추가된다면 오히려 클래스가 복잡해질 수 있다.

#2 jackson 모듈에 클래스별로 serializer를 추가한다

Module에 필요한 serializer를 추가해서 ObjectMapper에 적용 후 빈으로 등록한다.

public class ValueObjectModule extends SimpleModule {

    @Override
    public String getModuleName() {
        return super.getModuleName();
    }

    @Override
    public void setupModule(SetupContext context) {
        SimpleSerializers simpleSerializers = new SimpleSerializers();
        simpleSerializers.addSerializer(Money.class, new MoneySerializer());
        context.addSerializers(simpleSerializers);
    }
}

serializer를 SimpleModule에 등록한다.

@Configuration
public class JacksonConfiguration {

    @Bean
    @Primary
    public ObjectMapper getObjectMapper() {
        return new ObjectMapper()
            .registerModule(new ValueObjectModule())
            .setPropertyNamingStrategy(new SnakeCaseStrategy());
    }
}

spring에 objectMapper를 빈으로 등록하면 직렬화시 toString()을 활용해 직렬화를 수행한다.

Module에 저장된 serializer가 호출되는 방식은 다음과 같다.

BeanSerializeFactory에서
JsonSerializer<?> _createSerializer2(SerializerProvider prov, JavaType type, BeanDescription beanDesc, boolean staticTyping) 메서드를 호출하고 해당 타입이 컨테이너 타입 인지, reference type인지 확인한다. reference 타입인 경우

등록한 customSerializer에서 타입에 맞는 serializer를 탐색한다. serializers를 더 깊이 들어가보면

우리가 모듈에서 저장해두었던 SimpleSerializers에서 interface인 경우 또는 타입이 맞지 않는 경우 상위 클래스로 올라가면서 등록한 Serializer와 매칭한다. 그래서 찾은 serialize에서 serialze 메서드를 호출해 직렬화를 수행한다.

장점:

  • jackson을 클래스에서 분리 할 수 있다.

단점:

  • 매번 class마다 serializer를 등록하고 Module에 추가해주어야 한다.

#3 jackson 모듈에 interface를 활용해 serializer를 추가한다.

interface의 다형성과 jackson 라이브러리가 serializer 매칭하는 특성을 활용한 방법이다.

public interface Wrapper {

    String toString();
}

인터페이스를 선언한다. 그리고 원시타입 래핑 객체에 해당 interface를 적용해준다.
그리고 2번 과정과 동일하게 진행한다.

장점:

  • 다형성을 활용하기 때문에 매번 클래스 추가시 등록할 필요가 없다.
  • 2번의 장점과 같이 jackson을 vo에서 분리할 수 있다.
  • 정의한 vo 객체가 type wrapping 객체임을 알 수 있다.

단점:

  • 래핑 클래스에 매 번 해당 interface를 추가해야 한다.

참고

https://www.baeldung.com/jackson-custom-serialization
https://d2.naver.com/helloworld/0473330

profile
엘 프사이 콩그루

0개의 댓글