Skip to content

[draft] [bug] Outbox 조회 잠금 해제로 다중 워커 중복 발송 가능 #262

Open

Description

@PHJ2000

증상 (스크린샷/ 로그는 선택사항)

Outbox 이벤트 조회에는 PESSIMISTIC_WRITE와 SKIP LOCKED가 있지만, 조회 트랜잭션이 반환 시점에 끝나 실제 발송 전에 잠금이 풀립니다. 처리 소유권을 기록하지 않아 두 워커가 같은 이벤트를 가져가 발송할 수 있습니다.

검토용 초안입니다. 로컬에서 실제 서비스·전용 PostgreSQL DB로 두 워커를 실행했고, 동일 이벤트의 FCM 발송 호출이 두 번 발생했습니다. 실제 FCM 전달은 모의 객체로 대체했습니다.

예상 결과

프로세스 중단이나 DB 실패가 없는 정상적인 다중 워커 실행에서 한 이벤트는 한 워커가 선점해야 합니다. 다른 워커는 처리 중인 이벤트를 다시 발송하지 않아야 합니다.

실제 결과

첫 워커의 외부 발송 호출이 진행 중일 때 두 번째 워커를 시작하면, 두 번째도 같은 PENDING 이벤트를 조회하고 아직 PENDING인 Notification의 상태 검사를 통과했습니다.

반복 3회 모두 같은 이벤트에 대한 firebaseMessaging.send() 호출이 2회였습니다. 두 워커가 완료된 뒤 Notification과 OutboxEvent는 모두 SENT로 저장됐습니다.

워커 A: PENDING 이벤트 조회 → 조회 트랜잭션 종료 및 잠금 해제
워커 A: Notification 조회 → FCM 발송 호출 진행 중
워커 B: 같은 PENDING 이벤트 조회 → Notification 조회
워커 B: FCM 발송 호출
워커 A·B: 각각 상태 변경 및 커밋
최종 DB: SENT / SENT, 외부 발송 호출: 2회

영향 및 발생 빈도 (Impact)

  • 워커 둘 이상이 같은 DB를 폴링하면 동일 알림의 중복 발송으로 이어질 수 있습니다.
  • dispatcher의 SENT 검사는 발송 완료 후 순차 재처리를 차단하지만, 완료 전에 겹치는 발송을 막지 못합니다.
  • 코드에서 의도한 다중 워커 선점 보호가 발송 구간까지 유지되지 않습니다.
  • 단일 워커에서 항상 발생한다는 뜻은 아닙니다. 현재 운영 인스턴스 수와 실제 중복 푸시 수신 여부는 확인하지 않았습니다.
  • 3/3회는 실행 순서를 제어한 로컬 재현 결과이며 운영 발생률을 의미하지 않습니다.
  • 다중 워커 운영 전 우선 수정(P1)을 제안합니다.

재현 경로 (어떻게 발생하나요?)

  1. 전용 DB에 PENDING Notification 1건과 연결된 PENDING OutboxEvent 1건을 저장하고 커밋합니다.
  2. 실제 OutboxPublisher와 OutboxEventDispatcher를 Spring Bean으로 등록하고 FCM만 모의 객체로 대체합니다.
  3. 워커 A가 processPendingOutboxEvents()를 호출하게 합니다.
  4. A가 firebaseMessaging.send()에 진입하면 외부 호출이 느린 상황을 유지합니다.
  5. 그 시점에 워커 B도 processPendingOutboxEvents()를 호출하게 합니다.
  6. B도 같은 이벤트의 FCM 호출에 진입하는 것을 확인한 뒤 두 호출을 완료합니다.
  7. 두 워커가 반환한 후 발송 호출 수 2회와 Notification/Event의 SENT 상태를 확인합니다.

테스트 방법

실제 repository, DB 트랜잭션과 서비스 프록시를 사용했습니다. 조회 데이터나 서비스 동작을 모의 객체로 교체하지 않았습니다. FCM 모의 객체의 호출에서만 기다리게 해 느린 외부 발송과 겹치는 폴링을 재현했습니다.

test 프로필에서는 운영 Outbox Bean이 비활성화되므로, 테스트 설정에서 동일한 운영 클래스와 생성자 의존성으로 publisher/dispatcher를 등록했습니다. 자동 스케줄러 대신 두 스레드에서 publisher를 직접 호출했습니다.

로컬 재현 테스트 RemainingConcurrencyReproductionTest.twoOutboxPollersMustNotSendTheSameNotification을 3회 반복했습니다. 발송 호출 1회를 기대하는 검증이 실제 2회로 모두 실패했고, 최종 DB 상태 검증은 통과했습니다. 재현 코드는 아직 저장소에 커밋하지 않았습니다.

환경 (어디서 발생했나요?)

  • Local (내 컴퓨터)

  • Dev/Staging (테스트 서버)

  • Prod (운영 서버)

  • 검증 기준: 2026-09-18 원격 main, 00f596e372c021f13daeec6b803fe33e59aeb19d

  • Windows, Java 21.0.8, Gradle 8.7, Spring Boot 3.3.0

  • Testcontainers 1.20.5 / postgis/postgis:16-3.4

  • 전용 테스트 DB beach_concurrency_audit_20260918, 격리 수준 read committed

  • 실제 이메일·FCM 발송 없이 검증

심각도(Severity)

  • S1 (서비스 중단/데이터 손상)
  • S2 (핵심 기능 장애)
  • S3 (부분 장애/우회 가능)
  • S4 (경미)

다중 워커에서 알림 중복 발송을 유발할 수 있는 부분 장애 기준으로 S3를 제안합니다. 알림의 용도와 운영 구성에 따라 우선순위는 조정할 수 있습니다.

📸 참고 자료 (스크린샷/로그)

OUTBOX_TWO_POLLERS sends=2 notificationStatus=SENT eventStatus=SENT
OUTBOX_TWO_POLLERS sends=2 notificationStatus=SENT eventStatus=SENT
OUTBOX_TWO_POLLERS sends=2 notificationStatus=SENT eventStatus=SENT

발송 호출 수 기대값: 1
실제 발송 호출 수: 2

수정 방향 및 완료 조건

수정 방식은 아직 확정하지 않았습니다. 짧은 트랜잭션 안에서 잠금과 함께 처리 소유권을 DB에 기록한 뒤 발송하는 방식 등을 검토하면 좋겠습니다. PROCESSING 상태·작업자 식별·선점 만료를 도입한다면 완료 처리 권한과 작업자 중단 후 복구도 함께 설계해야 합니다.

  • 정상적인 두 워커 동시 처리에서 같은 이벤트의 FCM 발송 호출은 1회입니다.
  • 이미 SENT인 Notification의 재처리는 발송을 건너뜁니다.
  • 다른 이벤트는 기존 배치 처리와 재시도 정책대로 처리합니다.
  • 선점한 작업자가 중단됐을 때 이벤트가 영구히 처리 중으로 남지 않도록 복구합니다.
  • 완료 처리 시 다른 워커의 처리 상태를 오래된 데이터로 덮어쓰지 않습니다.
  • 실제 DB를 사용하는 다중 워커 회귀 테스트로 검증합니다.

이번 문제는 프로세스 중단 없이 정상 실행하는 두 워커의 선점 경쟁입니다. 외부 발송 성공 직후 DB 커밋이 실패하는 at-least-once 전달의 중복 가능성은 별도로 남으며, 선점 개선만으로 외부 발송까지 exactly-once를 보장하지는 않습니다.

관련: Outbox 구현 #184, Dispatcher 계측 #257 / PR #258. #253의 조건부 Bean 등록으로 폴링이 실행되지 않는 문제와는 다른 원인입니다. PR #258은 이번 기준 코드에 포함되지 않았고, 해당 브랜치에서 이 테스트를 실행하지 않았습니다.

Activity

  1. self-assigned this
    on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions