Repository navigation
[draft] [bug] Outbox 조회 잠금 해제로 다중 워커 중복 발송 가능 #262
Description
증상 (스크린샷/ 로그는 선택사항)
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)을 제안합니다.
재현 경로 (어떻게 발생하나요?)
- 전용 DB에 PENDING Notification 1건과 연결된 PENDING OutboxEvent 1건을 저장하고 커밋합니다.
- 실제 OutboxPublisher와 OutboxEventDispatcher를 Spring Bean으로 등록하고 FCM만 모의 객체로 대체합니다.
- 워커 A가
processPendingOutboxEvents()를 호출하게 합니다. - A가
firebaseMessaging.send()에 진입하면 외부 호출이 느린 상황을 유지합니다. - 그 시점에 워커 B도
processPendingOutboxEvents()를 호출하게 합니다. - B도 같은 이벤트의 FCM 호출에 진입하는 것을 확인한 뒤 두 호출을 완료합니다.
- 두 워커가 반환한 후 발송 호출 수 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