Backend · Reliability

버그가 돌아왔다,
다섯 개의 다른 가면을 쓰고

벤치마크 과제가 이벤트에 구독자가 둘 필요해진 첫 순간 5개 언어 중 2개가 크래시하거나 조용히 핸들러를 떨어뜨린다는 걸 찾아낸 지 일주일 뒤, 실제 프로덕션 기능 4개가 살아있는 핸들러를 가진 모든 이벤트에 두 번째 핸들러를 필요하게 만들었다. 이번엔 5개 언어 전부가 깨졌다 — 이전에 깨끗하다고 통과했던 둘까지 포함해서 — 각자 진짜로 다른 방식으로. 가장 시끄러운 것부터 가장 조용한 것까지 순서대로 정리한다.

그 주에 5개 언어 전부에 걸쳐 기능 4개가 배포됐다: 지난 몇 달의 히스토리로 만드는 지출 예측, 가맹점명 기반 거래 자동 분류, 알림만 보낼 뿐 절대 막지 않는 출금 이상 알림, 분석 엔드포인트에 데이터를 공급하는 환불 사유 분류. 전부 Account Bounded Context에서 MoneyWithdrawn에 반응해야 했다 — 그 주 전까지 이 저장소 전체에서 핸들러가 하나를 넘어본 적이 한 번도 없던 이벤트였다. 기능 4개는 곧 5개 언어 전부가 실제로 배포되는 코드에서 처음으로 같은 이벤트에 동시 구독자 둘 셋을 실제로 지원해야 한다는 뜻이었다.

이건 이미 한 번 일어났었다

의도적으로 설계된 벤치마크 과제가 일주일 전에 정확히 이 실패를 찾아낸 적이 있다 — 두 개의 Bounded Context가 같은 이벤트를 구독하자 java와 fastapi의 핸들러 맵이 하나 이상을 담지 못한다는 게 드러났고, 그때 kotlin의 수정은 진짜 수정이 아니라 임시방편으로 기록됐다. go와 nestjs는 깨끗하다고 선언됐다. 이번 라운드는 같은 형태의 요구가 실제로 5개 언어 전부를 다시 덮쳤을 때 무슨 일이 일어났는지에 관한 것이고, 그중 어느 것도 믿었던 것만큼 깨끗하지 않았다.

가장 시끄러운 것부터 가장 조용한 것까지

java의 핸들러 맵은 Collectors.toMap(...)으로 만들어져 있었다 — 이미 사용 중인 이벤트 타입에 두 번째 핸들러를 등록하면 IllegalStateException: Duplicate key를 던진다, 그것도 애플리케이션 부팅 시점에, 두 번째 핸들러 빈이 등록되는 바로 그 순간. 겉보기엔 가장 시끄러운 실패지만 어떤 의미에선 가장 안전하기도 하다 — 조용히 고장난 채로 배포되는 게 아니라, 고쳐질 때까지 앱이 그냥 부팅을 거부한다. Collectors.groupingBy로 만든 새 OutboxEventDispatcher가 이를 대체했다.

kotlin의 라우팅 테이블은 평범한 mapOf(...) 리터럴이었다. kotlin map 리터럴의 중복 키는 던지지도, 경고하지도 않는다 — 조용히 마지막에 쓰인 항목만 남긴다. 크래시도, 로그 한 줄도, 핸들러가 떨어졌다는 어떤 신호도 없다. 다섯 변종 중 아마도 가장 위험한데, 정확히 아무것도 스스로를 알리지 않기 때문이다. 이건 이전 벤치마크 라운드가 정확히 지적했지만 명시적으로 고치지 않은 채로 남겨뒀던 바로 그 갭이다. 이제 groupBy를 통해 Map<String, List<...>>로 재구성되어 고쳐졌다.

fastapi의 build_event_handlers()는 똑같은 형태에 똑같은 실패였다 — 평범한 dict 리터럴, 조용한 덮어쓰기, 어디에도 에러 없음. 같은 방식으로 고쳐졌다: dict[str, list[EventHandlerFn]].

go는 애초에 타입 레벨에서 이벤트당 핸들러 하나 이상을 지원한 적이 없었다 — map[string]outbox.Handler, 엄격하게 1:1. 벤치마크 라운드에서는 이게 진짜로 고쳐진 게 아니라 우회됐다: 진짜 두 번째 등록이 있어야 할 자리에 인라인으로 두 번째 호출을 추가하는 식으로. 이번엔 진짜 수정을 받았다 — map[string][]outbox.HandlerrunHandlers 함수.

nestjs가 가장 흥미로웠다. 다들 믿을 이유가 있던 쪽이었기 때문이다. 레지스트리는 이미 올바른 형태였다 — Map<string, EventHandlerFn[]> — 핸들러 여러 개를 담을 수 있는지는 애초에 의문의 여지가 없었다. 깨진 건 디스패치 루프 자체였다: 이벤트 타입에 대한 핸들러들을 순회하다가 던진 첫 번째 핸들러에서 멈춰버려서, 실패하는 첫 핸들러가 그 뒤에 등록된 모든 핸들러가 아예 한 번도 실행되지 못하게 조용히 막고 있었다. 타입 시스템은 이게 괜찮다고 말했다. 타입 시스템의 그 무엇도 이걸 잡을 수 없었다 — 버그는 구조가 뭘 담을 수 있느냐에 있지 않았다, 실제로 순회할 진짜 핸들러 둘이 존재했을 때 루프가 실제로 뭘 했느냐에 있었다:

public async handle(eventType: string, payload: object): Promise<void> {
  const errors: unknown[] = []
  for (const handler of this.handlers.get(eventType) ?? []) {
    try {
      await handler(payload)
    } catch (error) {
      this.logger.error({ message: 'A handler failed for eventType', event_type: eventType, error })
      errors.push(error)
    }
  }
  if (errors.length > 0) throw errors[0]
}

이제 모든 핸들러는 이전 핸들러의 실패와 무관하게 실행되고, 각 실패는 자기만의 로그 줄을 갖고, 메시지는 모든 핸들러가 자기 차례를 마친 뒤에만 — ack되지 않은 채 남아 재전달되도록 — 던져진다.

다섯 개를 전부 잡아낸 실제 방법

다섯 개 모두 같은 방식으로 잡혔다: 새 기능을 위해 작성된 e2e 테스트가 기존 핸들러도 여전히 실행되는지까지 함께 단언했다. "내 새 핸들러가 실행되는가"뿐 아니라 — "옛 핸들러도, 이제 동료가 생겼는데도, 여전히 실행되는가." 이게 일반화되는 구체적이고 반복 가능한 원칙이다: 이미 하나를 갖고 있는 이벤트에 두 번째 구독자를 추가한다는 건, 테스트 스위트의 임무가 더 이상 새 경로가 동작하는지 확인하는 것만이 아니라, 새 경로가 옛 경로를 조용히 망가뜨리지 않았는지까지 확인하는 것이라는 뜻이다.

같은 근본 원인의 더 작은 두 번째 버전

버그 하나가 더 같은 상황에서 나왔다 — 두 핸들러가 처음으로 같은 이벤트에 정당하게 반응하는 상황. SES 알림 멱등성 원장은, kotlin과 fastapi 둘 다에서, 이벤트 ID만으로 중복을 제거하고 있었다 — Outbox 전달 한 번이 이메일 최대 한 통을 만든다는 가정이었다. 이건 같은 MoneyWithdrawn 이벤트에 대한 두 핸들러가 각각 진짜로 다른 이메일을 보내야 하는 순간 — 이상 알림과 출금 완료 안내 — 깨졌다. 두 번째 이메일이 첫 번째와 조용히 같은 걸로 취급돼 중복 제거됐다. 중복 제거 키를 이벤트 ID 하나에서 (이벤트 ID, 이벤트 타입) 쌍으로 넓혀서 고쳤다.

5개 언어, 5개의 서로 다른 실패 모양, 원인은 하나 공유 — 모든 구현체가 이미 갖고 있다고 가정했던 능력이, 사실은 한 번도 요구받은 적이 없었을 뿐이었다. "이미 이걸 올바르게 처리한다"는 결국 아무도 둘로 실제로 돌려본 적 없는 코드에 대한 주장이었을 뿐이다.

저장소 내 추가 자료

event-handler-registry.ts — 이전 실패와 무관하게 모든 핸들러를 실행하도록 고쳐진 디스패치 루프 · 구독자가 둘이어야만 존재하던 버그 — 이걸 처음 찾아낸 벤치마크 라운드