콘텐츠로 이동

[운영 확장 6] 알림과 리마인더는 왜 별도 서비스인가

알림 아웃박스, STAFF 조치 큐, 리마인더 복구 모듈을 분리한 운영 장치 모형
예약을 확정하는 일과 알림을 전달하는 일은 서로 다른 책임이며, 운영 화면도 그 차이를 드러내야 합니다.

예약 상태가 CONFIRMED로 바뀌었다고 해서 환자에게 알림이 전달된 것은 아닙니다. 반대로 알림 제공자(provider)가 일시적으로 응답하지 않는다고 예약을 취소하거나 다시 저장해서도 안 됩니다. 예약 확정은 예약 서비스가 책임지는 사실이며, 알림 전달은 알림 서비스가 별도로 책임지는 실행 결과입니다.

이 글의 결론은 간단합니다.

예약 트랜잭션은 “알림을 보내야 한다”는 의도만 내구성 있게 기록하고, 알림 서비스는 발송 시점의 회원 프로필과 제공자 결과를 바탕으로 SENT, SUPPRESSED, RETRY_WAIT, EXHAUSTED를 결정합니다. 리마인더가 누락되었을 때도 같은 멱등성 키를 사용해 outbox를 보정할 뿐, 예약 확정 사실을 다시 쓰지 않습니다.

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

예약 확정과 알림 전달은 별개의 결과다

섹션 제목: “예약 확정과 알림 전달은 별개의 결과다”

예약 확정 요청에서 제공자 API까지 호출하면 먼저 응답 시간이 길어집니다. 더 큰 문제는 제공자 호출 결과를 예약 트랜잭션의 결과로 오해한다는 점입니다.

사실소유하는 경계실패해도 바꾸지 않는 것다음에 확인할 것
예약이 확정됨예약 서비스예약 상태와 이력알림 의도가 outbox에 함께 기록되었는지
알림 의도가 기록됨예약 트랜잭션의 outbox예약의 확정 사실워커가 처리할 수 있는지
알림을 발송함알림 서비스와 제공자 채널예약 상태와 상품 계약제공자 결과와 안정적인 사유 코드
리마인더가 누락됨리마인더 복구 스캐너기존 예약과 알림 멱등성 키발송 시각 전인지, 보정할 수 있는지, 이미 기록됐는지

예를 들어 예약 확정 요청이 성공한 직후 제공자 장애가 발생할 수 있습니다. 이때 예약을 실패로 되돌리면 환자는 예약이 사라졌다고 보게 되고, 예약 성공만 기록하면 STAFF는 알림이 실제로 전달됐는지 알 수 없습니다. 두 결과를 분리해 저장하면 예약은 확정된 상태로 남고, 알림은 재시도 대기나 운영 조치 큐에서 관리할 수 있습니다.

예약 트랜잭션은 최소 알림 의도를 기록한다

섹션 제목: “예약 트랜잭션은 최소 알림 의도를 기록한다”

예약 명령은 예약 변경과 최소한의 알림 outbox 행을 같은 트랜잭션으로 커밋합니다. 이때 저장하는 것은 회원 ID, 예약 ID, 알림 종류, 템플릿 키·버전, 리마인더 슬롯, 결정적인 멱등성 키처럼 발송을 다시 계산하는 데 필요한 최소 정보입니다. 렌더링된 본문이나 제공자 페이로드를 예약 테이블에 저장하지 않습니다.

이 경계는 다음 순서로 동작합니다.

  1. 예약 서비스가 상태를 CONFIRMED로 전환할 수 있는지 검증합니다.
  2. 같은 트랜잭션에서 알림 의도와 멱등성 키를 outbox에 기록합니다.
  3. 트랜잭션이 커밋되면 별도 워커가 발송할 수 있는 outbox 행을 찾습니다.
  4. 워커가 데이터베이스 리스(lease)와 펜싱 토큰(fencing token)으로 한 행을 조건부 선점합니다.
  5. 선점한 워커만 회원 프로필 조회, 템플릿 렌더링, 제공자 호출을 순서대로 수행합니다.

이렇게 하면 커밋 직후 애플리케이션이 재시작되어도 예약은 이미 확정되어 있고, 알림 의도는 다시 처리할 수 있습니다. 반대로 예약 트랜잭션 안에서 제공자 API를 호출하면 네트워크 지연·타임아웃·재시도가 예약 명령의 원자성 경계 안으로 들어옵니다.

예약 명령 트랜잭션이 알림 아웃박스에 의도를 기록하고 전달 경로 게이트, 리스와 펜싱 선점, 발송 시점의 회원 프로필 조회, 타입 기반 템플릿 렌더러와 제공자 채널, STAFF의 상태 조회와 리마인더 복구 스캐너를 거쳐 최종 상태 결정에서 SENT, SUPPRESSED, RETRY_WAIT, EXHAUSTED로 나뉘는 시퀀스 도표
예약 확정과 제공자 호출을 하나의 흐름으로 묶지 않았습니다. 각 결과는 명시적인 최종 상태 결정 노드에서 출발해 서로 겹치지 않는 둥근 모서리(rounded-corner) 경로로 나뉘고, STAFF는 상태 코드와 다음 작업을 다시 조회합니다.

다이어그램의 DURABLE 카드는 예약 트랜잭션이 남기는 outbox 경계를 나타냅니다. CLAIM은 여러 워커가 같은 행을 동시에 처리하지 않도록 하는 데이터베이스 선점이고, READ AT SEND는 연락처·언어·동의를 저장해 두었다가 재사용하지 않고 발송 직전에 읽는다는 뜻입니다. 아래쪽의 RECOVERY는 제공자 호출이 아니라 리마인더 생성 누락을 보정하는 별도 흐름입니다.

연락처와 동의는 발송 직전에 읽는다

섹션 제목: “연락처와 동의는 발송 직전에 읽는다”

회원의 전화번호, 이메일, 언어, 알림 동의는 회원 관리 시스템(CRM)이 회원 데이터의 기준 데이터 원본입니다. 알림 outbox가 이 값을 영구적으로 복사해 두면 예약을 만들 때의 낡은 연락처로 알림을 보내거나 이미 철회한 동의를 무시할 수 있습니다.

알림 서비스는 outbox에 회원 식별자와 타입이 지정된 템플릿 매개변수만 보관한 뒤, 실제 발송 직전에 MemberNotificationProfileResolver를 호출합니다.

발송 직전 확인결과STAFF에게 표시할 값
동의가 철회됨SUPPRESSED(CONSENT_DENIED)CONSENT_DENIED, 회원 동의 설정 확인
연락처가 없음SUPPRESSED(DESTINATION_UNAVAILABLE)DESTINATION_UNAVAILABLE, 회원 연락처 확인
회원 범위 불일치억제 또는 검토 대기안정적인 범위 오류 코드
프로필 조회가 일시적으로 실패함횟수를 제한한 재시도재시도 상태와 다음 시각

저장소나 운영 조회 응답에는 이름, 전화번호, 이메일, 렌더링된 제목·본문, 제공자 원문 오류, stack trace를 포함하지 않습니다. STAFF가 “왜 발송하지 않았는지” 이해할 수 있도록 사유 코드와 권장 작업만 제공합니다. 환자에게 보여 주는 조회 응답은 더 좁은 상태 집합만 사용하며, 억제·소진의 내부 원인을 그대로 노출하지 않습니다.

리스(lease)와 펜싱(fencing)은 중복 발송을 줄이는 계약이다

섹션 제목: “리스(lease)와 펜싱(fencing)은 중복 발송을 줄이는 계약이다”

워커는 outbox 행을 찾았다고 바로 제공자를 호출하지 않습니다. 먼저 만료 시각이 있는 리스를 조건부로 획득하고, 그때 발급된 펜싱 토큰을 성공·억제·재시도 저장에 함께 사용합니다. 리스가 만료된 뒤 늦게 돌아온 이전 워커는 더 이상 같은 행을 갱신할 수 없어야 합니다.

실제 제공자 네트워크 호출은 완전히 한 번만 일어난다고 보장할 수 없습니다. 응답이 유실된 순간 워커가 재시도하면 제공자는 첫 요청을 처리했을 수도 있습니다. 따라서 이 설계의 목표는 “정확히 한 번”이라는 문구가 아니라 다음 세 가지입니다.

  • 같은 논리 알림에는 결정적인 제공자 멱등성 키를 사용한다.
  • 리스 한 번에 제공자 호출 횟수와 전체 시도 횟수·경과 시간을 제한한다.
  • 결과를 저장할 때 펜싱 토큰을 검증해 오래된 워커의 기록을 차단한다.

재시도할 수 있는 오류는 RETRY_WAIT로 남기고, 최대 시도 횟수나 최대 경과 시간을 넘으면 EXHAUSTED로 종료합니다. 코루틴 취소는 제공자 오류로 바꿔 재시도하지 않고 그대로 전파합니다. 실패한 원문을 로그에 남기는 대신 카디널리티가 낮은 안정적인 실패 코드와 fingerprint만 보존합니다.

STAFF 화면에는 상태뿐 아니라 다음 작업도 보여 줘야 한다

섹션 제목: “STAFF 화면에는 상태뿐 아니라 다음 작업도 보여 줘야 한다”

알림 운영 화면의 첫 질문은 “오늘 알림이 몇 건인가?”가 아닙니다.

  • 지금 바로 처리할 수 있는 알림은 몇 건인가?
  • 다음 시각까지 기다려야 하는 재시도는 몇 건인가?
  • 동의나 연락처 문제로 억제된 건은 몇 건인가?
  • 자동 재시도가 끝나 담당자에게 넘겨야 하는 건은 몇 건인가?
  • 리마인더 후보 중 아직 발송 시각이 되지 않은 건, 처리 대기 중인 건, 늦어서 억제된 건, 이미 기록된 건은 각각 몇 건인가?

아래 운영 화면은 이 질문에 한 번에 답하도록 상단 지표, 조치 큐, 선택한 항목의 안전한 상세, 리마인더 복구 결과를 나눠 배치한 시안입니다.

발송 가능, 재시도 대기, 억제, 소진 지표와 알림 조치 큐, 선택한 익명 알림의 상태·사유 코드·다음 시각·권장 작업, 리마인더 복구 결과를 보여 주는 STAFF 운영 화면 시안

합성 데이터로 만든 운영 화면 시안입니다. 이름·연락처·본문·제공자 원문 오류는 표시하지 않으며, 숫자와 식별자는 설명을 위한 예시입니다. 운영 화면은 더 많은 정보보다 명확한 정보를 제공해야 합니다.

조치 큐의 각 조치 메시지(작업 요청)에는 상태와 사유 코드, 다음 작업을 함께 적습니다.

상태·사유 코드화면에 표시할 권장 작업자동으로 하지 않는 일
CONSENT_DENIED회원 동의 설정 확인동의를 대신 변경하거나 다시 발송하지 않음
DESTINATION_UNAVAILABLE회원 연락처 확인오래된 연락처를 outbox에 저장하지 않음
REMINDER_WINDOW_MISSED환자에게 직접 연락늦은 리마인더를 자동 발송하지 않음
RETRY_WAIT다음 시각까지 기다림STAFF가 같은 제공자 호출을 반복하지 않음
EXHAUSTED알림 지원 담당자 문의새 결정 없이 종료된 행을 되살리지 않음

NotificationStatusQueryService의 STAFF 응답은 상태, 사유 코드, 재시도 예정 시각 또는 소진 시각, 권장 작업처럼 정해진 필드로 제한합니다. outbox ID·attempt ID·회원 ID·예약 ID·제공자 페이로드는 이 화면에 포함하지 않습니다. 병원 범위도 tenantGroupId, clinicId, appointmentId를 함께 검증한 뒤 조회하므로, 다른 병원의 조치 큐가 섞이지 않습니다.

리마인더 복구는 발송 시각과 누락 상태에 따라 나눠 처리한다

섹션 제목: “리마인더 복구는 발송 시각과 누락 상태에 따라 나눠 처리한다”

전일·당일 리마인더는 예약이 확정될 때 미리 outbox에 기록하는 것이 기본입니다. 하지만 배포 직후, 데이터베이스 장애, 워커 중지, 리마인더 설정 변경 같은 상황이 생기면 일부 리마인더 슬롯이 기록되지 않을 수 있습니다. 복구 스캐너는 모든 예약에 무차별로 새 알림을 만들지 않고, 확정 예약을 제한된 페이지 단위로 읽어 필요한 슬롯만 같은 멱등성 키로 outbox 행을 생성합니다.

각 후보는 현재 시각, dueAt, catch-up-window를 비교해 네 가지 결과 중 하나로 분류합니다.

결과의미outbox 동작STAFF가 보는 것
notYetDue발송 시각이 아직 오지 않음미래 시각의 availableAt으로 미리 기록하거나 다음 순회로 넘김아직 조치할 필요 없음
enqueued발송 시각이 되었고 누락을 보정할 수 있음기존 키로 enqueue워커 처리 대기
suppressedcatch-up window가 지난 뒤 늦게 발견됨SUPPRESSED(REMINDER_WINDOW_MISSED) 기록환자에게 직접 연락
alreadyExists다른 경로가 먼저 기록함새 행을 만들지 않음중복 없음, 기존 상태 다시 조회

notYetDue를 오류로 취급하지 않는 것이 중요합니다. 대규모 backlog를 처리하는 동안 아직 이르다는 이유로 아무 행도 만들지 않으면, 다음 스캔 간격에 발송 시각이 지나 다시 처리해야 합니다. 미래 시각의 availableAt을 지원하는 어댑터는 미리 기록하고, 지원하지 않으면 다음 순회에서 같은 키로 다시 판단합니다.

복구 실행은 batch-size와 실행당 최대 후보 수로 제한하고, 키셋 커서와 runId, 마지막 예약 ID를 체크포인트로 저장합니다. 프로세스가 재시작되거나 리더가 바뀌어도 처음부터 전체 예약을 다시 훑지 않습니다. 여러 인스턴스에서 같은 후보를 읽더라도 outbox 고유 키와 저장소의 조건부 연산이 alreadyExists로 수렴시킵니다.

알림 전달 경로는 선택한 운영 범위에서만 바꾼다

섹션 제목: “알림 전달 경로는 선택한 운영 범위에서만 바꾼다”

알림 outbox 워커를 새로 배포해도 바로 모든 병원의 제공자 경로를 바꾸지 않습니다. NotificationDeliveryRouteGate는 병원 범위와 rollout 모드를 함께 평가해 하나의 경로를 결정합니다.

모드전환기 이벤트 경로outbox 워커 경로운영 의도
SHADOW모든 병원발송하지 않음새 처리 흐름을 검증하되 제공자 호출은 기존 경로에 맡김
CANARY허용 목록 밖의 병원허용 목록 병원만선택한 범위에서만 워커를 검증
ACTIVE사용하지 않음모든 병원전환을 완료
PAUSED사용하지 않음사용하지 않음제공자 장애가 발생하면 호출만 멈추고 enqueue·복구·보존은 유지

CANARY의 범위는 양수 tenant-group-idclinic-id 쌍으로 고정합니다. 이전 설정과의 호환을 위해 clinic ID 목록을 함께 사용한다면 두 집합이 일치해야 합니다. 경로를 바꾸는 작업은 코드 배포와 분리된 운영 작업이며, 같은 outbox 행을 조건부로 선점하는 안전장치가 있어야 두 경로가 동시에 제공자를 호출하지 않습니다.

현재 구현·승인된 설계·운영 화면 시안을 구분한다

섹션 제목: “현재 구현·승인된 설계·운영 화면 시안을 구분한다”
구분이 글에서 말하는 범위
현재 구현예약 트랜잭션의 최소 outbox, 리스·펜싱 선점, 발송 시점의 회원 프로필 조회, 타입 기반 템플릿 렌더링, 안정적인 결과 코드, STAFF 상태 조회, 제한된 리마인더 복구와 체크포인트
승인된 설계제공자 페이로드·개인정보의 저장 경계, outbox 고유 키와 펜싱 계약, rollout 모드, catch-up window와 상태별 보존 정책
운영 화면 시안네 가지 상태 지표, 사유 코드별 조치 큐, 익명화된 선택 항목, 리마인더 복구 결과. 실제 환자·병원 지표를 뜻하지 않음
후속 범위실제 제공자 어댑터의 운영 대시보드 연동, 병원별 알림 정책 편집, 환자 안내 문구 승인, 대규모 SaaS 환경의 리더 최적화

특히 SENT라는 상태를 “환자가 읽었다”로 확대 해석하지 않습니다. 이 상태는 알림 서비스와 제공자 계약에서 전달 성공으로 인정한 결과일 뿐이며, 읽음 확인이나 진료 결과를 의미하지 않습니다. 마찬가지로 SUPPRESSED는 환자에게 알림을 보내지 않았다는 운영 결과이지 예약을 취소했다는 뜻이 아닙니다.

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

섹션 제목: “STAFF가 조치 큐의 한 항목을 닫기 전에 확인할 다섯 가지”

알림 조치 큐의 항목을 종료하기 전에 STAFF는 다음을 확인합니다.

  1. 예약 확정 사실과 알림 의도가 각각 기록되어 있는가?
  2. 현재 회원 동의와 연락처를 발송 시점에 다시 읽었는가?
  3. 상태와 사유 코드에 맞는 다음 작업을 선택했는가?
  4. 리마인더라면 발송 시각 전인지, 보정할 수 있는지, 이미 기록됐는지, 늦어서 억제해야 하는지를 구분했는가?
  5. 예약의 원래 상태와 outbox 멱등성 키를 되살리거나 덮어쓰지 않았는가?

이 다섯 가지가 화면에 명확히 드러나면 “예약은 확정됐는데 알림은 어디까지 갔는가?”라는 질문에 답할 수 있습니다. 예약 서비스가 제공자 호출까지 맡는 대신, 각 서비스가 소유한 사실과 STAFF가 처리할 다음 작업을 분리하는 것이 운영 확장의 출발점입니다.

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

댓글

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