콘텐츠로 이동

[운영 확장 7] 예약 결과가 외부 시스템과 통계로 전달되는 과정

예약 아웃박스에서 외부 consumer와 통계 projection으로 갈라지는 운영 흐름을 보여 주는 3D workbench
예약 서비스가 확정한 사실은 한 번 기록하고, 알림과 통계는 각자의 처리 결과로 다시 확인합니다.

예약이 확정되었다는 사실과 그 결과가 다른 시스템에 전달되었다는 사실은 같은 문장이 아닙니다. 예약 서비스가 CONFIRMED 상태를 커밋했더라도 알림 consumer가 아직 처리하지 않았을 수 있고, 통계 projection이 최신 버전까지 반영하지 않았을 수도 있습니다. 반대로 통계 projection이 늦었다고 예약 자체를 다시 저장하거나 변경해서도 안 됩니다.

이 글의 결론은 다음과 같습니다.

예약 변경과 메시지 의도는 한 트랜잭션에서 기록하고, 커밋된 의도는 아웃박스(outbox)와 relay를 통해 전달합니다. 알림 consumer와 통계 consumer는 서로 다른 consumer group으로 같은 사실을 각자의 목적에 맞게 처리합니다. 중복은 consumer inbox에서 흡수하고, 통계 projection이 늦을 때 STAFF 화면은 현재 예약 집계를 기준 데이터 원본으로 확인합니다. 모든 실패는 최종 상태 결정에서 PROCESSED, RETRY, QUARANTINED, REPLAY_PENDING 중 하나로 구분해야 합니다.

아래 다이어그램과 운영 화면은 실제 병원 지표나 환자 정보를 담은 캡처가 아니라, 현재 구현을 설명하기 위한 합성 시안입니다. 본문에서는 현재 구현, 승인된 설계, 실제 운영 rollout을 서로 구분합니다.

예약 변경과 메시지 의도는 한 트랜잭션에 기록한다

섹션 제목: “예약 변경과 메시지 의도는 한 트랜잭션에 기록한다”

외부 시스템에 직접 HTTP 요청을 보내면서 예약 상태를 바꾸면 네트워크 호출이 예약의 원자성 경계 안으로 들어옵니다. 외부 시스템이 느리거나 응답을 잃으면 예약 커밋도 늦어지고, 재시도 시 같은 결과를 두 번 전달할 위험도 생깁니다.

현재 설계는 먼저 예약 변경과 전달 의도를 같은 데이터베이스 트랜잭션에 기록합니다.

  1. 예약 서비스가 상태 변경과 권한, 병원 범위를 검증합니다.
  2. 예약 aggregate를 변경하고, scheduling_outbox_events에 최소 이벤트 행을 함께 기록합니다.
  3. 이벤트 행에는 불변 eventId, eventVersion, aggregate 식별자와 재전송에 필요한 메타데이터만 남깁니다.
  4. 트랜잭션이 커밋된 뒤에야 별도 relay가 메시지 브로커를 호출합니다.

따라서 커밋 직후 애플리케이션이 재시작해도 예약 확정 사실과 전달 의도가 함께 남습니다. 반대로 relay가 잠시 멈췄다고 예약을 취소하거나 예약 aggregate를 다시 쓰지 않습니다. relay의 책임은 이미 커밋된 의도를 전달하는 것이고, 예약 서비스의 책임은 예약 사실을 보존하는 것입니다.

아웃박스가 커밋을 전달 가능한 사실로 바꾼다

섹션 제목: “아웃박스가 커밋을 전달 가능한 사실로 바꾼다”

아웃박스는 “언젠가 발행할 수도 있는 로그”가 아니라 예약 트랜잭션과 함께 커밋된 전달 의도입니다. relay는 만료된 lease를 조건부로 선점하고 fencing 정보를 확인한 뒤 Kafka에 발행합니다. 데이터베이스 트랜잭션 안에서 Kafka I/O를 수행하지 않으므로, 브로커 지연이 예약 명령의 커밋을 붙잡지 않습니다.

이 경계는 exactly-once를 약속하지 않습니다. relay가 메시지를 발행한 뒤 응답을 받기 전에 멈추면 같은 불변 eventId가 다시 발행될 수 있습니다. 대신 다음 계약을 지킵니다.

  • lease와 fencing으로 동시에 같은 outbox 행을 처리하는 워커를 제한합니다.
  • 발행 이벤트에는 eventIdeventVersion을 포함해 재전송을 식별합니다.
  • 소비자는 중복을 정상적인 입력으로 보고 inbox와 처리 트랜잭션에서 한 번만 반영합니다.
  • 브로커, schema registry, 실제 운영 SLO는 별도 rollout 증거가 생기기 전까지 준비 대기 상태로 둡니다.
예약 transaction이 scheduling_outbox_events에 기록되고 Kafka relay와 strict JSON Schema gate를 지나 알림 consumer와 통계 consumer로 분기된 뒤 consumer inbox, handler transaction, 최신 통계 projection, 현재 예약 집계, STAFF 조치 큐, 최종 상태 결정과 네 가지 결과로 이어지는 rounded architecture flow
알림과 통계 consumer를 같은 처리 결과로 그리지 않았습니다. projection이 완전할 때와 그렇지 않을 때의 대시보드 경계도 분리하고, 네 가지 결과 분기는 명시적인 최종 상태 결정 카드에서 시작합니다.

다이어그램의 scheduling_outbox_events는 예약 트랜잭션의 일부이고, Kafka 4 relay는 lease·fencing으로 발행을 조정합니다. strict JSON Schema gate를 통과한 이벤트만 consumer가 읽습니다. 연결선은 어느 카드의 경계에서 출발해 어느 카드로 들어가는지 보이도록 각각 둥근 모서리로 그렸습니다. 하나의 선을 여러 경로가 공유하지 않기 때문에 알림과 통계의 책임을 잘못 합쳐 읽을 위험도 줄어듭니다.

알림과 통계 consumer는 같은 이벤트를 서로 다른 목적으로 읽는다

섹션 제목: “알림과 통계 consumer는 같은 이벤트를 서로 다른 목적으로 읽는다”

알림 consumer와 통계 consumer는 같은 예약 사실을 읽지만 결과의 의미가 다릅니다.

consumer소유하는 결과실패했을 때 바꾸지 않는 것STAFF가 확인할 값
알림 consumer발송 요청과 전달 결과예약 상태와 상품 계약전달 상태, 사유 코드, 다음 작업
통계 consumer날짜·상태별 집계 projection예약 aggregate의 기준 데이터projection 버전, 집계 반영 여부

두 consumer를 하나의 group으로 합치면 알림 처리량이나 일시적인 실패가 통계 갱신을 막을 수 있습니다. 현재 설계는 consumer group을 분리하고, 각 consumer가 자신의 logicalConsumerId를 사용해 inbox 중복 키를 계산합니다. 같은 eventId라도 알림과 통계는 서로 독립적으로 처리할 수 있습니다.

통계 consumer는 이벤트를 받았다는 사실만으로 현재 예약 상태를 덮어쓰지 않습니다. aggregate lock을 얻고, 저장된 eventVersion보다 새 버전일 때만 날짜와 상태 bucket을 이동합니다. 늦게 도착한 오래된 이벤트는 projection을 되돌리지 않습니다.

중복은 inbox에서 흡수하고, 계약 오류는 격리한다

섹션 제목: “중복은 inbox에서 흡수하고, 계약 오류는 격리한다”

consumer runtime은 메시지를 받자마자 외부 부작용을 실행하지 않습니다. 먼저 (logicalConsumerId, logicalStreamId, eventId) 조합을 inbox에서 확인하고, 처리 결과와 side effect를 같은 handler transaction에 기록합니다.

처리 결과는 다음처럼 나뉩니다.

  • 이미 처리한 이벤트면 PROCESSED로 다시 확인하고 부작용을 반복하지 않습니다.
  • 일시적인 데이터베이스·브로커 오류면 제한된 횟수와 시간 안에서 RETRY로 보냅니다.
  • schema 버전, 범위, 필수 메타데이터가 맞지 않으면 원본 payload를 저장하지 않고 메타데이터만 QUARANTINED로 남깁니다.
  • 승인된 범위의 재처리라면 바로 실행하지 않고 REPLAY_PENDING으로 조치 큐에 올립니다.

여기서 격리는 장애 원문을 운영 화면에 복사하는 기능이 아닙니다. schemaVersion, eventId fingerprint, consumer, 사유 코드, 최초·최근 시각처럼 안전한 메타데이터만 남겨 STAFF가 무엇을 확인해야 하는지 알 수 있게 합니다. 환자 이름, 연락처, 예약 메모, raw payload, stack trace는 조치 큐에 표시하지 않습니다.

통계 projection은 최신 상태만 남겨야 한다

섹션 제목: “통계 projection은 최신 상태만 남겨야 한다”

예약 상태나 방문 날짜가 바뀌면 통계 집계는 기존 bucket에서 빠지고 새 bucket으로 이동해야 합니다. 단순히 새 이벤트가 도착할 때마다 count + 1을 하면 재전송이나 순서 역전이 발생했을 때 숫자가 부풀어 오릅니다.

현재 projection 흐름은 다음과 같습니다.

  1. 통계 consumer가 schema와 병원 범위를 확인합니다.
  2. inbox에서 이벤트 중복 여부를 확인하고 aggregate별 lock을 얻습니다.
  3. 저장된 eventVersion이 새 버전보다 낮을 때만 이전 날짜·상태 bucket을 감소시키고 새 bucket을 증가시킵니다.
  4. projection 행과 inbox 처리 결과를 같은 트랜잭션으로 커밋합니다.
  5. 같은 이벤트가 다시 도착하면 최신 버전 비교에서 멈추고 수치를 다시 더하지 않습니다.

이 projection은 빠른 대시보드 지표를 위한 읽기 모델입니다. 예약의 현재 상태를 결정하는 원본이 아닙니다. projection이 없거나 마지막 반영 시각이 유효하지 않으면 STAFF 화면은 Appointments repository에서 현재 예약 집계를 다시 읽어야 합니다.

대시보드는 projection이 아니라 기준 데이터 원본을 확인해야 한다

섹션 제목: “대시보드는 projection이 아니라 기준 데이터 원본을 확인해야 한다”

운영자가 가장 먼저 물어보는 것은 “통계 숫자가 몇 건인가?”가 아니라 “이 예약을 지금 어떤 기준으로 처리해야 하는가?”입니다. 따라서 화면은 projection과 현재 예약 집계를 같은 의미의 카드로 나열하지 않습니다.

  • projection이 완전하고 최신이면 빠른 지표와 추세를 보조 정보로 사용합니다.
  • projection이 비어 있거나 지연되면 현재 예약 집계를 기준 데이터 원본으로 표시합니다.
  • 현재 예약 집계와 projection의 버전이 다르면 조치 큐에 “projection 미완료”를 표시합니다.
  • 재처리나 백필은 자동으로 예약 상태를 바꾸지 않고, 승인 전 dry-run 결과로 영향 범위를 보여 줍니다.

아래 시안은 STAFF가 상단 지표, 조치 큐, 선택 항목의 판단 근거, projection과 기준 데이터 원본의 차이를 한 화면에서 확인하는 예입니다.

이벤트 대기, 처리 중, 재시도 대기, 격리·검토 필요 지표와 예약 결과 조치 큐, 선택 항목의 scope·schemaVersion·eventVersion·projection 상태, 현재 예약 집계·통계 projection·재처리 백필 경계를 보여 주는 STAFF 운영 대시보드 시안

합성 데이터로 만든 운영 화면 시안입니다. 숫자와 이벤트 식별자는 설명을 위한 예시이며, 원본 payload나 개인정보를 표시하지 않습니다. 조치 큐의 각 조치 메시지(작업 요청)는 상태와 사유 코드뿐 아니라 STAFF가 다음에 할 작업까지 함께 보여 줍니다.

조치 큐의 상세에는 scope, schemaVersion, eventVersion, projection 반영 여부, 기준 데이터 원본, 다음 작업만 노출합니다. 이 정도면 STAFF는 “재시도해도 되는가”, “계약 오류를 먼저 확인해야 하는가”, “현재 예약 집계를 기준으로 환자에게 안내해야 하는가”를 판단할 수 있습니다. 원본 메시지를 그대로 보여 주는 것은 이해를 돕기보다 개인정보와 운영 비밀을 함께 노출하는 일이 됩니다.

재시도·격리·재처리·백필을 한 큐에 섞지 않는다

섹션 제목: “재시도·격리·재처리·백필을 한 큐에 섞지 않는다”

네 가지 결과는 모두 실패처럼 보이지만 다음 작업이 다릅니다.

결과의미STAFF의 다음 작업자동으로 하지 않는 일
PROCESSEDhandler와 inbox 기록이 완료됨필요하면 결과만 다시 조회side effect를 다시 실행하지 않음
RETRY일시 오류로 아직 종료하지 않음다음 시각과 영향 범위 확인무제한 즉시 재시도
QUARANTINEDschema·범위·필수 메타데이터 오류계약 담당자와 격리 사유 확인raw payload를 다시 발행
REPLAY_PENDING승인된 재처리 후보dry-run 결과 확인 후 승인예약 상태를 자동으로 변경

재처리는 “실패한 이벤트를 다시 던지는 버튼”이 아닙니다. 원래 이벤트의 불변 식별자, 대상 consumer, 범위, 예상 side effect를 먼저 확인하고, 승인된 runId와 감사 기록을 남긴 뒤에 실행해야 합니다. 백필도 같은 규칙을 따릅니다. 특정 날짜나 병원 범위의 projection을 다시 만드는 작업과 현재 예약 aggregate를 변경하는 작업은 서로 다른 명령입니다.

CRM, 결제, 상품, 환불 같은 보상 처리는 이 예약 서비스의 책임이 아닙니다. 외부 시스템이 보상이나 환불을 수행해야 한다면 해당 서비스가 자기 계약에 따라 별도의 이벤트와 조치 큐를 소유해야 합니다. 예약 결과 전달이 성공했다는 이유로 예약 서비스가 상품 금액이나 환불 상태를 임의로 변경하지 않습니다.

현재 구현과 운영 rollout 대기를 구분한다

섹션 제목: “현재 구현과 운영 rollout 대기를 구분한다”
구분이 글에서 말하는 범위
현재 구현예약 변경과 outbox 의도의 원자적 기록, relay lease·fencing, strict schema gate, 독립 consumer group, inbox 중복 제거, 최신 버전 통계 projection, 기준 데이터 원본 fallback
승인된 설계메타데이터만 남기는 격리, 승인 기반 replay·backfill, projection 잠금과 날짜·상태 bucket 이동, STAFF 조치 큐의 안전한 필드
운영 화면 시안대기·처리·재시도·격리 지표, consumer별 조치 큐, 선택 항목의 판단 근거, projection과 현재 예약 집계의 경계. 실제 병원 지표가 아님
rollout 대기실제 Kafka broker, schema registry, MySQL, 외부 consumer의 운영 SLO와 장애 훈련 증거

마지막 행을 현재 운영이 완료되었다고 읽으면 안 됩니다. 소스와 설계는 메시지를 안전하게 재처리할 경계를 설명하지만, 실제 운영 전환은 별도 환경에서 broker·registry·DB·consumer의 관측 지표와 복구 훈련을 검증해야 합니다.

STAFF가 조치 큐의 한 항목을 닫기 전에 확인할 다섯 가지

섹션 제목: “STAFF가 조치 큐의 한 항목을 닫기 전에 확인할 다섯 가지”
  1. 예약 aggregate와 outbox 의도가 같은 트랜잭션에서 커밋되었는가?
  2. 이 항목이 알림 consumer인지 통계 consumer인지, 그리고 해당 consumer의 inbox에서 이미 처리했는가?
  3. eventVersion이 현재 projection보다 최신인가? 오래된 이벤트가 통계를 되돌리고 있지는 않은가?
  4. 화면의 현재 예약 집계와 projection 중 어느 것이 지금 기준 데이터 원본인가?
  5. 다음 작업이 재시도, 격리 검토, 승인 기반 재처리, 단순 조회 중 무엇인지 명확한가?

이 다섯 가지가 한 화면에 드러나면 “예약은 확정되었는데 외부 시스템과 통계는 어디까지 따라왔는가?”라는 질문에 답할 수 있습니다. 운영 확장은 데이터를 더 많이 보여 주는 일이 아니라, 서로 다른 사실의 경계를 명확히 보여 주고 STAFF가 다음 작업을 안전하게 고르는 일입니다.

아래 링크는 clinic-appointment develop의 f0c7614beed766efc4b88a1a59aa5c370f8fccf7에 고정했습니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.