-
Notifications
You must be signed in to change notification settings - Fork 1
[fix] Transactional Outbox 패턴 적용해서 알림 로직 수정 #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||
| package com.todaysound.todaysound_server.domain.alarm.entity; | ||||||||||||
|
|
||||||||||||
| import com.todaysound.todaysound_server.global.entity.BaseEntity; | ||||||||||||
| import jakarta.persistence.Column; | ||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||
| import jakarta.persistence.EnumType; | ||||||||||||
| import jakarta.persistence.Enumerated; | ||||||||||||
| import jakarta.persistence.Table; | ||||||||||||
| import java.time.LocalDateTime; | ||||||||||||
| import lombok.AccessLevel; | ||||||||||||
| import lombok.Getter; | ||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Transactional Outbox 패턴 구현체 | ||||||||||||
| * | ||||||||||||
| * FCM 알림을 직접 전송하는 대신 이 테이블에 "전송할 것"을 기록한다. | ||||||||||||
| * Summary 저장과 같은 트랜잭션 내에서 저장되므로 둘 다 커밋되거나 둘 다 롤백된다. | ||||||||||||
| * NotificationOutboxScheduler 가 주기적으로 PENDING 건을 읽어 FCM 전송을 수행한다. | ||||||||||||
| */ | ||||||||||||
| @Entity | ||||||||||||
| @Getter | ||||||||||||
| @Table(name = "notification_outbox") | ||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||
| public class NotificationOutbox extends BaseEntity { | ||||||||||||
|
|
||||||||||||
| @Column(nullable = false) | ||||||||||||
| private Long userId; | ||||||||||||
|
|
||||||||||||
| @Column(nullable = false) | ||||||||||||
| private String title; | ||||||||||||
|
|
||||||||||||
| @Column(nullable = false, columnDefinition = "TEXT") | ||||||||||||
| private String body; | ||||||||||||
|
|
||||||||||||
| @Enumerated(EnumType.STRING) | ||||||||||||
| @Column(nullable = false, length = 20) | ||||||||||||
| private OutboxStatus status; | ||||||||||||
|
|
||||||||||||
| @Column(nullable = false) | ||||||||||||
| private int retryCount; | ||||||||||||
|
|
||||||||||||
| @Column(nullable = false) | ||||||||||||
| private LocalDateTime createdAt; | ||||||||||||
|
Comment on lines
+43
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
코드의 일관성을 유지하고 프레임워크의 기능을 활용하기 위해, 이 엔티티에서도 어노테이션을 사용하여
Suggested change
|
||||||||||||
|
|
||||||||||||
| public static NotificationOutbox create(Long userId, String title, String body) { | ||||||||||||
| NotificationOutbox outbox = new NotificationOutbox(); | ||||||||||||
| outbox.userId = userId; | ||||||||||||
| outbox.title = title; | ||||||||||||
| outbox.body = body; | ||||||||||||
| outbox.status = OutboxStatus.PENDING; | ||||||||||||
| outbox.retryCount = 0; | ||||||||||||
| outbox.createdAt = LocalDateTime.now(); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||
| return outbox; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| public void markAsSent() { | ||||||||||||
| this.status = OutboxStatus.SENT; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * 재시도 횟수를 증가시키고, maxRetry 초과 시 FAILED 로 전환한다. | ||||||||||||
| */ | ||||||||||||
| public void incrementRetry(int maxRetry) { | ||||||||||||
| this.retryCount++; | ||||||||||||
| if (this.retryCount >= maxRetry) { | ||||||||||||
| this.status = OutboxStatus.FAILED; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.todaysound.todaysound_server.domain.alarm.entity; | ||
|
|
||
| public enum OutboxStatus { | ||
| PENDING, // 전송 대기 | ||
| SENT, // 전송 완료 | ||
| FAILED // 최대 재시도 횟수 초과 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.todaysound.todaysound_server.domain.alarm.repository; | ||
|
|
||
| import com.todaysound.todaysound_server.domain.alarm.entity.NotificationOutbox; | ||
| import com.todaysound.todaysound_server.domain.alarm.entity.OutboxStatus; | ||
| import java.util.List; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface NotificationOutboxRepository extends JpaRepository<NotificationOutbox, Long> { | ||
|
|
||
| List<NotificationOutbox> findByStatusOrderByCreatedAtAsc(OutboxStatus status); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| package com.todaysound.todaysound_server.domain.alarm.scheduler; | ||
|
|
||
| import static com.todaysound.todaysound_server.global.utils.LogMarkers.BUSINESS; | ||
| import static net.logstash.logback.argument.StructuredArguments.kv; | ||
|
|
||
| import com.todaysound.todaysound_server.domain.alarm.entity.NotificationOutbox; | ||
| import com.todaysound.todaysound_server.domain.alarm.entity.OutboxStatus; | ||
| import com.todaysound.todaysound_server.domain.alarm.repository.NotificationOutboxRepository; | ||
| import com.todaysound.todaysound_server.domain.user.entity.User; | ||
| import com.todaysound.todaysound_server.domain.user.repository.UserRepository; | ||
| import com.todaysound.todaysound_server.global.application.FCMService; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| /** | ||
| * Transactional Outbox 패턴의 소비자(Consumer) 역할. | ||
| * | ||
| * notification_outbox 테이블에서 PENDING 상태인 항목을 주기적으로 읽어 FCM 전송을 수행한다. | ||
| * FCM 전송에 실패하면 retryCount 를 증가시키며, MAX_RETRY 초과 시 FAILED 로 전환한다. | ||
| * | ||
| * [처리 흐름] | ||
| * PENDING → (전송 성공) → SENT | ||
| * PENDING → (전송 실패 × MAX_RETRY) → FAILED | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class NotificationOutboxScheduler { | ||
|
|
||
| private static final int MAX_RETRY = 3; | ||
|
|
||
| private final NotificationOutboxRepository outboxRepository; | ||
| private final FCMService fcmService; | ||
| private final UserRepository userRepository; | ||
|
|
||
| /** | ||
| * 5초마다 PENDING 상태인 outbox 항목을 처리한다. | ||
| * | ||
| * FCMService.sendNotificationToUser() 는 내부적으로 FirebaseMessagingException 을 catch 하므로 | ||
| * Firebase 장애 시에도 예외가 전파되지 않는다. 단, DB 접근 실패 등 인프라 레벨 오류는 | ||
| * 트랜잭션 전체를 롤백시켜 다음 주기에 재처리된다. | ||
| */ | ||
| @Scheduled(fixedDelay = 5000) | ||
| @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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이를 위해서는 // In NotificationOutbox.java
public void markAsFailed() {
this.status = OutboxStatus.FAILED;
} |
||
| continue; | ||
| } | ||
|
|
||
| fcmService.sendNotificationToUser(user, outbox.getTitle(), outbox.getBody()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| outbox.markAsSent(); | ||
|
|
||
| log.info(BUSINESS, "Outbox FCM 전송 성공 {} {}", | ||
| kv("outboxId", outbox.getId()), | ||
| kv("userId", outbox.getUserId())); | ||
|
|
||
| } catch (Exception e) { | ||
| log.warn(BUSINESS, "Outbox FCM 전송 실패 {} {} {}", | ||
| kv("outboxId", outbox.getId()), | ||
| kv("retryCount", outbox.getRetryCount()), | ||
| kv("error", e.getMessage())); | ||
|
|
||
| outbox.incrementRetry(MAX_RETRY); | ||
|
|
||
| if (outbox.getStatus() == OutboxStatus.FAILED) { | ||
| log.error(BUSINESS, "Outbox 최대 재시도 초과, FAILED 처리 {} {}", | ||
| kv("outboxId", outbox.getId()), | ||
| kv("userId", outbox.getUserId())); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| -- notification_outbox: Transactional Outbox 테이블 생성 | ||
| CREATE TABLE notification_outbox | ||
| ( | ||
| id BIGINT AUTO_INCREMENT PRIMARY KEY, | ||
| user_id BIGINT NOT NULL, | ||
| title VARCHAR(255) NOT NULL, | ||
| body TEXT NOT NULL, | ||
| status VARCHAR(20) NOT NULL, | ||
| retry_count INT NOT NULL, | ||
| created_at DATETIME(6) NOT NULL | ||
| ) ENGINE=InnoDB | ||
| DEFAULT CHARSET = utf8mb4; | ||
|
|
||
| -- 조회 최적화를 위한 인덱스 (PENDING + created_at 순) | ||
| CREATE INDEX idx_notification_outbox_status_created_at | ||
| ON notification_outbox (status, created_at); |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.todaysound.todaysound_server.support.isolation; | ||
|
|
||
| import java.util.List; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| class TableNameExtractorImpl implements TableNameExtractor { | ||
|
|
||
| @Override | ||
| public List<String> getNames() { | ||
| return List.of( | ||
| "keywords", | ||
| "urls", | ||
| "users", | ||
| "fcm_tokens", | ||
| "subscriptions", | ||
| "subscriptions_keywords", | ||
| "summaries", | ||
| "notification_outbox" | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
/internal/alertsendpoint lacks any visible authentication or authorization mechanism. While there is a consistency check betweensubscriptionIdanduserId, 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.