이벤트 스키마 진화 — 옛 이벤트를 어떻게 읽을까
2025-10-15
order.placed 이벤트에 "유입 경로(channel)" 필드를 하나 더하려던 참이었습니다. 그런데 코드를 고치기 직전에 문득 멈췄습니다. 스트림에 이미 쌓여 있는 옛 order.placed 들은 어떻게 되지?
이게 이벤트 시스템의 독특한 지점입니다. 데이터베이스라면 ALTER TABLE 로 스키마를 바꾸고 기존 행을 마이그레이션하면 됩니다. 그런데 이벤트는 불변의 기록 입니다. JetStream 스트림은 추가만 되는(append-only) 로그라, 과거 이벤트를 고쳐 쓸 수 없습니다. 게다가 우리 읽기 모델은 7편에서 봤듯 시작할 때 스트림의 과거를 전부 재생 해 뷰를 재구축합니다. 즉, 옛 모양의 이벤트를 미래의 소비자가 계속 읽어야 합니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-23태그에 있습니다.
변경에는 두 종류가 있습니다
- 더하기(additive): 필드를 추가한다. → 대개 안전 합니다.
- 깨는(breaking): 필드를 지우거나, 이름·타입·의미를 바꾼다. → 위험 합니다.
이 둘을 완전히 다르게 다뤄야 합니다.
더하기 변경 — 관용적 리더로 그냥 소화
channel 을 추가하는 건 더하기 변경입니다. 여기엔 사실 아무 장치도 필요 없습니다. Postel 의 법칙 — "보낼 땐 엄격하게, 받을 땐 너그럽게" — 을 따르는 관용적 리더(tolerant reader) 면 충분합니다. Go 의 json.Unmarshal 이 바로 그렇습니다.
- 모르는 필드는 무시 합니다. 그래서 옛 소비자가 새 필드가 붙은 이벤트를 받아도 그냥 넘어갑니다.
- 빠진 필드는 zero-value 가 됩니다. 그래서 새 소비자가 옛 이벤트(channel 없음)를 읽으면
channel은""이 됩니다.
// 새 필드(channel)가 붙은 이벤트를, 그 필드를 모르는 옛 소비자가 읽는다 → 그냥 무시.
var oldConsumer struct{ OrderID string `json:"order_id"` }
json.Unmarshal([]byte(`{"order_id":"o1","channel":"app"}`), &oldConsumer) // 문제 없음
그래서 channel 추가는 도메인 이벤트에 필드를 얹고, 재고 서비스는 그냥 무시(원래 자기가 쓸 필드만 읽음), 읽기 모델은 새로 읽어 뷰에 담습니다. 기존 소비자를 하나도 안 건드립니다.
함수 시그니처도 안 깨고 얹었습니다.
PlaceOrder에 채널을 가변 인자 로 더해, 기존 9곳의 호출부는 한 줄도 안 바꿨습니다. "받을 땐 너그럽게" 는 코드 API 에도 통합니다.func PlaceOrder(id OrderID, customerID CustomerID, lines []OrderLine, channel ...string) (*Order, error)
철칙: 더하기 변경만 "그냥" 해도 됩니다. 필드 삭제·이름 변경·타입 변경은 절대 조용히 하면 안 됩니다 — 그건 깨는 변경입니다.
깨는 변경 — 버전과 업캐스팅
언젠가는 정말 모양을 바꿔야 할 때가 옵니다. 필드 이름을 바꾸거나, 총액: 숫자 를 총액: {금액, 통화} 로 구조화하거나. 이때는 버전 을 도입합니다. 봉투에 스키마 버전을 싣고, "옛 버전 → 새 버전"으로 바꿔 주는 업캐스터(upcaster) 를 등록합니다.
type Envelope struct {
Name string `json:"name"`
SchemaVersion int `json:"schema_version,omitempty"` // 0/누락 = v1
Data json.RawMessage `json:"data"`
// ...
}
핵심 아이디어는 "쓸 때가 아니라 읽을 때 변환한다" 입니다. 이벤트는 불변이라 스트림을 고쳐 쓸 수 없으니, 소비 직전에 옛 이벤트를 최신 모양으로 끌어올립니다. 그러면 핸들러는 언제나 최신 모양 하나만 상대하면 됩니다.
// order.placed v1 → v2: v1 이벤트엔 channel 이 없다. 없으면 "web" 으로 정규화한다.
eventbus.RegisterUpcaster("order.placed", 1, func(e Envelope) Envelope {
var m map[string]json.RawMessage
json.Unmarshal(e.Data, &m)
if _, ok := m["channel"]; !ok { // 멱등: 이미 있으면 안 건드림
m["channel"], _ = json.Marshal("web")
e.Data, _ = json.Marshal(m)
}
return e
})
소비할 때 upcast 가 등록된 업캐스터를 버전 순서대로 적용해, 봉투를 최신까지 끌어올립니다.
func upcast(env Envelope) Envelope {
v := env.SchemaVersion
if v == 0 { v = 1 } // 버전 없는 옛 이벤트는 v1 로 본다
for {
fn, ok := upcasters[env.Name][v]
if !ok { break }
env = fn(env) // v → v+1
v++
env.SchemaVersion = v
}
return env
}
발행할 때는 최신 버전을 찍고, 소비할 때는 이 upcast 를 핸들러 직전에 한 번 돌립니다. 그러면:
- 새 이벤트(v2, channel 있음) → 업캐스터가 건드릴 게 없어 그대로 통과.
- 옛 이벤트(스트림에 남은 v1, channel 없음) → 재생될 때 업캐스터가
channel="web"을 채움.
읽기 모델을 재시작해 과거를 재생해도, 옛 주문이든 새 주문이든 뷰에는 늘 channel 이 채워집니다.
정직하게 — 이 길의 규칙과 함정
- 업캐스터는 (거의) 못 지웁니다. 옛 이벤트는 스트림에 영원히 남으니, v1→v2 변환도 영원히 필요합니다. 버전을 거듭하면 v1→v2→v3 체인 이 쌓입니다. 그래서 애초에 깨는 변경을 아끼는 게 최선입니다.
- 업캐스터는 멱등하게. 이미 새 필드가 있으면 손대지 않게 합니다. 그래야 발행측이 이미 v2 로 찍은 이벤트를 두 번 변환하는 사고가 없습니다.
- 배포 순서. 새 필드를 읽는 소비자를 먼저 배포하고, 그 필드를 쓰는 생산자를 나중에 배포합니다. 반대로 하면, 아직 준비 안 된 소비자에게 새 이벤트가 먼저 닿습니다.
- 정말 큰 변경은 새 이름으로. 도저히 한 이벤트로 못 담을 변화라면,
order.placed.v2처럼 새 이벤트를 병행 발행 하며 소비자를 점진 이관하는 편이 낫습니다. - 우리는 가볍게 갔습니다. JSON + 관용적 리더 + 업캐스터. 규모가 커지면 Avro·Protobuf 에 스키마 레지스트리 를 붙여 "호환되지 않는 변경"을 배포 단계에서 아예 막는 길도 있습니다.
정리 — 이벤트는 영원하다는 전제로 설계하기
- 이벤트는 불변의 기록이라, DB 처럼 고쳐 쓸 수 없고 옛 모양이 영원히 재생 됩니다.
- 더하기 변경 은 관용적 리더(모르는 필드 무시·빠진 필드 기본값)로 그냥 소화합니다 —
channel추가가 그 예입니다. - 깨는 변경 은 버전 + 업캐스팅으로 다룹니다 — 옛 이벤트를 읽을 때 최신 모양으로 끌어올려, 핸들러는 늘 하나의 모양만 봅니다.
- 업캐스터는 멱등하게, 잘 안 지우고, 배포 순서는 소비자 먼저.
핵심 한 줄 — 이벤트는 영원하다는 전제로 설계한다. 오늘 쓴 이벤트를 10년 뒤의 코드가 읽어야 할 수도 있으니, "지금 편한 모양"이 아니라 "두고두고 읽을 모양"을 고릅니다.
이번 편 전체 코드는 리포의
part-23태그에 있습니다.