Spring에 원시 객체 래핑 적용기

임현규·2023년 3월 4일
0

OOP에서 원시 객체 래핑

객체지향적으로 설계하다보면 원시 객체 래핑이 필요할 때가 있다. 원시 객체 래핑에는 좋은 글이 많지만 요약해서 설명하자면 확장성이 좋고 원시 객체의 상태를 생성시 스스로 검증하면서 비즈니스에 적합한 타입을 만들 수 있다.

예를 들어 보자

우리가 돈을 표현하고 싶다. 가장 쉬운 방법은 언어에서 제공하는 원시 타입으로 표현하는 것이다.

int money = 10;

만약 돈에 관련된 로직을 만들고 싶은데 money를 1,000,000 원 과 같은 형태로 출력하고 싶다.

        DecimalFormat decimalFormat = new DecimalFormat("###,###");
        int money = 1_000_000_000;
        String format = decimalFormat.format(money) + "원";
        System.out.println(format);

이 코드의 문제는 그냥 서술해서 풀었다는 것이다. 함수로 묶어서 표현하면 좀 더 파악하기 쉽고 깔끔할 것 같다.

public class MoneyHelper {
	private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("###,###");
	
    public static String convertKoreanMoneyFormat(int money) {
    	return MONEY_FORMAT.format(money) + "원";
    }
}

MoneyHelper 클래스를 만들고 정적 메서드를 통해 int 타입의 money를 String 타입의 한국 화폐 형태로 리턴한다. 그러나 이것은 int money라는 상태의 데이터를 외부 객체가 메서드를 통해서 관리하고 있으므로 객체지향적이라 보기는 어렵다.(나쁜 코드라는 것이 아니다. 실제로 이런 패턴도 많은 개발자들이 사용한다.)

@EqualsAndHashCode
class Money {
    private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("###,###");
    
    private final int money;

    public Money(int money) {
        validateMoney(money);
        this.money = money;
    }
    
    public String getKoreanMoneyFormat() {
        return MONEY_FORMAT.format(money) + "원";
    }

    private void validateMoney(int money) {
        if (money < 0) {
            throw new IllegalArgumentException("Money를 음수로 초기화 할 수 없습니다");
        }
    }
}

이렇게 원시 정수 타입 int 를 Money로 래핑하면 int 상태를 외부가 아닌 Money과 관리하게 되기 때문에 객체지향적이라 할 수 있다. 그리고 코드 보면 알겠지만 필요하다면 Value Object 형태로 만들 수 있고, 훨씬 안정적으로 코드를 짜고 테스트 코드도 관리가 쉽다.

spring 에서 원시 객체 래핑 사용시 문제점

spring에서는 최종적으로 json 직렬화/역직렬화시 jackson 라이브러리를 사용한다. 그러나 원시 래핑 객체의 경우 역직렬화는 문제가 없으나 직렬화시에는 한번 더 래핑에서 직렬화한다.

@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
MoneyDto {
	private Money money;
}

이런 Dto를 만들었다고 가정하자 우리가 원하는 json 직렬화는 다음과 같다.

{
	money: ~~~~
}

그러나 실제 응답은 위와 같은 형태로 나오지 않는다.

{
	money: {
    	money: ~~~~
    }
}

사실 래핑 객체이기 때문에 당연히 나올 수 밖에 없는 결과이다.

문제 해결하기

문제 파악하기

위 문제를 해결하려면 클래스의 래핑을 벗겨야 한다. Jackson은 이런 상황에도 대비를 해놨나 보다. 관련 어노테이션을 통해 래핑을 벗겨내는 방법을 사용 가능하다. 그러나 나는 원시 래핑 객체에 Jackson 어노테이션을 통해 의존하고 싶지 않았고, 래핑 객체를 자동으로 jackson에서 인식해서 벗겨주길 원했다.

정리하자면

  • 원시 래핑 객체는 jackson 라이브러리에 의존해서는 안된다.(어노테이션 허용 x)
    • jackson 라이브러리를 사용하지 않더라도 유연하게 사용가능하게 하기 위해
    • 어노테이션을 매번 래핑 객체에 사용하는 것은 중복 작업이기에 피하고 싶음
  • 원시 래핑 객체는 JSON 직렬화시 래핑이 벗겨지고 내부 변수만을 직렬화해야 한다.

답은 Jackson Module과 StdSerializer

https://d2.naver.com/helloworld/0473330

네이버 기술 블로그에서도 spring boot를 활용해 JSON 직렬화/역직렬화 문제를 해결하고 싶었나 보다. 위의 예제에서 jackson module과 Serializer, DeSerializer를 활용해서 문제를 해결한다. 위의 해결방법의 좋은 점은 Module을 추가해서 ObjectMapper를 초기화함으로써 어노테이션 없이 내가 적용하고 싶은 타입을 해당 객체가 알아서 인식하고 직렬화/역직렬화를 할 수 있다는 점이다.

그러나 이 해결 방법도 타입 인식의 문제점이 발생한다.

제네릭 매개변수 타입을 받는 Serializer

Serializer를 편하게 사용하고 싶어 StdSerializer를 사용하는데 JsonSerializer를 상속받은 StdSerializer 역시 제네릭을 활용한다. StdSerializer는 정의한 제네릭 매개변수 타입을 인식하고 이를 serialize 메서드를 오버라이드해서 처리한다.

문제는 매번 Wrapper 객체를 만들 때마다 해당 타입을 처리해야 한다는 것이다. 이것은 어노테이션을 붙이는 것만 못하다. 타입만 다르고 비슷비슷한 코드를 계속해서 추가해야 하기 때문이다.

interface를 통해 다형성을 활용하기

이 문제의 해결방법은 하나의 타입을 인식할 수 있게 만들고 다형성을 활용하는 것이다. 이 방법의 좋은 점은 모듈에 매 번 비슷한 로직의 wrapper 타입을 추가할 필요 없고 정의한 interface를 활용하면 훨씬 효율적으로 사용 가능하기 때문이다.

getter를 쓸 것인가.. 말 것인가..

본격적으로 내가 원하는 방식으로 serialize하기 위해서는 serialize를 오버라이딩해야 한다. 이 때 내가 원하는 방식의 wrapper 타입의 클래스의 내부 변수에 접근하기 위해서는 getter 메서드를 interface에 구현해야 한다. 그러나 나는 wrapper 클래스에 getter를 제공하고 싶지 않았다. 그 이유는 public으로 공개된 wrapper는 내부 변수을 외부에서 접근 가능하고 개발자가 객체지향적이지 못한 코드를 짤 수 있다는 위험성이 있기 때문이였다. 그럼 나머지 하나 남은 방법은 reflection이다.

reflection을 활용해서 직렬화 로직 작성하기

public class WrapperSerializer extends StdSerializer<Wrapper> {

	protected WrapperSerializer() {
		this(null);
	}

	protected WrapperSerializer(Class<Wrapper> t) {
		super(t);
	}

	@Override
	public void serialize(Wrapper value, JsonGenerator gen, SerializerProvider provider) throws IOException {
		Class<?> clazz = value.getClass();
		Field[] fields = Stream.of(clazz.getDeclaredFields()).filter(this::isPurePropertyField).toArray(Field[]::new);
		if (fields.length != 1) {
			throw new IllegalStateException("It has not one property" + "property numbers: " + fields.length);
		}
		Field field = fields[0];
		field.setAccessible(true);
		makeJsonByFieldType(value, gen, field);
	}

	private boolean isPurePropertyField(Field field) {
		int modifiers = field.getModifiers();
		return !Modifier.isTransient(modifiers) && !Modifier.isNative(modifiers) && !Modifier.isVolatile(modifiers)
			&& !Modifier.isStatic(modifiers);
	}

	private void makeJsonByFieldType(Wrapper value, JsonGenerator gen, Field field) throws IOException {
		try {
			Object o = field.get(value);
			if (o instanceof String) {
				gen.writeString((String)o);
			}
			if (o instanceof UUID) {
				gen.writeString(o.toString());
			}
			if (o instanceof Double) {
				gen.writeNumber((double)o);
			}
			if (o instanceof Integer) {
				gen.writeNumber((int)o);
			}
			if (o instanceof Float) {
				gen.writeNumber((float)o);
			}
			if (o instanceof Short) {
				gen.writeNumber((short)o);
			}
			if (o instanceof Long) {
				gen.writeNumber((long)o);
			}
		} catch (IllegalAccessException e) {
			throw new IllegalStateException(e);
		}
	}
}

reflection을 활용해서 작성한 로직이다. 우선 장점과 단점을 설명하겠다.

장점

  • private 변수에 강제로 접근할 수 있기 때문에 getter를 API로 노출할 필요가 없다.

단점

  • 타입 검사와 Wrapper 인터페이스가 적절하게 구현됬는지 검사하는 로직이 들어가 있다. 이는 getter에 비해 퍼포먼스가 떨어질 수 있다.
  • wrapper 타입의 로직 검사를 serializer에서 하고 있다. 즉 런타임에서 wrapper가 잘 못 쓰였음을 개발자는 알 수 있는데 이 때문에 반드시 wrapper 인터페이스에 반드시 주석을 남겨야 하며, 잘못된 코드 작성의 위험성이 있다.

2번째 단점 때문에 주석을 사용

단점의 2번째가 조금 위험할 수도 있다고 생각했다. 그래서 만약 성능 이슈가 있다면 interface에 getter API를 추가해서 로직을 간단하게 하는 방법을 사용해야 할 듯 쉽다.

jackson module에 serializer 추가하기

public class WrapperModule extends SimpleModule {

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

	@Override
	public void setupModule(SetupContext context) {
		SimpleSerializers simpleSerializers = new SimpleSerializers();
		simpleSerializers.addSerializer(Wrapper.class, new WrapperSerializer());

		context.addSerializers(simpleSerializers);
	}
}

이를 objectMapper 초기화시 해당 모듈을 추가하면 자동으로 Wrapper 타입을 인식해줄 것이다.

profile
엘 프사이 콩그루

0개의 댓글