ObjectMapper
ObjectMapper란?
Java를 통해 개발을 하다보면, JSON 객체를 응답 받거나 데이터를 JSON 형태로 변환하는 등, JSON 형태의 데이터를 우리가 원하는 객체나 DTO에 저장해야할 필요가 있다. ObjectMapper란 이러한 JSON 데이터를 Java 객체로 역질렬화(deserialization)하거나, 반대로 Java 객체를 JSON 데이터로 직렬화(serialization) 할 때 사용하는 Jackson 라이브러리의 클래스이다.
ObjectMapper는 생성 비용이 비싸기 때문에, 필요한 클래스마다 선언하여 사용하면 효율이 좋지 못하다.
// 생성자를 통한 의존성 주입
public class MyClass {
private final ObjectMapper objectMapper;
protected MyClass(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
}
Java
복사
때문에 static으로 생성하여 여러 클래스에서 공유하여 사용하거나, bean을 통해 의존성 주입을 받아서 사용하는 것이 권장된다.
자세한 내용및 사용법은 이전에 썼던 글에 자세히 적어두었으니, 참고하자.
문제 상황 및 해결 과정
문제 발생 상황
CQRS 패턴 적용을 위해서는 문자열로 이루어진 JSON 데이터를 우리가 사용할 DTO나 객체로 바꾸어야 하는 상황이다. 파싱하고자하는 DTO 객체는 다음과 같다.
public class AvailableCabinetInfoDto {
private Map<Integer, List<CabinetPreviewDto>> cabinetResponseDto;
}
public class CabinetPreviewDto {
private Long cabinetId;
...
}
Java
복사
이와 같이 객체 내부에 있는 Map 자료구조에 value로 List 자료 구조 형태로 또 다른 DTO를 가지고 있는 형태이다. 이러한 형태의 데이터를 조회용 데이터베이스인 Redis에 문자열 형태로 저장하고 꺼내야한다.
// JSON 문자열 -> DTO
private <T> T stringToDto(String value) {
if (value == null) {
return null;
}
try {
return objectMapper.readValue(value, new TypeReference<>() {});
} catch (JsonProcessingException e) {
log.error("String to JSON Parse Error : {}, {}", value, e.toString());
throw ExceptionStatus.INTERNAL_SERVER_ERROR.asDomainException();
}
}
Java
복사
// DTO -> JSON 문자열
private <T> String dtoToString(T dto) {
if (dto == null) {
return null;
}
try {
return objectMapper.writeValueAsString(dto);
} catch (JsonProcessingException e) {
log.error("DTO to String Parse Error : {}, {}", dto, e.toString());
throw ExceptionStatus.INTERNAL_SERVER_ERROR.asDomainException();
}
}
Java
복사
이러한 형태로 파싱하기 위해 별도의 파싱 메서드를 추가했다. 이와 같이 작성된 코드를 수행했을 때 ClassCastException 발생이 발생하는 문제가 있었다.
ClassCastException
위 코드 자체는 문제 없이 잘 동작한다.
// 내부 요소 정렬
List<CabinetPreviewDto> availableCabinets = cqrsRedis.getHash(key, floor);
availableCabinets.sort(Comparator.comparing(CabinetPreviewDto::getCabinetId));
// 내부 요소 삭제
List<CabinetPreviewDto> availableCabinets = cqrsRedis.getHash(key, floor);
availableCabinets.removeIf(c -> c.getCabinetId().equals(cabinet.getId()));
Java
복사
하지만 이처럼 파싱 이후 변환된 DTO 내부를 정렬하려하거나, 내부의 요소를 삭제하려고 하면 아래와 같이 ClassCast Exception이 발생한다.
처음에는 LinkedHashMap은 사용하지도 않았는데 이게 뭐지 싶었다.
디버거를 통해 확인해보니, List 내부에 CabinetPreviewDto로 저장하는 것이 아니라 LinkedHashMap 형태로 저장되어 있었다.
이러한 에러가 발생하는 이유는 우리는 objectMapper.readValue() 메서드를 호출할 때 제네릭 타입으로 List<CabinetPreviewDto>를 묵시적으로 전달하지만, 제네릭을 받는 입장에서는 단순히 T 타입으로 넘어오니까 외부에 감싸져있는 ArrayList.class를 객체로 전달 받는다. 때문에 objectMapper에서 역직렬화를 수행할 때, 전달 받지 않아 모르는 클래스인 CabinetPreviewDto로 변환하지 않고 자신이 알고 있는 클래스 중 데이터 형태에 맞는 LinkedHashMap 형태로 변환하는 것이다.
objectMapper.readValue(value, new TypeReference<>() {});
Java
복사
결국 문제가 되는 코드는 위의 TypeReference의 타입을 지정하지 않고, 묵시적 캐스팅을 통한 제네릭으로 전달받기 때문에 발생하는 문제인 것이다.
문제 해결
objectMapper.readValue(value, new TypeReference<List<CabinetPreviewDto>>() {});
Java
복사
사실 이 문제는 위처럼 TypeReference를 타입을 제대로 넣은 채로 매번 생성하여 넘겨주면 해결되는 문제였다. TypeReference를 생성해서 넣으면 intellij에서 명시적 타입 인수를 제거할 수 있다고 경고를 띄우고, 사용하는 Google style prettier에서 저장 시마다 자동으로 이러한 명시적 타입 인수를 삭제하는 바람에 이러한 방식으로 해결할 시도는 한 번도 못해봤던 것 같다.
이 글을 쓰기 전에 원인을 정확히 모른 채로 대충 추측하면서 오류를 잡으려고 하다보니, ObjectMapper를 통해 변환된 값을 다시 돌면서 정렬하려 하거나 불필요한 코드를 추가해가면서 해결하려고 노력했었다. 하지만 글을 쓰면서 원인을 명확히 분석하고 나니, 그간 해온 노력이 무색하게도 너무나도 쉽게 해결이 되어버렸다. 다시 한번 명확한 원인 분석 후 디버깅 하기의 중요성과 글로 정리하는 일의 유용함을 상기할 수 있었다.