객체지향적으로 설계하다보면 원시 객체 래핑이 필요할 때가 있다. 원시 객체 래핑에는 좋은 글이 많지만 요약해서 설명하자면 확장성이 좋고 원시 객체의 상태를 생성시 스스로 검증하면서 비즈니스에 적합한 타입을 만들 수 있다.
예를 들어 보자
우리가 돈을 표현하고 싶다. 가장 쉬운 방법은 언어에서 제공하는 원시 타입으로 표현하는 것이다.
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에서는 최종적으로 json 직렬화/역직렬화시 jackson 라이브러리를 사용한다. 그러나 원시 래핑 객체의 경우 역직렬화는 문제가 없으나 직렬화시에는 한번 더 래핑에서 직렬화한다.
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
MoneyDto {
private Money money;
}
이런 Dto를 만들었다고 가정하자 우리가 원하는 json 직렬화는 다음과 같다.
{
money: ~~~~
}
그러나 실제 응답은 위와 같은 형태로 나오지 않는다.
{
money: {
money: ~~~~
}
}
사실 래핑 객체이기 때문에 당연히 나올 수 밖에 없는 결과이다.
위 문제를 해결하려면 클래스의 래핑을 벗겨야 한다. Jackson은 이런 상황에도 대비를 해놨나 보다. 관련 어노테이션을 통해 래핑을 벗겨내는 방법을 사용 가능하다. 그러나 나는 원시 래핑 객체에 Jackson 어노테이션을 통해 의존하고 싶지 않았고, 래핑 객체를 자동으로 jackson에서 인식해서 벗겨주길 원했다.
정리하자면
https://d2.naver.com/helloworld/0473330
네이버 기술 블로그에서도 spring boot를 활용해 JSON 직렬화/역직렬화 문제를 해결하고 싶었나 보다. 위의 예제에서 jackson module과 Serializer, DeSerializer를 활용해서 문제를 해결한다. 위의 해결방법의 좋은 점은 Module을 추가해서 ObjectMapper를 초기화함으로써 어노테이션 없이 내가 적용하고 싶은 타입을 해당 객체가 알아서 인식하고 직렬화/역직렬화를 할 수 있다는 점이다.
그러나 이 해결 방법도 타입 인식의 문제점이 발생한다.
Serializer를 편하게 사용하고 싶어 StdSerializer를 사용하는데 JsonSerializer를 상속받은 StdSerializer 역시 제네릭을 활용한다. StdSerializer는 정의한 제네릭 매개변수 타입을 인식하고 이를 serialize 메서드를 오버라이드해서 처리한다.
문제는 매번 Wrapper 객체를 만들 때마다 해당 타입을 처리해야 한다는 것이다. 이것은 어노테이션을 붙이는 것만 못하다. 타입만 다르고 비슷비슷한 코드를 계속해서 추가해야 하기 때문이다.
이 문제의 해결방법은 하나의 타입을 인식할 수 있게 만들고 다형성을 활용하는 것이다. 이 방법의 좋은 점은 모듈에 매 번 비슷한 로직의 wrapper 타입을 추가할 필요 없고 정의한 interface를 활용하면 훨씬 효율적으로 사용 가능하기 때문이다.
본격적으로 내가 원하는 방식으로 serialize하기 위해서는 serialize를 오버라이딩해야 한다. 이 때 내가 원하는 방식의 wrapper 타입의 클래스의 내부 변수에 접근하기 위해서는 getter 메서드를 interface에 구현해야 한다. 그러나 나는 wrapper 클래스에 getter를 제공하고 싶지 않았다. 그 이유는 public으로 공개된 wrapper는 내부 변수을 외부에서 접근 가능하고 개발자가 객체지향적이지 못한 코드를 짤 수 있다는 위험성이 있기 때문이였다. 그럼 나머지 하나 남은 방법은 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을 활용해서 작성한 로직이다. 우선 장점과 단점을 설명하겠다.
장점
단점
2번째 단점 때문에 주석을 사용
단점의 2번째가 조금 위험할 수도 있다고 생각했다. 그래서 만약 성능 이슈가 있다면 interface에 getter API를 추가해서 로직을 간단하게 하는 방법을 사용해야 할 듯 쉽다.
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 타입을 인식해줄 것이다.