Skip to content

[fix] Transactional Outbox 패턴 적용해서 알림 로직 수정#85

Open
DOHOON0127 wants to merge 3 commits into
devfrom
fix/#84
Open

[fix] Transactional Outbox 패턴 적용해서 알림 로직 수정#85
DOHOON0127 wants to merge 3 commits into
devfrom
fix/#84

Conversation

@DOHOON0127
Copy link
Copy Markdown
Contributor

Related issue 🛠

어떤 변경사항이 있었나요?

  • 🐛 BugFix Something isn't working
  • 💻 CrossBrowsing Browser compatibility
  • 🌏 Deploy Deploy
  • 🎨 Design Markup & styling
  • 📃 Docs Documentation writing and editing (README.md, Swagger, etc.)
  • ✨ Feature Feature
  • 🔨 Refactor Code refactoring
  • ⚙️ Setting Development environment setup
  • ✅ Test Test related

Transactional Outbox 패턴

TodaySound 서비스는 "피드에 새 글이 올라오면 사용자 폰에 알림을 보내주는 것"이 핵심 기능임. 그런데 운영하다 보니 같은 피드를 구독한 사용자인데 어떤 사람은 알림이 오고 어떤 사람은 안 오는 현상이 발생함.


왜 이런 일이 벌어졌는가

기존 코드의 실행 순서가 이랬음

① FCM 전송 (Firebase에 HTTP 요청) → 외부 시스템, 한번 보내면 취소 불가
② DB 저장 (summaryRepository.save())

그리고 @transactional이 없었음. 즉 ①과 ②는 아무 관계 없는 독립된 두 작업임.

크롤러가 구독자 10명에 대해 이 API를 10번 호출하는데, 각 요청이 독립적으로 다른 지점에서 실패할 수 있음

  • User A: ① 성공 ② 성공 → 정상 (알림 오고, 앱에도 있음)
  • User B: ① 실패 ② 실행 안 됨 → 알림도 없고 앱에도 없음 (서비스가 조용히 죽은 것)
  • User C: ① 성공 ② 실패 → 알림은 왔는데 앱에 아무것도 없음 (Phantom Notification)

이게 뒤섞여서 "어떤 핸드폰은 오고 어떤 핸드폰은 안 온다"는 현상이 나온 것임.


해결 과정

시도 1: 순서만 바꾸기 → 불완전

① DB 저장 먼저
② FCM 나중에

@transactional이 없으니 ①은 즉시 auto-commit됨. ②에서 FCM이 실패하면? → DB에는 데이터가 있는데 알림은 영영 안 감. 재시도 수단이 없음.

시도 2: @transactional + afterCommit() → 거의 해결, 하지만 한계

@Transactional 시작
  ① DB 저장 (아직 커밋 안 됨)
  ② afterCommit 콜백 등록
트랜잭션 커밋 → DB 영구 저장 확정
  ③ afterCommit() 실행 → FCM 전송

이러면 DB 커밋이 실패하면 FCM이 아예 실행 안 됨 → Phantom 문제 해결됨.

하지만: afterCommit() 실행 중에 서버가 죽거나 Firebase가 일시 장애이면? → 그 알림은 영구 유실됨. afterCommit()은 메모리에만 있는 콜백이라 서버가 재시작되면 사라짐.

시도 3: CDC (Debezium) → 검토했지만 부적합

CDC는 DB의 binlog를 읽어서 변경을 감지하는 방식인데, 상황에 부적합함

  • CDC가 캡처하는 건 Summary 테이블의 row 변경뿐인데, FCM 전송에는 User의 FCM 토큰, subscription의 siteAlias 등 다른 도메인 정보가 필요함 → CDC만으로는 알림을 구성할 수 없음
  • 테이블 스키마가 바뀔 때마다 전송 로직도 영향을 받음
  • Debezium 같은 인프라를 추가로 운영해야 함

최종 해결: Transactional Outbox 패턴

핵심 아이디어:

FCM을 직접 쏘지 말고, "쏴야 할 것"을 DB에 기록. 비즈니스 데이터와 같은 트랜잭션에 넣으면 원자성이 보장된다. 실제 전송은 별도 스케줄러가 책임진다.

@Transactional:
  ① summaryRepository.save()              ─┐ 하나의 트랜잭션
  ② outboxRepository.save(status=PENDING)  ─┘ 둘 다 커밋 or 둘 다 롤백
  (FCM 전송 코드 없음!)

별도 스케줄러 (5초마다): ③ PENDING인 건 조회 ④ FCM 전송 시도 → 성공: status = SENT → 실패: retryCount++, 3회 초과 시 FAILED

왜 이게 모든 문제를 해결하는지 케이스별로 보면:

상황 기존 코드 Outbox 적용 후
DB 저장 실패 FCM은 이미 나감 (Phantom) 트랜잭션 롤백 → outbox도 없음 → FCM 전송 자체가 안 일어남
FCM 전송 실패 DB도 저장 안 됨, 유실 DB에는 저장됨 + PENDING 유지 → 5초 후 재시도
서버 재시작 afterCommit 콜백은 메모리에만 있어서 유실 PENDING이 DB에 남아있음 → 재시작 후 자동 재처리
Firebase 일시 장애 영구 유실 장애 복구 후 PENDING 건 자동 재전송

트레이드오프: 뭘 포기하고 뭘 얻었는가

포기한 것: 알림의 즉각성. 기존에는 요청 처리 중에 바로 FCM을 쐈는데, 이제는 스케줄러 주기(최대 5초) 만큼 지연이 생김.

얻은 것: 알림의 신뢰성. 모든 사용자에게 eventually 반드시 전달된다는 보장.

이건 의도된 트레이드오프. TodaySound는 실시간 메신저가 아니라 피드 알림 서비스. 5초 늦게 오는 게 문제가 아니라, 안 오는 게 문제임.


To Reviewers 📢

"알림 전송과 데이터 저장의 원자성을 DB 트랜잭션으로 보장하고, 제어할 수 없는 외부 시스템(Firebase)의 일시적 장애에 대한 복구 책임을 애플리케이션 내부로 가져오기 위해서 수정함."

@DOHOON0127 DOHOON0127 self-assigned this Mar 2, 2026
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 TodaySound 서비스의 핵심 기능인 피드 알림의 신뢰성 문제를 해결하기 위해 Transactional Outbox 패턴을 적용합니다. 기존에는 알림 전송(FCM)과 DB 저장 로직이 분리되어 있어 알림 유실, 중복, 또는 팬텀 알림과 같은 문제가 발생했습니다. 이제 Summary 저장과 알림 전송 요청(Outbox 기록)을 하나의 트랜잭션으로 묶어 데이터 일관성을 보장하고, 별도의 스케줄러가 Outbox 테이블을 모니터링하여 FCM 전송을 비동기적으로 처리하며 실패 시 재시도 메커니즘을 통해 알림의 '결과적 전달(eventual delivery)'을 보장합니다. 이는 알림의 즉시성보다는 신뢰성을 우선시하는 서비스의 특성을 반영한 변경입니다.

Highlights

  • 트랜잭셔널 아웃박스 패턴 적용: 알림 전송의 신뢰성을 높이기 위해 Transactional Outbox 패턴을 도입했습니다.
  • 알림 로직 분리 및 원자성 보장: 기존의 FCM 직접 전송 방식을 제거하고, Summary 저장과 NotificationOutbox 기록을 단일 DB 트랜잭션으로 묶어 원자성을 보장합니다.
  • 비동기 알림 전송 및 재시도: NotificationOutboxScheduler를 통해 PENDING 상태의 아웃박스 항목을 주기적으로 읽어 FCM을 비동기적으로 전송하고, 실패 시 재시도 로직을 포함하여 안정적인 알림 전달을 보장합니다.
  • 알림 즉시성 vs 신뢰성 트레이드오프: 알림의 즉각적인 전달 대신 최대 5초의 지연을 감수하고, 모든 사용자에게 알림이 '결국' 전달되는 신뢰성을 확보했습니다.
Changelog
  • src/main/java/com/todaysound/todaysound_server/domain/alarm/controller/InternalAlertController.java
    • FCMService 의존성을 제거하고 NotificationOutboxRepository를 주입받도록 변경되었습니다.
    • @Transactional 어노테이션이 추가되었습니다.
    • Summary 저장 후 NotificationOutbox를 저장하는 로직이 구현되었습니다.
  • src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationOutbox.java
    • 알림 전송을 위한 Outbox 엔티티가 추가되었습니다.
  • src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/OutboxStatus.java
    • Outbox 항목의 상태를 나타내는 PENDING, SENT, FAILED enum이 추가되었습니다.
  • src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationOutboxRepository.java
    • NotificationOutbox 엔티티를 위한 JPA 리포지토리가 추가되었습니다.
  • src/main/java/com/todaysound/todaysound_server/domain/alarm/scheduler/NotificationOutboxScheduler.java
    • 5초마다 PENDING 상태의 Outbox 항목을 읽어 FCM을 전송하고, 재시도 로직을 처리하는 스케줄러가 추가되었습니다.
  • src/main/resources/db/migration/V5__create_notification_outbox.sql
    • notification_outbox 테이블을 생성하는 Flyway 마이그레이션 스크립트가 추가되었습니다.
  • src/test/java/com/todaysound/todaysound_server/support/isolation/TableNameExtractorImpl.java
    • 테스트 격리를 위해 notification_outbox 테이블 이름이 추가되었습니다.
Activity
  • 이 PR에 대한 추가적인 활동(댓글, 리뷰 등)은 제공되지 않았습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 2, 2026

Test Results

24 tests   12 ✅  0s ⏱️
10 suites   0 💤
10 files    12 ❌

For more details on these failures, see this check.

Results for commit aeec702.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request is an excellent improvement that enhances the reliability of the notification system by applying the Transactional Outbox pattern. However, the implementation introduces a significant Denial of Service (DoS) risk in the scheduler and lacks necessary authentication for the internal alert endpoint. The scheduler performs synchronous network calls within a long-running database transaction, which can lead to connection pool exhaustion. Additionally, the internal endpoint for receiving alerts from the crawler does not verify the identity of the caller, potentially allowing unauthorized users to send notifications or create data. I have also included some suggestions for performance, readability, and consistency improvements in the review.

Comment on lines +48 to +72
@Transactional
public void processOutbox() {
List<NotificationOutbox> pending =
outboxRepository.findByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING);

if (pending.isEmpty()) {
return;
}

log.info(BUSINESS, "Outbox 처리 시작 {}", kv("pendingCount", pending.size()));

for (NotificationOutbox outbox : pending) {
try {
User user = userRepository.findById(outbox.getUserId()).orElse(null);

if (user == null) {
// 탈퇴한 사용자: 재시도 없이 즉시 FAILED 처리
log.warn(BUSINESS, "Outbox 전송 대상 사용자 없음 (탈퇴) {} {}",
kv("outboxId", outbox.getId()),
kv("userId", outbox.getUserId()));
outbox.incrementRetry(1);
continue;
}

fcmService.sendNotificationToUser(user, outbox.getTitle(), outbox.getBody());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The processOutbox method is annotated with @Transactional and performs synchronous network calls to the FCM service within a loop. This is a major anti-pattern that can lead to database connection pool exhaustion, causing a Denial of Service (DoS) for the entire application. To remediate this, decouple the database transaction from the network call. Each outbox item should be processed in its own short-lived transaction, and the network call should occur outside of that transaction. Additionally, within this loop, there is an N+1 query issue with userRepository.findById() for each outbox item, which can further degrade performance when many notifications are processed. Consider fetching all necessary users in a single query using findAllById before the loop and storing them in a Map for efficient lookup.

Comment on lines 42 to 44
@PostMapping("/alerts")
@Transactional
public void createAlert(@RequestBody InternalAlertRequest request) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The /internal/alerts endpoint lacks any visible authentication or authorization mechanism. While there is a consistency check between subscriptionId and userId, it does not verify that the caller is an authorized internal service (e.g., the crawler). If this endpoint is exposed, an attacker could send arbitrary push notifications to any user or create unauthorized summaries. Implement a secure authentication mechanism, such as an internal API key or mutual TLS, and ensure the endpoint is protected at both the infrastructure and application levels.

Comment on lines +43 to +44
@Column(nullable = false)
private LocalDateTime createdAt;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

createdAt 필드를 LocalDateTime.now()로 수동 설정하고 있습니다. 프로젝트 내 다른 엔티티(User, Subscription 등)에서는 @CreationTimestamp@CreatedDate 같은 어노테이션을 사용하여 생성 시각을 자동으로 관리하고 있습니다.

코드의 일관성을 유지하고 프레임워크의 기능을 활용하기 위해, 이 엔티티에서도 어노테이션을 사용하여 createdAt을 자동으로 설정하는 것을 권장합니다. 이렇게 변경하면 create 팩토리 메서드에서 관련 코드를 제거할 수 있습니다.

Suggested change
@Column(nullable = false)
private LocalDateTime createdAt;
@org.hibernate.annotations.CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

outbox.body = body;
outbox.status = OutboxStatus.PENDING;
outbox.retryCount = 0;
outbox.createdAt = LocalDateTime.now();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

앞선 제안에 따라 createdAt 필드에 @CreationTimestamp 어노테이션을 적용하면, JPA가 엔티티를 저장할 때 자동으로 생성 시각을 주입해주므로 이 라인은 더 이상 필요하지 않습니다.

log.warn(BUSINESS, "Outbox 전송 대상 사용자 없음 (탈퇴) {} {}",
kv("outboxId", outbox.getId()),
kv("userId", outbox.getUserId()));
outbox.incrementRetry(1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

outbox.incrementRetry(1) 호출은 "즉시 실패 처리"라는 의도를 코드만으로 파악하기 어렵게 만듭니다. 이 로직은 의도대로 동작하지만 가독성과 유지보수성을 위해 개선할 수 있습니다.

NotificationOutbox에 상태를 직접 FAILED로 설정하는 markAsFailed() 메서드를 추가하고, 여기서는 그 메서드를 호출하는 것이 더 명확합니다.

이를 위해서는 NotificationOutbox 엔티티에 다음과 같은 메서드 추가가 필요합니다.

// In NotificationOutbox.java
public void markAsFailed() {
    this.status = OutboxStatus.FAILED;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TableNameExtractorImpl.java을 추가한 이유가 뭘까요?
DatabaseManager에서 TableNameExtractor tableNameExtractor이 추가될 때 TableNameExtractor에 어떤걸 상속받을 지 몰라서 에러가 나는 것 같아요!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants