2 분 소요

Redis standalone -> cluster 마이그레이션 작업을 하면서 Redis Serializer 설정 불일치로 역직렬화 시 InvalidTypeIdException 예외가 발생했었다. 정확히는 다른 이유로 마이그레이션 건 롤백 후 발생했지만, 아무튼 원인을 찾아보니 Serializer에 사용했던 ObjectMapper 설정값이 다른게 원인이었다는걸 알게되어서 참고차 기록했다.

  • 이 글은 단순 String만 직렬화하는 경우는 해당되지 않는다. 객체를 다루는 경우만 해당된다.

특히 MSA 환경에서 개발중이라면 같은 Redis 인스턴스를 보게될 수 있는데, 이때 ObjectMapper 설정은 동일하게 가져가는게 불필요한 이슈를 피할 수 있다는게 결론이다. 불가피한 경우가 아니라면 같은 자원(Redis 인스턴스 등)에 접근하는 로직들은 통일하자.

InvalidTypeIdException는 어떤 경우에 발생할까

위에서 Redis Serializer를 얘기했지만 일단 InvalidTypeIdException는 Jackson에서 발생시키는 예외다.
그래서 ObjectMapper 인스턴스에서 default typing 설정 여부로 재현도 가능하다. 아래 테스트 코드를 참고하자.

  • default typing 활성화: 직렬화 시 @class 포함
    • 역직렬화 시 @class 값을 보고 일치하는 class로 fetch
  • default typing 비활성화 (기본값): 직렬화 시 @class 미포함
테스트 코드
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import org.junit.jupiter.api.Test;

import static org.junit.Assert.*;

public class SerializeTest {

	@Test
	void defaultTypingOff() throws Exception {
		ObjectMapper mapper = new ObjectMapper();
		String json = mapper.writeValueAsString(new User("abc"));

		System.out.println(json);
		assertFalse(json.contains("@class"));
	}

	@Test
	void defaultTypingOn() throws Exception {
		ObjectMapper mapper = new ObjectMapper();
		mapper.activateDefaultTyping(
				BasicPolymorphicTypeValidator.builder()
						.allowIfSubType(Object.class)
						.build(),
				ObjectMapper.DefaultTyping.NON_FINAL,
				JsonTypeInfo.As.PROPERTY
		);

		String json = mapper.writeValueAsString(new User("abc"));

		System.out.println(json);
		assertTrue(json.contains("@class"));
	}

	@Test
	void deserializeWithoutClassInfo() {
		ObjectMapper mapper = new ObjectMapper();
		mapper.activateDefaultTyping(
				BasicPolymorphicTypeValidator.builder()
						.allowIfSubType(Object.class)
						.build(),
				ObjectMapper.DefaultTyping.NON_FINAL,
				JsonTypeInfo.As.PROPERTY
		);

		String jsonWithoutClass = "{\"name\":\"abc\"}";

		Exception exception = assertThrows(
				Exception.class,
				() -> mapper.readValue(jsonWithoutClass, Object.class)
		);

		System.out.println(exception.getMessage());
		assertTrue(exception.getMessage().contains("@class"));
	}

	@Test
	void deserializeWithClassInfo() throws JsonProcessingException {
		ObjectMapper mapper = new ObjectMapper();
		mapper.activateDefaultTyping(
				BasicPolymorphicTypeValidator.builder()
						.allowIfSubType(Object.class)
						.build(),
				ObjectMapper.DefaultTyping.NON_FINAL,
				JsonTypeInfo.As.PROPERTY
		);

		String jsonWithoutClass = "{\"@class\":\"SerializeTest$User\",\"name\":\"abc\"}";
		Object result = mapper.readValue(jsonWithoutClass, Object.class);
		System.out.println(result.toString());
	}

	static class User {
		public String name;
		public User() {}
		public User(String name) {
			this.name = name;
		}
	}
}

대략 이런 시나리오에서 예외가 발생한다고 보면 된다.

  1. default typing=false인 ObjectMapper로 직렬화한 String 생성 (@class가 포함됨)
  2. 위 String을 default typing=true인 ObjectMapper로 역직렬화 시도 -> 예외 발생

역으로 default typing=true ObjectMapper로 직렬화하면 @class는 포함되지만,
해당 String을 default typing=false ObjectMapper로 역직렬화하면 @class를 안보고 역직렬화하므로 괜찮다.

해결 방법

ObjectMapper 설정을 모두 동일하게 가져가는게 맘편하다. (위에서 설정이 달라도 괜찮은 케이스도 언급했지만)
MSA 환경인데 서로 같은 데이터에 접근 가능한 경우(DB, Kafka, Redis, feign call 등) 설정 불일치가 있는지 고려하는게 좋겠다.

개인적으로는 default typing을 다 끄고 사용하는 것도 나쁘지 않을 것 같다. 직렬화하는 입장에선 어떤 클래스로 파싱될진 몰라도 객체 내 key-value가 필요한 곳에서 파싱해 갈거라는 생각이 있으니까 꼭 class 정보를 담을 필요는 없을 것 같다. 특정 class로의 파싱을 강하게 제한해야하는 케이스가 크게 떠오르지 않는다.

값을 저장할 때는 문제없으나 저장한걸 꺼내서 객체로 변환할 때 문제가 발생하니 디버깅하는데 시간이 좀 걸려 골치아팠다.
다음에도 큰 일이 있지 않다면 통일해서 사용하고 예외 상황은 나중에 필요하면 고려하는게 나은 것 같다.

카테고리:

업데이트: