Skip to content

refactor: 알림 발송 구조 개선, 아웃박스 재시도 및 보관 정책 - #145

Merged
tnals0924 merged 4 commits into
developfrom
refactor/#144
Aug 17, 2026
Merged

refactor: 알림 발송 구조 개선, 아웃박스 재시도 및 보관 정책#145
tnals0924 merged 4 commits into
developfrom
refactor/#144

Conversation

@tnals0924

@tnals0924 tnals0924 commented Aug 17, 2026

Copy link
Copy Markdown
Member

#️⃣연관된 이슈

🎯 해결하려는 문제가 무엇인가요?

알림 푸시가 실패하면 그대로 유실됩니다. FCM 일시 장애나 네트워크 오류로 실패하면 사용자는 대여 승인 알림을 영영 받지 못합니다.

재시도를 붙이려 했으나 현재 구조로는 붙일 자리가 없어, 선행 정리(#144)와 재시도 구현(#146)을 함께 담았습니다.

1부 — 재시도를 막고 있던 구조 (#144)

트랜잭션이 FCM 네트워크 I/O를 감싸고 있음
NotificationEventHandler@Transactional(REQUIRES_NEW)firebaseMessaging.send() 완료까지 유지됐습니다. 관리자 알림은 관리자 수만큼 HTTP 호출이 순차 실행되므로, HTTP N+1회 동안 HikariCP 커넥션 1개를 점유합니다. (풀 15개 / async 스레드 8개)

푸시 실패가 인앱 알림까지 롤백시킴
FCMServiceFirebaseMessagingException만 잡습니다. 그 외 예외가 나오면 트랜잭션이 롤백되면서 방금 저장한 Notification 레코드까지 사라졌습니다. 관리자 알림 루프 중간에서 실패하면 앞선 관리자는 푸시를 받았는데 알림 목록에는 아무것도 없는 불일치가 생깁니다.

FCM 반환값이 성공/실패를 표현하지 못함
sendPushNotificationBoolean은 "토큰 유효 여부"라서, 네트워크 오류·FCM 5xx·타임아웃이 전부 true(성공과 동일)로 반환됐습니다.

@Async 실행기 미설정
자동 설정 실행기(코어 8, 큐 무제한)를 쓰고 있었습니다. 종료 대기 설정이 없어 배포·재시작 시 큐에 남은 알림이 유실됩니다. server.shutdown: graceful 은 웹 요청만 보호합니다.

2부 — 재시도와 보관 (#146, #147)

위를 정리한 뒤에도 실패한 발송을 다시 시도할 수단이 없었습니다. 그리고 재시도용 대기열은 알림 한 건마다 수신자 수만큼 쌓이므로, 정리하지 않으면 계속 늘어납니다.

❓ 왜 해결해야 하나요?

  • 2번(롤백)이 남아 있으면 재시도용 레코드를 저장해도 푸시 실패와 함께 같이 롤백됩니다
  • 3번(반환값)이 남아 있으면 "무엇을 재시도해야 하는지" 판단할 근거가 없습니다
  • 1번(트랜잭션)은 재시도로 호출 횟수가 늘어날수록 커넥션 점유 시간이 비례해 길어집니다

그리고 알림은 이 서비스에서 사용자가 대여 상태를 아는 유일한 수단이라, 조용히 유실되면 사용자는 승인 여부를 확인할 방법이 없습니다.

⭐ 어떻게 해결했나요?

저장과 발송을 다른 트랜잭션으로 분리

NotificationEventHandler (@Async + AFTER_COMMIT, 트랜잭션 없음)
  ├ NotificationService.createNotification()   # 알림 + 아웃박스 저장 (한 트랜잭션, 즉시 커밋)
  └ PushNotificationSender.dispatch()          # 트랜잭션 밖에서 FCM 호출
  • NotificationService — 알림 저장만 담당. FCMService·MemberService 의존을 걷어냈습니다
  • PushNotificationSender — 트랜잭션 밖에서 발송하고 실패를 예외로 전파하지 않습니다. 한 수신자의 실패가 다른 수신자나 이미 저장된 알림에 영향을 주면 안 되기 때문입니다
  • 핸들러의 REQUIRES_NEW 제거 — @Async로 이미 별도 스레드라 필요 없던 설정이기도 합니다

FCM 실패를 세 종류로 구분 (PushResult)

결과 해당 에러 코드 처리
Success SENT
InvalidToken UNREGISTERED, SENDER_ID_MISMATCH 토큰 제거 후 종료
Retryable UNAVAILABLE, INTERNAL, QUOTA_EXCEEDED, THIRD_PARTY_AUTH_ERROR, 에러 코드 없는 전송 계층 오류 백오프 후 재시도
Permanent INVALID_ARGUMENT FAILED

아웃박스 기반 재시도

발송 대상을 notification_push_outbox수신자 단위 row로 남깁니다. 알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남습니다.

PENDING ─┬─ 발송 성공 ──────────────→ SENT
         ├─ Retryable 실패 ─ 백오프 → PENDING (재시도 횟수 소진 시 FAILED)
         ├─ InvalidToken/Permanent → FAILED
         └─ 생성 후 1시간 경과 ─────→ EXPIRED
  • 유효 시간(TTL)은 10분. 사용자 알림은 "지금 과방에 갈지"를 결정하는 정보고, 관리자 알림도 학생이 기다리는 상태에서 처리해야 하는 일이라 둘 다 실시간성이 중요합니다. 지나면 EXPIRED로 포기하며, 인앱 알림은 이미 저장돼 있으므로 정보가 사라지는 것은 아닙니다
  • 백오프는 30초 → 2분 → 5분, 최대 3회. 누적 7분 30초로 유효 시간 안에 들어옵니다
  • 다음 재시도 시각이 TTL을 넘기면 예약하지 않고 즉시 포기합니다. 어차피 만료될 시도를 기다리며 폴러가 한 번 더 집어가는 낭비를 막습니다
  • 즉시 발송과 재시도가 같은 코드 경로(PushNotificationSender.dispatch)를 탑니다. 폴러는 트리거만 다를 뿐입니다
  • 메시지 본문은 저장하지 않습니다 — 연결된 Notification의 status와 formatValues로 재구성합니다

보관 정책 (PushOutboxPurgeScheduler)

매일 새벽 4시(KST)에 보존 기간이 지난 건을 정리합니다.

상태 보존 기간 이유
SENT 7일 성공 건은 이력 가치가 낮다
FAILED, EXPIRED 30일 실패 원인(last_error)을 들여다볼 여지를 남긴다
PENDING 삭제하지 않음 아직 처리되지 않은 발송 대상
  • 배치(500건)로 나눠 삭제하고 배치마다 트랜잭션을 끊습니다 — 한 번에 지우면 락 구간이 길어집니다
  • 한 회 처리량 상한(20배치 = 10,000건)에 도달하면 경고 로그를 남깁니다 — 남은 건이 있다는 사실이 조용히 묻히지 않도록

재시도 폴러와 같은 스케줄러 스레드를 쓰기 때문에, 정리가 길어지면 폴러가 밀립니다. 상한을 둔 이유입니다.

알림 전용 실행기 (AsyncConfig)

코어 4 / 최대 8 / 큐 500, CallerRunsPolicy, 종료 시 최대 20초 대기, AsyncUncaughtExceptionHandler 등록.

🧩 이 PR의 한계 & 트레이드오프

  • 보존 기간(7일 / 30일)은 실측이 아닌 추정치입니다. 실제 알림 발생량과 장애 분석에 필요한 기간을 보고 조정해야 합니다
  • notifications 테이블 자체의 보관 정책은 다루지 않았습니다. 사용자에게 노출되는 알림 이력이라 보존 기간을 따로 논의해야 합니다
  • 중복 발송을 완전히 막지는 못합니다. 즉시 시도와 폴러가 겹치지 않도록 새 row의 nextRetryAt을 60초 뒤로 잡았지만, 이건 잠금이 아니라 시간차입니다. 즉시 시도가 60초 넘게 걸리면 폴러가 같은 건을 집어갈 수 있습니다. 실제 잠금은 다중 인스턴스로 갈 때 함께 넣는 게 맞다고 봤습니다
  • 다중 인스턴스 환경을 가정하지 않았습니다. 인스턴스를 늘리면 여러 대가 같은 건을 집어가 중복 발송됩니다. FOR UPDATE SKIP LOCKED 또는 ShedLock이 필요하며 SchedulingConfig에 주석으로 남겼습니다
  • 폴러는 단일 스레드에서 순차 발송합니다. 한 주기 100건이고 FCM 호출이 느리면 주기가 길어집니다. fixedDelay라 중복 실행되지는 않습니다
  • 사용자 알림과 관리자 알림이 별도 트랜잭션이 됐습니다. 기존에는 하나로 묶여 원자적이었지만, 이제 관리자 알림 저장이 실패해도 사용자 알림은 남습니다. 전부 잃는 것보다 낫다고 판단해 부분 성공을 택했습니다
  • CallerRunsPolicy는 큐가 가득 차면 호출 스레드(= 이벤트를 발행한 HTTP 요청 스레드)에서 처리합니다. 유실 대신 요청 지연을 감수한 선택입니다
  • 풀 크기(4/8/500)와 백오프 간격은 실측이 아닌 추정치입니다
  • 알림 레코드 자체가 저장되지 못하는 경우는 여전히 남습니다. 비동기 핸들러가 실행되기 전에 프로세스가 죽으면 이벤트가 사라집니다. 이벤트를 대여 트랜잭션 안에서 아웃박스에 넣는 진짜 트랜잭셔널 아웃박스가 필요한데 RentalService까지 손대야 해서 분리했습니다

⛓️ 기존 기능에 미치는 영향

  • API 스펙 변경 없음. 알림 조회·읽음 처리 엔드포인트는 그대로입니다
  • DDL 변경 있음notification_push_outbox 테이블 추가. ddl-auto: update라 자동 생성되지만, 운영 반영 시 확인용으로 남깁니다
CREATE TABLE notification_push_outbox (
    notification_push_outbox_id BIGINT       NOT NULL AUTO_INCREMENT,
    notification_id             BIGINT       NOT NULL,
    receiver_id                 BIGINT       NOT NULL,
    delivery_status             ENUM('PENDING','SENT','FAILED','EXPIRED') NOT NULL,
    retry_count                 INT          NOT NULL,
    next_retry_at               DATETIME(6)  NOT NULL,
    last_error                  VARCHAR(500) NULL,
    created_at                  DATETIME(6)  NOT NULL,
    updated_at                  DATETIME(6)  NOT NULL,
    PRIMARY KEY (notification_push_outbox_id),
    KEY idx_push_outbox_delivery (delivery_status, next_retry_at),
    KEY idx_push_outbox_purge    (delivery_status, created_at),
    CONSTRAINT fk_push_outbox_notification FOREIGN KEY (notification_id) REFERENCES notifications (notification_id) ON DELETE CASCADE,
    CONSTRAINT fk_push_outbox_receiver     FOREIGN KEY (receiver_id)     REFERENCES member (member_id)             ON DELETE CASCADE
);
  • @EnableScheduling 이 새로 켜집니다. 재시도 폴러(30초 간격)와 정리 스케줄러(매일 04:00 KST) 두 개가 같은 단일 스레드에서 순차 실행됩니다
  • Executor 빈을 정의하면서 Boot 자동 설정의 applicationTaskExecutor 가 물러납니다. Spring MVC 비동기(Callable/DeferredResult/SseEmitter)를 쓰는 곳이 없는 것을 확인했고, @AsyncAsyncConfigurer 로 명시 지정하므로 동작 차이는 없습니다
  • NotificationService.sendNotification / sendNotificationToAdmincreateNotification / createAdminNotification 으로 이름과 역할이 바뀌었습니다. 호출부는 NotificationEventHandler 뿐이라 영향 범위는 닫혀 있습니다
  • SENDER_ID_MISMATCH 를 토큰 제거 대상에 새로 포함했습니다. 다른 Firebase 프로젝트의 토큰이므로 지우는 게 맞다고 봤습니다
  • FCM 토큰이 없는 회원은 아웃박스에 등록되지 않습니다 (기존과 동일하게 경고 로그만). 무의미한 실패 row가 쌓이지 않도록 했습니다
  • CLAUDE.md의 서비스 의존성 표를 실제 구조에 맞게 갱신했습니다 (RentalService → NotificationService 도 이미 이벤트 발행으로 바뀐 상태라 함께 수정)

🔀 Edge Case & 실패 시나리오

상황 기존 변경 후
FCM 일시 장애 로그만 남고 유실 30초 뒤부터 최대 4회 재시도
발송 직전 프로세스 종료 유실 아웃박스에 남아 재기동 후 폴러가 처리
관리자 5명 중 3번째 푸시 실패 나머지 2명 중단 + 레코드 롤백 나머지 2명 정상 발송, 실패한 1명만 재시도
메시지 포맷 인자 개수 불일치 예외 전파 → 롤백 Permanent 로 기록, 알림은 저장 유지
토큰 만료(UNREGISTERED) 토큰 제거 재시도 없이 토큰 제거 후 FAILED
재시도 중 사용자가 토큰 재등록 매 시도마다 토큰을 다시 읽으므로 새 토큰으로 발송
10분 넘게 실패 지속 EXPIRED 로 포기 (ERROR 로그). 인앱 알림은 남음
유효 시간 20초 남기고 실패 다음 간격(30초)이 만료 후라 기다리지 않고 즉시 포기
배포로 인한 종료 큐 잔여 작업 즉시 소멸 최대 20초 대기 + 아웃박스에 남아 재시도
큐 포화 (무제한이라 힙에 누적) 호출 스레드에서 실행
정리 대상이 한 회 처리량 초과 상한까지만 지우고 경고 로그, 나머지는 다음 날
정리 중 재시도 폴러 실행 시점 도달 같은 스레드라 정리가 끝난 뒤 실행 (상한으로 지연 제한)

📋 검토한 대안과 선택 이유

  • Spring Retry(@Retryable) 인메모리 재시도 — 30분이면 끝나지만 프로세스가 죽으면 유실되고 실패 이력이 남지 않습니다. 재시도 동안 async 스레드도 계속 잡습니다. 알림 유실 방지가 목적이라 영속 대기열을 택했습니다
  • Redis Stream / SQS 등 브로커 도입 — 동시 접속 100명 규모에 인프라를 추가하는 건 과하다고 봤습니다. 아웃박스는 기존 MySQL만으로 같은 보장을 얻습니다
  • 아웃박스에 메시지 본문 저장 — 조회는 편하지만 Notification 과 중복이고, enum 문구를 고치면 두 곳이 어긋납니다. status + formatValues로 재구성하는 쪽을 택했습니다
  • 실패했을 때만 아웃박스 row 생성 — 성공 시 DB 쓰기를 아낄 수 있지만, 발송 직전에 죽으면 아무 기록도 남지 않아 아웃박스의 존재 이유가 사라집니다
  • spring.task.execution.* 프로퍼티로만 실행기 설정RejectedExecutionHandler 를 지정할 수 없어 큐 포화 시 태스크가 버려집니다
  • 아웃박스를 지우지 않고 무한 보관 — 조회는 인덱스로 버티겠지만 백업·복구 비용이 계속 커집니다. 발송이 끝난 건은 재조회되지 않으므로 남길 이유가 약합니다
  • 한 번의 DELETE 로 일괄 삭제 — 코드는 짧지만 락 구간이 길어지고, 같은 스레드를 쓰는 재시도 폴러가 그만큼 밀립니다
  • 알림 종류별로 TTL 분리(사용자 15분 / 관리자 1시간) — 관리자 알림은 대시보드에 건이 남아 있으니 길게 잡아도 된다고 봤는데, 학생이 과방에서 기다리는 상황이라 관리자 쪽도 실시간성이 중요하다는 리뷰를 받아 하나로 통일했습니다
  • 대여 시각(rentAt) 기준으로 유효 시간 계산 — 승인 푸시의 실제 가치는 "대여 시각까지 남은 시간"에 달려 있어 가장 정확합니다. 다만 아웃박스가 대여 도메인을 알아야 해서, 종류별 고정 TTL로 충분하다고 판단했습니다
  • sendEachForMulticast 로 관리자 일괄 발송 — 호출 횟수는 줄지만 수신자별 재시도 상태를 따로 관리해야 해서, 아웃박스가 수신자 단위인 현재 구조와 맞지 않습니다

💬 리뷰 포인트

  • [r] TTL 10분이 적절한지 봐주세요. 이 안에 30초·2분·5분 세 번의 재시도가 들어갑니다. 더 줄이면 재시도 횟수도 함께 줄여야 합니다
  • [r] Member 가 detached 상태로 Notification·NotificationPushOutbox 에 연결됩니다. 핸들러에 트랜잭션이 없어져 memberService.findById() 가 준영속 엔티티를 반환하고, 그 상태로 저장 메서드에 넘어갑니다. @ManyToOne 에 cascade가 없어 FK만 기록되므로 동작에는 문제가 없다고 판단했는데, 이 방식이 괜찮은지 봐주시면 좋겠습니다
  • [c] INVALID_ARGUMENTPermanent 로 두고 토큰을 지우지 않았습니다. 이 코드는 토큰 문제일 수도, 페이로드 문제일 수도 있어 멀쩡한 토큰을 지우는 위험을 피했습니다. 실제 로그를 보고 조정하는 게 나을 것 같습니다
  • [c] 보존 기간(SENT 7일 / FAILED·EXPIRED 30일) 이 적절한지 봐주세요. 실패 원인을 들여다볼 기간이 30일이면 충분한지가 관건입니다
  • [a] 풀 크기(4/8/500), 폴링 주기 30초, 배치 100건 수치에 의견 있으시면 알려주세요

🔍 검증

  • ./gradlew compileKotlin 통과
  • ./gradlew test 통과 — contextLoads 1건 + 아웃박스 상태 전이 단위 테스트 8건 (백오프 간격, 마지막 재시도가 유효 시간 안에 드는지, 최대 재시도 횟수 소진, TTL 초과, 만료될 재시도 예약 차단, 성공 시 오류 기록 초기화)
  • 기존 contextLoadsapplication-local.yml 의 H2 TCP 설정이 로컬 환경에 의존해서, 인메모리 H2로 datasource를 덮어 실행했습니다 (SPRING_DATASOURCE_URL='jdbc:h2:mem:billilge;MODE=MySQL')
  • 생성된 스키마를 직접 조회해 notification_push_outbox 테이블과 idx_push_outbox_delivery (delivery_status, next_retry_at) 인덱스가 만들어지는 것을 확인했습니다
  • 정리 스케줄러는 임시 통합 테스트로 H2에 상태별·시점별 row를 넣고 확인했습니다 — 보존 기간이 지난 SENT/FAILED/EXPIRED 만 삭제되고, 기간 내의 건과 PENDING 은 100일이 지나도 남습니다. (기대값을 뒤집어 실패하는 것까지 확인한 뒤 임시 테스트는 제거했습니다. CI가 -x test 로 테스트를 건너뛰고 로컬 DB 설정에 의존하는 테스트라 커밋하지 않았습니다)
  • 실제 FCM 발송·재시도 경로는 검증하지 못했습니다. dev 환경에서 대여 신청 → 푸시 수신, 그리고 잘못된 토큰으로 실패 시 아웃박스 row가 재시도되는지 한 번 봐주시면 좋겠습니다

tnals0924 and others added 2 commits August 17, 2026 18:41
FCM 호출이 알림 저장 트랜잭션 안에 있어, 푸시가 실패하면 방금 저장한
Notification 레코드까지 롤백되고 관리자 알림은 루프 중간에서 끊겼다.
또 HTTP 호출이 끝날 때까지 HikariCP 커넥션을 점유했다.

- NotificationService는 알림 저장만 담당하도록 축소 (FCMService, MemberService 의존 제거)
- PushNotificationSender를 추가해 트랜잭션 밖에서 푸시 발송, 실패를 예외로 전파하지 않음
- NotificationEventHandler의 REQUIRES_NEW 제거 — 저장은 짧은 트랜잭션에서 커밋 후 푸시 발송
- FCMService 반환 타입을 Boolean에서 PushResult로 교체해 재시도 가능 여부를 구분
  (기존에는 네트워크 오류·FCM 5xx도 true로 반환되어 실패가 호출부에 전달되지 않았음)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
자동 설정 실행기(applicationTaskExecutor)는 큐가 무제한이라 FCM 지연 시
태스크가 계속 쌓이고, 종료 대기 설정이 없어 배포 시 큐에 남은 알림이 유실됐다.

- notificationTaskExecutor 정의 (코어 4 / 최대 8 / 큐 500)
- 큐 포화 시 CallerRunsPolicy로 유실 대신 지연 선택
- 종료 시 최대 20초간 잔여 작업 처리 대기
- AsyncUncaughtExceptionHandler 등록해 실패한 비동기 작업을 식별 가능하도록 기록

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tnals0924 tnals0924 added the refactoring 코드 리팩터링 label Aug 17, 2026
@tnals0924 tnals0924 self-assigned this Aug 17, 2026
Retryable로 분류된 푸시 실패가 로그로만 남고 유실됐다. FCM 일시 장애나
네트워크 오류로 실패하면 사용자는 대여 승인 알림을 영영 받지 못한다.

발송 대상을 수신자 단위 row로 DB에 남기고 스케줄러가 미발송 건을 재시도한다.
알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남는다.

- notification_push_outbox 테이블 및 엔티티 추가 (상태/재시도 횟수/다음 시도 시각/마지막 오류)
- 백오프 30초 → 2분 → 5분 → 15분, 최대 4회. 생성 후 1시간 경과 시 EXPIRED로 포기
- 즉시 발송과 재시도가 같은 경로(PushNotificationSender.dispatch)를 공유
- 새 row의 nextRetryAt을 60초 뒤로 잡아 즉시 시도와 폴러의 중복 발송 방지
- InvalidToken은 재시도 없이 토큰 제거 후 종료
- 상태 전이(백오프/최대 횟수/TTL) 단위 테스트 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tnals0924 tnals0924 changed the title refactor: 알림 발송 트랜잭션 분리 및 FCM 실패 처리 구조 개선 refactor: 알림 발송 구조 개선 및 아웃박스 기반 푸시 재시도 Aug 17, 2026
@tnals0924 tnals0924 added the new feature 새로운 기능 추가 label Aug 17, 2026
발송이 끝난 아웃박스 row가 계속 남았다. 알림 한 건마다 수신자 수만큼 쌓이므로
방치하면 재시도 대상 조회가 느려진다.

- 매일 새벽 4시(KST) 보존 기간이 지난 건 삭제
- SENT 7일, FAILED/EXPIRED 30일(실패 원인 확인용), PENDING은 삭제하지 않음
- 배치(500건) 단위로 나눠 삭제하고 배치마다 트랜잭션을 끊어 락 구간을 짧게 유지
- 한 회 처리량 상한(20배치)에 도달하면 남은 건이 있다는 경고 로그
- 정리 조회용 인덱스 (delivery_status, created_at) 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tnals0924 tnals0924 changed the title refactor: 알림 발송 구조 개선 및 아웃박스 기반 푸시 재시도 refactor: 알림 발송 구조 개선, 아웃박스 재시도 및 보관 정책 Aug 17, 2026
@tnals0924
tnals0924 merged commit 644f746 into develop Aug 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature 새로운 기능 추가 refactoring 코드 리팩터링

Projects

None yet

1 participant