이벤트 스키마 진화 — 옛 이벤트를 어떻게 읽을까

2025-10-15

order.placed 이벤트에 "유입 경로(channel)" 필드를 하나 더하려던 참이었습니다. 그런데 코드를 고치기 직전에 문득 멈췄습니다. 스트림에 이미 쌓여 있는 옛 order.placed 들은 어떻게 되지?

이게 이벤트 시스템의 독특한 지점입니다. 데이터베이스라면 ALTER TABLE 로 스키마를 바꾸고 기존 행을 마이그레이션하면 됩니다. 그런데 이벤트는 불변의 기록 입니다. JetStream 스트림은 추가만 되는(append-only) 로그라, 과거 이벤트를 고쳐 쓸 수 없습니다. 게다가 우리 읽기 모델은 7편에서 봤듯 시작할 때 스트림의 과거를 전부 재생 해 뷰를 재구축합니다. 즉, 옛 모양의 이벤트를 미래의 소비자가 계속 읽어야 합니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-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 태그에 있습니다.