Files
document-haness/.run/redis/redis-codec-schema-evolution.md
T

21 KiB

Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version

Redis 코드 상세 시리즈 09/20 · 전체 지도 · 이전: Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL · 다음: 문자열 명령 대신 타입을 노출하는 RedisOperations 코드 지도

이 글이 답하는 코드 질문

Redis에 저장한 object byte가 어느 schema와 version인지 어떻게 판별하며, 배포가 읽지 못하는 값은 cache miss가 아니라 어떤 실패가 됩니까?

코드는 payload와 framing의 책임을 나눕니다.

  • RedisPayloadCodec<T>는 schema id, write version, readable versions, payload encode/decode를 소유합니다.
  • VersionedJsonCodec<T>는 timestamp가 포함된 envelope와 byte ceiling을 소유합니다.
  • RedisCodecRegistry는 deployment가 승인한 schema와 Java type의 닫힌 집합을 소유합니다.

다른 schema, 읽을 수 없는 version, 깨진 framing은 RedisSerializationException입니다. ordinary miss로 바꾸지 않습니다.

먼저 보는 클래스·리소스 지도

클래스·리소스 입력 출력 다음 호출
RedisPayloadCodec domain object 또는 payload bytes/version payload bytes 또는 object VersionedJsonCodec
RedisEnvelope schema, version, createdAt, payload immutable envelope framing
JsonEnvelopeFraming envelope 또는 stored bytes canonical JSON bytes 또는 envelope VersionedJsonCodec
VersionedJsonCodec typed value/stored bytes versioned bytes/typed value typed operation
RedisCodecRegistry payload codec와 value type schema별 RedisCodec<V> typed key factory
golden order-summary-v1 고정 timestamp와 payload byte compatibility 기준 codec contract test

객체 생성 시점: registry를 닫습니다

RedisCodecRegistry.builder는 세 값을 받습니다.

  • maxValueBytes
  • envelope에 기록할 Clock
  • decode failure metadata에 기록할 RedisDeploymentMode

builder의 registerRedisPayloadCodec<V>Class<V>를 함께 받습니다. 내부에서 VersionedJsonCodec을 만들고 schema id를 key로 저장합니다.

동일 schema를 두 번 등록하면 실패합니다. class name을 보고 codec을 반사적으로 만들거나 stored bytes의 schema를 보고 미등록 decoder를 동적으로 로드하는 path는 없습니다.

registry에는 object envelope 외에도 네 built-in codec이 있습니다.

  • UTF-8 string
  • native long counter
  • native double counter
  • opaque byte array

이 built-in codec은 forSchema map과 별도로 singleton을 반환합니다.

생성자 단계의 불변식

VersionedJsonCodec 생성자는 다음을 확인합니다.

  1. payload codec, clock, deployment mode가 null이 아닙니다.
  2. maximum encoded bytes가 양수입니다.
  3. payload codec이 자신이 쓰는 writeVersion()을 읽을 수 있습니다.

세 번째 규칙은 배포가 쓴 직후 자기 값을 못 읽는 설정을 시작 전에 막습니다. constructor 검사에 있습니다.

RedisEnvelope도 schema가 1..128자의 제한된 alphabet인지, version이 양수인지 검사합니다. schema pattern은 letter, digit, ., _, -만 허용합니다. quote나 control character가 framing 구조를 바꾸지 못하게 합니다.

payload byte array는 constructor와 accessor에서 defensive copy됩니다. array를 record component로 두지 않고 value equality를 직접 구현했습니다.

Encode 호출 순서

sequenceDiagram
    participant O as Typed operation
    participant V as VersionedJsonCodec
    participant P as RedisPayloadCodec
    participant F as JsonEnvelopeFraming
    participant R as Redis
    O->>V: encode(value)
    V->>P: encodePayload(value)
    P-->>V: payload bytes
    V->>V: schema·writeVersion·clock.instant로 envelope 생성
    V->>F: write(envelope)
    F-->>V: canonical UTF-8 JSON
    V->>V: encoded byte ceiling 검사
    V-->>O: bytes
    O->>R: admission 후 write

encode는 Redis 호출 전 byte 길이를 검사합니다. 초과하면 RedisSerializationException이며 bytes는 server로 가지 않습니다.

codec id는 json:<schema>:v<writeVersion>입니다. 예를 들면 json:order-summary:v1입니다.

Canonical envelope bytes

JsonEnvelopeFraming.write는 field를 다음 순서로 씁니다.

{"schema":"order-summary","version":1,"createdAt":"2026-08-07T00:00:00Z","payload":"<base64>"}

payload는 Base64입니다. JSON serializer 설정이나 reflection에 byte 결과가 좌우되지 않습니다. 같은 envelope를 주면 writer는 같은 UTF-8 byte를 만듭니다. timestamp가 envelope의 일부이므로 실제 encode 호출의 clock instant가 다르면 전체 byte도 달라집니다.

golden-byte test는 fixed clock을 사용해 이 변수를 고정합니다.

Decode 호출 순서

flowchart TD
    A[stored bytes] --> B{byte ceiling 이내인가}
    B -- 아니요 --> X[RedisSerializationException]
    B -- 예 --> C[framing read]
    C --> D{exact four field set이고 값 변환이 가능한가}
    D -- 아니요 --> X
    D -- 예 --> E{schema가 codec schema와 같은가}
    E -- 아니요 --> X
    E -- 예 --> F{payloadCodec.canRead version인가}
    F -- 아니요 --> X
    F -- 예 --> G[decodePayload payload, version]

decode는 먼저 stored byte 길이를 검사합니다. 그다음 framing을 읽고 schema와 readable version을 확인합니다. 마지막에만 payload decoder를 호출합니다.

이 순서는 잘못된 schema의 payload를 우연히 같은 Java shape로 decode하는 것을 막습니다. future version도 caller가 canRead에서 명시하지 않으면 hard failure입니다.

Framing parser가 실제로 검사하는 범위

JsonEnvelopeFraming.read는 범용 JSON parser가 아니라 hand-written framing parser입니다. 다음 입력은 거절합니다.

  • null 또는 empty bytes
  • JSON object brace가 없는 문자열
  • 네 field 중 일부가 없거나 extra field가 있는 object
  • 중복 field
  • integer가 아닌 version
  • Instant로 읽히지 않는 timestamp
  • Base64가 아닌 payload
  • envelope constructor 규칙을 어긴 schema/version
  • field name의 quote, colon, escape 구조가 parser 문법과 맞지 않는 framing

그러나 이 목록을 strict 또는 canonical JSON validation으로 읽으면 안 됩니다. fields parser는 quoted value면 quote를 벗기고, 아니면 다음 comma까지의 text를 그대로 가져옵니다. 이후 versionInteger.parseInt, createdAtInstant.parse, payload는 Base64 decode가 성공하는지만 봅니다. 그래서 "version":"1"처럼 JSON type이 writer와 달라도 통과하며 schema·timestamp·payload의 unquoted text도 변환 가능하면 통과할 수 있습니다. 마지막 field 뒤 trailing comma도 현재 loop가 허용합니다.

source comment의 “reordered fields를 거절한다”는 설명도 실제 코드와 일치하지 않습니다. parser는 LinkedHashMap에 읽지만 key set equality만 비교합니다. 같은 네 field를 재배열한 object는 통과합니다. writer가 canonical bytes를 만든다는 사실, reader가 exact field set과 변환 가능성을 확인한다는 사실, reader가 canonical JSON까지 강제한다는 주장은 서로 다릅니다.

Schema evolution을 적용하는 순서

RedisPayloadCodec은 stored version을 decodePayload에 넘깁니다. 따라서 호환 변경은 다음 배포 순서를 취할 수 있습니다.

  1. reader가 old version과 next version을 모두 canRead하도록 배포합니다.
  2. 실제 decode가 version별 payload를 처리하도록 합니다.
  3. writeVersion을 next version으로 올린 writer를 배포합니다.
  4. old data의 TTL·migration 조건을 확인한 뒤 old reader 제거를 검토합니다.

이 순서는 API가 허용하는 패턴이지 자동 migration 구현이 있다는 뜻은 아닙니다. registry나 codec에는 stored data backfill, read-repair, dual-write, version usage metric이 없습니다.

정상·실패 분기와 failure metadata

정상

  • registered schema와 요청한 Java type이 일치합니다.
  • envelope schema가 payload codec schema와 같습니다.
  • stored version을 canRead가 허용합니다.
  • framing과 payload decode가 성공합니다.

lookup 실패

forSchema는 미등록 schema와 잘못된 requested Class<V>IllegalArgumentException으로 거절합니다. generic cast가 나중의 ClassCastException으로 밀리지 않습니다.

등록 단계도 같은 schema id의 두 구현을 허용하지 않습니다. putIfAbsent 검사는 두 번째 등록이 같은 payload codec인지 비교해 합치지 않고 즉시 실패합니다. 따라서 schema id 하나가 배포 안에서 어느 decoder를 뜻하는지 모호해지지 않습니다. 다만 서로 다른 배포가 같은 schema id를 다른 의미로 등록하는 문제까지 중앙에서 탐지하는 registry는 아닙니다. 그 호환성은 golden byte와 교차 version test로 관리해야 합니다.

serialization 실패

다른 schema, unreadable version, oversized bytes, framing 오류는 모두 RedisSerializationException입니다. 다만 metadata의 deployment mode 경로는 같지 않습니다. VersionedJsonCodec이 직접 만드는 size/schema/version failure는 failure factory를 거쳐 bound deployment mode를 넣습니다.

반면 decode의 framing 호출JsonEnvelopeFraming.read가 던진 failure를 다시 감싸지 않습니다. framing 쪽 serializationFailure는 deployment mode를 STANDALONE으로 고정합니다. Cluster에 bound된 codec이라도 malformed framing이면 metadata가 현재 STANDALONE을 보고합니다.

stored data corruption은 retryable도 ambiguous도 아닙니다. 같은 byte를 다시 decode해도 성공할 근거가 없으므로 read라는 이유만으로 retryable로 표시하지 않습니다.

테스트가 고정하는 계약

registry 테스트는 declared type lookup, wrong type의 lookup-time 거절, unregistered schema 거절을 각각 고정합니다.

versioned codec 테스트도 계약별 시작 행이 다릅니다.

reordered field, 잘못된 JSON value type, trailing comma 거절 test는 없습니다. framing failure 테스트는 retryable만 검사하고 deployment mode는 검사하지 않습니다. 이번 문서 작업에서 테스트를 실행하지 않았고 production source와 test를 정적으로 대조했습니다.

Golden byte가 의미하는 범위

golden file은 envelope framing, field spelling/order, timestamp rendering, Base64 payload를 한 사례로 고정합니다. payload codec의 모든 version 호환성을 자동으로 증명하지는 않습니다.

payload representation을 바꾸려면 새 golden fixture와 old-version read test가 필요합니다. 기존 golden file을 새 writer output으로 덮어쓰는 것만으로는 backward compatibility를 증명할 수 없습니다.

현재 구현 공백과 잘못 읽기 쉬운 지점

  1. RedisCodecRegistry, payload codec 등록, typed key 조립의 production bean은 확인되지 않습니다.
  2. aggregate RedisOperations production facade도 확인되지 않으므로 registry가 application path에 실제 연결됐다고 단정할 수 없습니다.
  3. framing writer는 field order와 JSON value type을 고정하지만 reader는 reordered field, 변환 가능한 잘못된 JSON value type, trailing comma를 허용합니다. strict/canonical JSON parser가 아니며 class comment와 구현도 drift했습니다.
  4. framing parser failure metadata는 bound deployment mode 대신 STANDALONE을 hard-code합니다. 현재 테스트는 corrupt framing의 topology를 고정하지 않습니다.
  5. 자동 migration, read-repair, dual-write, stored version inventory는 없습니다.
  6. createdAt은 compatibility framing의 일부지만 expiry나 freshness를 자동 판단하지 않습니다.
  7. built-in string/number/bytes codec은 versioned object envelope와 다른 wire format입니다.

다음에 source를 열 때는 RedisPayloadCodec, RedisEnvelope, framing, VersionedJsonCodec, registry, golden test 순으로 보면 됩니다.

시리즈의 관련 문서

관련 범위는 keyspace, typed operations, execution failure certainty입니다.

시리즈에서 이어 읽기