Jackson(ObjectMapper)로 Enum컨트롤 하기

recordsbeat·2020년 6월 18일
2
post-thumbnail

Enum아저씨

한 동안 여러가지 이벤트로 포스팅이 뜸 했다..
하지만 개발은 계속 하고 있었던 것.

백엔드 작업 후 프론트 단과 협업하는 중 Enum 자료형 통신에 문제점을 발견하였다.

Enum을 Json으로 바꾸어 통신할 때 Field : Name과 같이 변환되었다.


@Test
    public void enumToJson() throws Exception{
        ObjectMapper objectMapper = new ObjectMapper();
        Response response = Response.builder()
                .id(1L)
                .language(Language.KOREAN)
                .build();
        String jsonDto = objectMapper.writeValueAsString(response);
        System.out.println("jsonDto : " + jsonDto);
    }

public enum Language implements CodeEnum {
    ENGLISH("en", "영어"),
    KOREAN("ko", "한국어");

    private String code;
    private String comment;

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getComment() {
        return comment;
    }


    @Override
    public String getCode() {
        return code;
    }
}

결과

jsonDto : {"id":1,"language":"KOREAN"}

클라이언트(프론트)의 입장에서는 EnumValue만을 가지고 UI에 노출하는 데 무리가 있다. 영어로 표기된 English야 보편적으로 사용되지만 자주 사용되지 않는 언어나 Enum Value는 UI에 노출을 위한 설명 태그(위 Enum Value의 "영어","한국어")가 필요한 것이다.

어떤 자료형이 가장 적합할까?
일전에 이동욱님의 Enum 사용기를 토대로 만들어 두었던 EnumValueDto가 존재한다.

참조링크
Enum 활용 & Enum 리스트 가져오기
https://jojoldu.tistory.com/122

public class EnumValueDto {
    private String key;
    private String value;
    private String comment;

    public EnumValueDto(CodeEnum codeEnum) {
        key = codeEnum.getKey();
        value = codeEnum.getCode();
        comment = codeEnum.getComment();
    }

    public String getComment() {
        return comment;
    }

    public String getKey() {
        return key;
    }

    public String getValue() {
        return value;
    }
}


public interface CodeEnum {
    String getKey();
    String getCode();
    String getComment();
}

선언해둔 모든 Enum클래스는 CodeEnum을 구현한다. 그리고 EnumValueDto는 CodeEnum을 생성자 인자로 받아 사용자가 컨트롤 하기 쉬운 자료형으로 바꿔준다.

위를 코드에 사용하게 되면 다음과 같다.


@Getter
@AllArgsConstructor
@Builder
public class Response {
    Long id;
    EnumValueDto language;
}

public class EnumTest {

    @Test
    public void enumToJson() throws Exception{
        ObjectMapper objectMapper = new ObjectMapper();
        Response response = Response.builder()
                .id(1L)
                .language(new EnumValueDto(Language.KOREAN))
                .build();
        String jsonDto = objectMapper.writeValueAsString(response);
        System.out.println("jsonDto : " + jsonDto);
    }
}

결과

jsonDto : {"id":1,"language":{"key":"KOREAN","value":"ko","comment":"한국어"}}

결과를 보면 사용자가 comment를 사용하여 UI에 노출하고 통신 간 Key를 사용하면 될 것으로 보인다.

하지만 이도 불편함이 많아보인다.

서버는 기존에 만들어 둔 모든 Response의 Enum 자료형을 EnumValueDto로 변환해야 한다.

그래서 EnumValueDto를 사용하지 않고 Enum 자체를 사용자가 보기 편하도록 바꾸는 방법을 택했다.


@ToString
@AllArgsConstructor
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum Language implements CodeEnum {
    ENGLISH("en", "영어"),
    KOREAN("ko", "한국어");

    private String code;
    private String comment;

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getComment() {
        return comment;
    }


    @Override
    public String getCode() {
        return code;
    }
}

결과

jsonDto : {"id":1,"language":{"code":"ko","comment":"한국어","key":"KOREAN"}}

@JsonFormat(shape = JsonFormat.Shape.OBJECT) 어노테이션 추가로 Enum 자료형을 EnumValueDto를 사용했을 때와 같이 문자열로 출력한다.

클라이언트로 보내기위한 Serialize절차(Enum -> Json)는 끝났다. 그럼 Deserialize(Json -> Enum)은 어떻게 해결해야 할까?

간단했다.


@ToString
@AllArgsConstructor
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum Language implements CodeEnum {
    ENGLISH("en", "영어"),
    KOREAN("ko", "한국어");

    private String code;
    private String comment;

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getComment() {
        return comment;
    }


    @Override
    public String getCode() {
        return code;
    }

    @JsonCreator
    public static Language fromJson(@JsonProperty("key") String name) {
        return valueOf(name);
    }

 
}

public static으로 메소드를 선언하고 JsonProperty의 Key를 Enum으로 변환해주도록 한다. 마지막으로 @JsonCreator을 붙이면 끝



@ToString
@AllArgsConstructor
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum Language implements CodeEnum {
    ENGLISH("en", "영어"),
    KOREAN("ko", "한국어");

    private String code;
    private String comment;

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getComment() {
        return comment;
    }


    @Override
    public String getCode() {
        return code;
    }

    @JsonCreator
    public static Language fromJson(@JsonProperty("key") String name) {
        return valueOf(name);
    }
}
@Test
public void jsonToEnum() throws Exception{
	ObjectMapper objectMapper = new ObjectMapper();
	String str = "{\"id\":1,\"language\":{\"code\":\"ko\",\"comment\":\"한국어\",\"key\":\"KOREAN\"}}";

	Response result = objectMapper.readValue(str,Response.class);

	System.out.println("Response : " + result);
}

결과

Response : Response(id=1, language=Language(code=ko, comment=한국어))

이렇게되면 웬만큼 클라이언트 서버 모두 win-win하는 것처럼 보인다.
그러나 한 번에 끝나면 재미없지.
내가 사용하는 Enum자료형이 Language 하나뿐일 리가 없다.

이전에 Jackson을 알아봤을 때 다음과 같은 내용이 있었다.

@Bean에 ObjectMapper등록해 놓고 싱글톤으로 주입 받아서 쓰기 때문에 성능도 우수
[JAVA] JSON 다루기 정리 (JACKSON - ObjectMapper)中
http://bitly.kr/1lp8PJ3InX

ObjectMapper를 사용해 LocalDate를 통째로 De/Serialize를 진행했던 기억을 더듬어 Enum자료형 또한 컨트롤해보자.

참조링크
Jackson databind enum case insensitive
https://stackoverflow.com/questions/24157817/jackson-databind-enum-case-insensitive


@Configuration
public class JacksonConfiguration {

    @Bean
    @Primary
    public ObjectMapper serializingObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();

        SimpleModule module = new SimpleModule();

        module.setDeserializerModifier(new BeanDeserializerModifier() {
            @Override
            public JsonDeserializer<Enum> modifyEnumDeserializer(DeserializationConfig config,
                                                                 final JavaType type,
                                                                 BeanDescription beanDesc,
                                                                 final JsonDeserializer<?> deserializer) {
                return new JsonDeserializer<Enum>() {
                    @Override
                    public Enum deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
                        Class<? extends Enum> rawClass = (Class<Enum<?>>) type.getRawClass();
                        final JsonNode jsonNode = jp.readValueAsTree();
                        String key = jsonNode.get("key").asText();
                        return Enum.valueOf(rawClass, key);
                    }
                };
            }
        });

        module.addSerializer(CodeEnum.class, new StdSerializer<CodeEnum>(CodeEnum.class) {
            @Override
            public void serialize(CodeEnum value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
                jgen.writeStartObject();
                jgen.writeStringField("key", value.getKey());
                //jgen.writeStringField("code", value.getCode());
                jgen.writeStringField("comment", value.getComment());
                jgen.writeEndObject();
            }
        });
        objectMapper.registerModule(module);

        return objectMapper;
    }
}

익명클래스를 사용한 Enum De/Serialize 커스터 마이징이다.
Serialize를 할때 CodeEnum을 사용한 것이 조금 억지스럽지만 모든 Enum을 CodeEnum의 구현체로 사용하였기에 크게 문제가 되진 않는다.

위와 같이 쓰면 Language에 작업해두었던 Json관련 어노테이션을 모두 삭제 해도 무방하다.


@ToString
@AllArgsConstructor
@Getter
public enum Language implements CodeEnum {
    ENGLISH("en", "영어"),
    KOREAN("ko", "한국어");

    private String code;
    private String comment;

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getComment() {
        return comment;
    }


    @Override
    public String getCode() {
        return code;
    }
}

@RunWith(SpringRunner.class)
@SpringBootTest
public class EnumTest {

    @Autowired
    ObjectMapper objectMapper;

    @Test
    public void enumToJson() throws Exception{
        Response response = Response.builder()
                .id(1L)
                .language(Language.KOREAN)
                .build();
        String jsonDto = objectMapper.writeValueAsString(response);
        System.out.println("jsonDto : " + jsonDto);
    }

    @Test
    public void jsonToEnum() throws Exception{
        String str = "{\"id\":1,\"language\":{\"code\":\"ko\",\"comment\":\"한국어\",\"key\":\"KOREAN\"}}";
        Response result = objectMapper.readValue(str,Response.class);

        System.out.println("Response : " + result);
    }
}

빈으로 등록해둔 ObjectMapper를 사용하여 테스트 해보니 정상적으로 작동한다.

결과

jsonDto : {"id":1,"language":{"key":"KOREAN","comment":"한국어"}}
Response : Response(id=1, language=Language(code=ko, comment=한국어))

해당 작업을 끝낸 후 모든 테스트를 돌려봤더니 또 한가지 문제점이 발생했었다.

바로 MultiValueMapConverter.
MockMvc를 사용한 get 매핑 테스트를 할 때 파라미터를 편리하게 사용하도록 만든 클래스다.

참조링크
Dto 클래스에서 MultiValueMap로 쉽게 타입 변환하기
https://jojoldu.tistory.com/478


@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class MultiValueMapConverter {

    public static MultiValueMap<String, String> convert(ObjectMapper objectMapper, Object dto) { // (2)
        try {
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            Map<String, String> map = objectMapper.convertValue(dto, new TypeReference<Map<String, String>>() {}); // (3)
            params.setAll(map); // (4)

            return params;
        } catch (Exception e) {
            log.error("Url Parameter 변환중 오류가 발생했습니다. requestDto={}", dto, e);
            throw new IllegalStateException("Url Parameter 변환중 오류가 발생했습니다.");
        }
    }

}
@Test
public void findAll() throws Exception{
	SearchDto SearchDto = SearchDto.builder()
		.status(Status.STANDBY)
		.build();

	MultiValueMap<String,String> searchMap = MultiValueMapConverter.convert(objectMapper, SearchDto);


	mockMvc.perform(get("/api/v1")
		.params(searchMap))
		.andDo(print())
		.andExpect(status().isOk())
		.andExpect(jsonPath("$.content.[0].id").exists())
		.andExpect(jsonPath("$.content.[0].name").exists());
}
2020-06-18 19:03:38.327 ERROR 1960 --- [           main] c.guiving.utils.MultiValueMapConverter   : Url Parameter 변환중 오류가 발생했습니다. requestDto=OperatorSearchDto( status=OperatorStatus(code=0, comment=승인대기))

java.lang.IllegalArgumentException: Cannot deserialize instance of `java.lang.String` out of START_OBJECT token
 at [Source: UNKNOWN; line: -1, column: -1] (through reference chain: java.util.LinkedHashMap["status"])
	at com.fasterxml.jackson.databind.ObjectMapper._convert(ObjectMapper.java:3750) ~[jackson-databind-2.9.7.jar:2.9.7]
	at com.fasterxml.jackson.databind.ObjectMapper.convertValue(ObjectMapper.java:3678) ~[jackson-databind-2.9.7.jar:2.9.7]
	at com.guiving.utils.MultiValueMapConverter.convert(MultiValueMapConverter.java:27) ~[classes/:na]

ObjectMapper를 사용해 dto를 Map<String, String> map으로 변환하는데 문제가 생긴 것이다. 기존에 EnumValue를 통해
"language":"KOREAN" 와 같이 컨버팅 되던 값이 이제는
"language":{"key":"KOREAN","comment":"한국어"} 와 같은 형태로 나오니 Map으로 변환을 실패한 것이다.

파라미터로 넘어온 dto자료형을 ObjectMapper의 convertValue로 넘기기 전에 한 번 더 파싱을 시도했다.

참조링크
Java introspection: object to map
https://stackoverflow.com/questions/52406467/convert-object-to-map-in-java


@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class MultiValueMapConverter {

    public static MultiValueMap<String, String> convert(ObjectMapper objectMapper, Object dto) { // (2)
        try {
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            Map<String, String> map = objectMapper.convertValue(parameters(dto), new TypeReference<Map<String, String>>() {}); // (3)
            params.setAll(map); // (4)

            return params;
        } catch (Exception e) {
            log.error("Url Parameter 변환중 오류가 발생했습니다. requestDto={}", dto, e);
            throw new IllegalStateException("Url Parameter 변환중 오류가 발생했습니다.");
        }
    }

    public static Map parameters(Object dto) {
        Map<String, String> map = new HashMap<>();
        for (Field field : dto.getClass().getDeclaredFields()) {
            try {
                field.setAccessible(true);
                Object value=field.get(dto);

                map.put(field.getName(), value instanceof Enum? ((Enum) value).name():value.toString());
            }
            catch (Exception e) {
            }
        }
        return map;
    }
}

각 필드를 순회하면서 Enum 자료형인지 판별 후 Enum일 경우 KeyValue를 넘겨주도록 한다. Dto를 Map으로 컨버팅한 것이 불필요한 단계가 하나 더 생긴 것이 아닌가 하는 아쉬움이 남는다. 또한 parameters메소드를 Stream을 통해 구현할 수 있도록 바꿔봐야겠다.

동작원리를 제대로 알고 쓰냐 묻는다면 당당히 그렇다고 할 수는 없다.
차츰 알아가보면서 개선해나가는 것을 목표로 해야징


제에발 그만들좀하세요

profile
Beyond the same routine

2개의 댓글

comment-user-thumbnail
2021년 6월 18일

포스팅 잘 봤습니다.
다만 매 번 저렇게 JSON을 파싱하게 되면 성능에는 이슈 없을까요?
차라리 JSON 관련 애너테이션을 각 Enum에 추가하는 방식이 더 깔끔할 거 같은데..
그리고 CodeEnum 이런 인터페이스를 구현하는 것도 당연히 좋은 방법이지만
@JsonCreator // json -> object
@JsonValue // object -> json
이 두 가지만 사용해도 serialize/deserialize가 모두 가능합니다.
그래도 인터페이스를 반드시 구현하게 가이드해서 통일성을 주는 게 협업할 때는 더 좋은 방식일 거 같네요!

1개의 답글