본문 바로가기
생각

Redis, Kafka Deserialize with FQCN

by 6161990 2025. 5. 25.

 

Redis 나 Kafka 의 de/serialized 할 때, 객체의 패키지명(Fully Qualified Class Name) 이 직렬화 데이터(본문 혹은 Kafka 헤더)에 포함되는 경우가 있다. 역직렬화할 때, 그 대상 클래스를 알아야하기 때문에 객체의 FQCN 을 메세지에 포함하는 방식이다. Kafka 의 경우 DefaultKafkaHeaderMapper 사용 시 헤더에 __TypeId__키로 클래스명을 저장한다. Redis 의 경우에는 GenericJackson2JsonRedisSerializer 사용 시 @class 를 속성으로 포함한다. FQCN 기반으로 클래스 로딩을 시도하며, 해당 클래스가 없으면 오류가 발생한다. 

나는 이 방식은 위험하다고 생각했다. 패키지명이 변경되면 역직렬화시 오류가 나기 때문이다. 리팩토링하기 어려워진다. 메세지와 코드가 너무 강하게 결합되어있는 것이 문제다. 

해결할 수 있는 방법이 몇가지 있다. 첫 번째는 타입 별칭을 이용하는거다. @JsonTypeInfo + @JsonSubTypes 를 통해 클래스명 대신 alias 방식으로 핸들링할 수 있다. 

 



역직렬화 시에는 type=signup-even t처럼 클래스명이 아닌 alias만 포함되고, 리팩토링도 안전하게 수행할  수 있다. 아니면 Kafka Consumer 쪽에서 헤더에 담긴 alias 를 실제 클래스명으로 변환할 수 있도록 매핑하면된다. 

 


좀 더 전문적으로 사용하고 싶다면, Apache Avro 도 있다. Apache가 만든 JSON 기반 스키마 정의에 사용되는 툴이다. 스키마를 동적으로 포함하거나 분리할 수도 있다고한다. 아래 의존성과 yaml 세팅을 참고하여 테스트 해볼 수 있다. 

 

dependencies {
    implementation("org.springframework.kafka:spring-kafka")
    implementation("io.confluent:kafka-avro-serializer:7.5.0") // Confluent Schema Registry 연동
    implementation("org.apache.avro:avro:1.11.1") // Avro core
}

 

spring:
  kafka:
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
      properties:
        schema.registry.url: http://localhost:8081
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
      properties:
        schema.registry.url: http://localhost:8081
        specific.avro.reader: true



근데 나는 이런 방식 말고도 더 간편하고유연한 동작을 찾고 싶었다. 다름 포스팅을 발견헀다. 

typeMapper 는 JsonDeserializer 가 Kafka 메시지를 역직렬화할 때 사용하는 타입 추론 도구다. 기본 구현체는 DefaultJackson2JavaTypeMapper 가 있다. Kafka 헤더 또는 기타 설정으로부터 JavaType 을 결정하는 역할을 한다. 

 

 

DefaultJackson2JavaTypeMapper 동작에서의 핵심 메소드는 useHeadersIfPresent 다. 
true (기본값) 라면 Kafka 헤더(__TypeId__ 등)를 우선해서 타입을 결정한다. typePrecedence = TYPE_ID 가 된다.
false 라면, 헤더를 무시하고 JsonDeserializer 에 주입된 기본 타입을 사용한다. typePrecedence = INFERRED  가 된다.

흐름은 다음과 같다. 
1. Kafka 에서 메시지를 수신
2. JsonDeserializer 가 typeMapper.toJavaType() 을 호출
3. 헤더가 비어있고 useHeadersIfPresent=false 라서, JavaType = null
4. 헤더 기반 추론은 실패 
5. 지정된 기본 클래스 T.class 로 역직렬화 수행 성공

typeMapper 는 Kafka 메세지의 역직렬화 대상 타입을 결정하는데 사용된다. useHeadersIfPresent=false 로 설정하면, Kafka 헤더 없이도 역직렬화가 가능하고, 이때는 JsonDeserializer 에 미리 명시한 클래스 타입으로 직접 변환이 수행되는거다. 



Redis 에서는 어떨까? 우선 redisSerialize 는 두 가지를 사용해볼 수 있다. Jackson2JsonRedisSerializer<T> 과  GenericJackson2JsonRedisSerializer 다. 전자는 타입 정보가 포함 되지않아 직접 직렬화 대상 클래스 지정해야한다. 후자는 기본적으로 @class 속성이 포함되어있어서 대상 클래스를 따로 지정해주지않아도 된다. 다형성과 유연성은 후자가 강력하게 보장해주지만, 리팩토링 측면에서 위험도가 있어보인다. GenericJackson2JsonRedisSerializer 를 사용하면서도 @class 없이 직렬화/역직렬화할 수 있는 방법은 없을까? 아래처럼 수행하면 @class  속성 없이 데이터가 저장된다.

ObjectMapper 에서 DefaultTyping 을 deactive 처리하면된다.



근데 이렇게 되면 역직렬화 시점에서 Jackson은 "이 JSON을 어떤 클래스로 바꿔야 하는지" 알 수 없기 때문에, 조회시 타입을 명시적으로 전달해야한다. @class  없이 Redis에 저장한다는 건 오히려 Jackson의 다형성 역직렬화 기능을 "의도적으로 포기"하는 것이다. 그만큼 유연성은 떨어지지만, 리팩토링 안전성은 높아지는 트레이드오프 영역인거다.

 

추가로 GenericJackson2JsonRedisSerializer 이용시 custom ObjectMapper 를 사용하면 주의해야할 점이 있다. ObjectMapper는 기본적으로 직렬화/역직렬화 시 class type 정보를 포함하지 않기 때문에, 직렬화된 데이터에는 type 정보가 존재하지 않는다. 또한 역직렬화 시에도 ObjectMapper가 type 정보를 모른 채 역직렬화를 진행하게 되고, 기본 타입인 LinkedHashMap으로 역직렬화 되어 에러가 발생한다. 결국 아래와 같은 세팅값이 필요하다.


ObjectMapper.DefaultTyping은 Jackson에서 타입 정보를 JSON에 포함시키는 기준을 설정할 때 사용된다. activateDefaultTyping(...)을 사용할 때 설정하게 되며, 직렬화할 객체의 클래스 정보(@class )를 자동으로 포함시킬지 여부를 결정한다. 나는 가장 일반적으로사용되는 NON_FINAL 을 설정으로 가져갔다.

 

 

 

그런데도 redis value 에 @class 가 포함되지않았다. kotlin 의 패러다임과 관련있는 문제였다. Jackson이 역직렬화할 때는 주로 기본 생성자 호출 → 필드 주입(setter or reflection)혹은 생성자 기반 바인딩을 사용한다. 동적 프록시나 서브클래스를 만들어 인스턴스를 생성하는 방식은 다형성 처리(@JsonTypeInfo 사용 시)에서 주로 쓰인다.

@JsonTypeInfo(use = Id.CLASS)같은 다형성 처리를 사용하는 경우, Jackson은 역직렬화 대상 클래스의 타입 정보를 기반으로 동적으로 서브타입을 결정하려고 시도하는데, 이때 클래스가 final이면 다형적 타입 바인딩에 제약이 생길 수 있다. 왜냐하면 프록시 생성이나 하위 클래스 생성이 불가능하기 때문이다.

Kotlin은 모든 클래스와 메서드가 기본적으로 final이기 때문에, 이런 타입 바인딩 처리나 프록시 기반 AOP 적용 시 명시적으로 open을 붙여야 한다. 따라서 아래와 같이 수동 설정을 통해 Jackson이 클래스 타입 정보를 포함할 수 있도록 지정해주어야 한다:



Jackson 이 해주는 역할을 수동으로 설정해주는거다. 이 설정은 직렬화/역직렬화시 객체의 실제 클래스 정보를 JSON 에 포함시키는 역할을 한다. 그럼 다음과 같이 @class 정보가 추가된다.

 

 



메시지와 코드를 느슨하게 연결하는 것은 중요하다. FQCN 기반 직렬화는 편리하지만 장기적으로는 유연성을 해칠 수 있다고 생각했다. Alias, 명시적 매핑 등 몇 가지 메시지 매핑 도구를 찾아보았지만 바로 이거다! 하는 방법은 아니었다. 결국 또 트레이프의 영역 안에서 적재적소에 따라 오른쪽과 왼쪽을 번갈아가며 헤메야 하는 문제일지도 모른다.

 

 

 




참고 블로그

https://velog.io/@bagt/Redis-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%82%BD%EC%A7%88%EA%B8%B0-feat.-RedisSerializer

 

'생각' 카테고리의 다른 글

Agent 실험 2 - 졌잘싸 with Agent Test  (3) 2025.05.15
Agent 실험 1. 프롬프트 설계  (2) 2025.04.27
Prompt With Engineering  (0) 2025.03.21
ChainOfThought "AI"  (1) 2025.03.20
<AI, 네가 뭔데 날 울려> 소개  (3) 2025.03.17