From 2018d0e6f79a45714b3dd4bb17376b81af7aa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= Date: Sat, 29 Aug 2026 00:08:55 +0900 Subject: [PATCH] docs: explain notification delivery flow --- .../domain/alarm/entity/NotificationDelivery.java | 3 +++ .../repository/NotificationDeliveryRepository.java | 11 +++++++++++ .../domain/alarm/service/InternalAlertService.java | 2 ++ .../alarm/service/NotificationDeliveryDispatcher.java | 6 ++++++ .../service/NotificationDeliveryRetryPolicy.java | 1 + .../alarm/service/NotificationDeliveryService.java | 4 ++++ .../infra/scheduler/SummaryCleanupScheduler.java | 1 + .../domain/user/repository/FCMRepository.java | 4 ++++ .../global/application/FCMService.java | 4 ++++ .../global/application/FcmTokenLifecycleService.java | 3 +++ 10 files changed, 39 insertions(+) diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationDelivery.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationDelivery.java index fb113bc..d2265d0 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationDelivery.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationDelivery.java @@ -97,6 +97,9 @@ public void claim(LocalDateTime claimedLeaseUntil) { this.leaseUntil = truncateToMicros(claimedLeaseUntil); } + /** + * 재선점 후 도착한 이전 워커의 응답이 현재 작업 상태를 덮지 못하도록 lease를 비교한다. + */ public boolean isClaimedWith(LocalDateTime claimedLeaseUntil) { return status == DeliveryStatus.PROCESSING && Objects.equals(leaseUntil, truncateToMicros(claimedLeaseUntil)); diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationDeliveryRepository.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationDeliveryRepository.java index b47f9e1..d2716e8 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationDeliveryRepository.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationDeliveryRepository.java @@ -14,6 +14,10 @@ public interface NotificationDeliveryRepository extends JpaRepository { + /** + * 동시에 들어온 크롤러 콜백이 같은 event-token 작업을 생성하더라도 + * unique 충돌로 트랜잭션을 롤백하지 않고 기존 작업을 유지한다. + */ @Modifying(flushAutomatically = true) @Query(value = """ INSERT INTO notification_deliveries ( @@ -42,6 +46,10 @@ int insertPendingIfAbsent( @Param("createdAt") LocalDateTime createdAt ); + /** + * 다른 워커가 잠근 행은 기다리지 않고 건너뛰며, lease가 만료된 작업은 다시 선점한다. + * 반환된 행의 잠금은 호출한 claimBatch() 트랜잭션이 끝날 때까지 유지된다. + */ @Query(value = """ SELECT delivery.id FROM notification_deliveries delivery @@ -77,6 +85,9 @@ List findEligibleIdsForUpdate( """) List findAllForDispatchByIdIn(@Param("ids") Collection ids); + /** + * 결과 반영과 재선점이 교차하지 않도록 행을 잠가 lease 확인과 상태 변경을 직렬화한다. + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" SELECT delivery diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/InternalAlertService.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/InternalAlertService.java index a332f47..2251a9c 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/InternalAlertService.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/InternalAlertService.java @@ -31,6 +31,7 @@ public class InternalAlertService { @Transactional public void createAlert(InternalAlertCommand command) { + // 동일 구독의 중복 콜백을 직렬화해 Summary 검사와 Outbox 생성을 한 임계 구역에서 처리한다. Subscription subscription = subscriptionRepository.findByIdForUpdate(command.subscriptionId()) .orElseThrow(() -> BaseException.type(CommonErrorCode.ENTITY_NOT_FOUND)); @@ -61,6 +62,7 @@ public void createAlert(InternalAlertCommand command) { return; } + // 구독이 달라도 같은 URL의 같은 게시글은 하나의 이벤트로 식별해 토큰별 중복 작업을 막는다. String eventId = CryptoUtils.sha256( subscription.getUrl().getId() + ":" + command.sitePostId()); LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryDispatcher.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryDispatcher.java index 8cb4784..d5d55c0 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryDispatcher.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryDispatcher.java @@ -18,6 +18,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +/** + * 선점 트랜잭션을 끝낸 뒤 FCM을 호출하고, 결과는 별도 트랜잭션으로 저장한다. + * 외부 호출 중에는 DB 잠금과 커넥션을 점유하지 않는다. FCM 성공 직후 프로세스가 종료되면 + * lease 만료 후 재발송될 수 있으므로 전체 전달 보장은 exactly-once가 아닌 at-least-once다. + */ @Slf4j @Component @RequiredArgsConstructor @@ -35,6 +40,7 @@ public int dispatchPendingDeliveries() { return 0; } + // 하나의 Multicast는 payload를 공유하므로 메시지 내용과 eventId가 모두 같은 작업만 묶는다. Map> groups = claimed.stream() .collect(Collectors.groupingBy( delivery -> new MessageKey( diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryRetryPolicy.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryRetryPolicy.java index 66584cc..1e67e9f 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryRetryPolicy.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryRetryPolicy.java @@ -39,6 +39,7 @@ Duration delay( if (retryAfter == null || retryAfter.isNegative()) { return backoff; } + // 서버 백오프보다 FCM Retry-After가 길면 공급자가 요구한 최소 대기 시간을 우선한다. return retryAfter.compareTo(backoff) > 0 ? retryAfter : backoff; } diff --git a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryService.java b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryService.java index 5a8826b..b6e1c97 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryService.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryService.java @@ -27,6 +27,7 @@ @RequiredArgsConstructor public class NotificationDeliveryService { + // 최초 전송 1회와 최대 3회의 재시도를 합한 횟수다. public static final int MAX_ATTEMPTS = 4; public static final int MAX_BATCH_SIZE = 500; private static final Duration LEASE_DURATION = Duration.ofMinutes(5); @@ -46,6 +47,7 @@ public List claimBatch(int requestedBatchSize) { public List claimBatch(int requestedBatchSize, LocalDateTime requestedAt) { int batchSize = Math.max(1, Math.min(requestedBatchSize, MAX_BATCH_SIZE)); LocalDateTime now = truncateToMicros(requestedAt); + // 후보 행 잠금부터 PROCESSING 전환까지 한 트랜잭션으로 묶어 선점을 원자적으로 만든다. List eligibleIds = deliveryRepository.findEligibleIdsForUpdate(now, batchSize); if (eligibleIds.isEmpty()) { return List.of(); @@ -118,6 +120,7 @@ public void applyResults(Collection results, LocalDateTime compl (first, ignored) -> first, LinkedHashMap::new )); + // 행 잠금 아래에서 lease를 검사해야 늦은 결과와 만료 작업의 재선점이 서로 덮어쓰지 않는다. List processingDeliveries = deliveryRepository.findAllByStatusAndIdIn( DeliveryStatus.PROCESSING, resultByDeliveryId.keySet() @@ -145,6 +148,7 @@ private void applyResult(NotificationDelivery delivery, DeliveryResult result, L String errorCode = normalizedErrorCode(result.errorCode()); if (result.unregistered()) { delivery.markFailed(errorCode); + // 발송 중 토큰이 갱신됐을 수 있으므로 실제 시도한 토큰과 현재 값이 같을 때만 끈다. int deactivatedCount = fcmRepository.deactivateIfTokenMatches( delivery.getFcmToken().getId(), result.attemptedToken(), diff --git a/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java b/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java index 9484c3b..a29615e 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java @@ -18,6 +18,7 @@ @RequiredArgsConstructor public class SummaryCleanupScheduler { + // Summary의 cascade 삭제로 미완료 발송 작업이 사라지지 않도록 정리 대상에서 제외한다. private static final Set IN_FLIGHT_DELIVERY_STATUSES = Set.of( DeliveryStatus.PENDING, DeliveryStatus.PROCESSING, diff --git a/src/main/java/com/todaysound/todaysound_server/domain/user/repository/FCMRepository.java b/src/main/java/com/todaysound/todaysound_server/domain/user/repository/FCMRepository.java index 4c9382e..3066ccf 100644 --- a/src/main/java/com/todaysound/todaysound_server/domain/user/repository/FCMRepository.java +++ b/src/main/java/com/todaysound/todaysound_server/domain/user/repository/FCMRepository.java @@ -16,6 +16,10 @@ public interface FCMRepository extends JpaRepository { FCM_Token findByUserId(Long userId); + /** + * 발송 당시 토큰과 현재 토큰이 같을 때만 비활성화해, + * 늦은 UNREGISTERED 응답이 이미 갱신된 토큰을 끄지 않게 한다. + */ @Modifying @Query(""" UPDATE FCM_Token token diff --git a/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java b/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java index f200680..ca3730c 100644 --- a/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java +++ b/src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java @@ -118,6 +118,7 @@ public List sendMulticast( ApnsConfig apnsConfig = ApnsConfig.builder() .putHeader("apns-priority", "10") + // APNs에 대기 중인 동일 이벤트의 병합을 요청하며 이미 표시된 알림까지 제거하지는 않는다. .putHeader("apns-collapse-id", eventId) .setAps(Aps.builder().setSound("default").setBadge(1).build()) .build(); @@ -125,6 +126,7 @@ public List sendMulticast( MulticastMessage message = MulticastMessage.builder() .setNotification(notification) .setApnsConfig(apnsConfig) + // 클라이언트가 eventId를 기준으로 중복 표시를 방지할 수 있도록 함께 전달한다. .putData("eventId", eventId) .addAllTokens(targets.stream().map(FcmTarget::token).toList()) .build(); @@ -158,6 +160,7 @@ private List mapResponse(BatchResponse response, List List responses = response == null ? null : response.getResponses(); List results = new ArrayList<>(targets.size()); + // Admin SDK가 입력 토큰과 응답 순서를 보존하므로 같은 index의 발송 건에 결과를 대응한다. for (int index = 0; index < targets.size(); index++) { FcmTarget target = targets.get(index); if (responses == null || index >= responses.size() || responses.get(index) == null) { @@ -237,6 +240,7 @@ private FcmSendResult failureResult(FcmTarget target, FirebaseMessagingException ); } + /** Retry-After의 delta-seconds와 HTTP-date 형식을 모두 지연 시간으로 변환한다. */ private Duration retryAfterOf(FirebaseMessagingException exception) { if (exception.getHttpResponse() == null) { return null; diff --git a/src/main/java/com/todaysound/todaysound_server/global/application/FcmTokenLifecycleService.java b/src/main/java/com/todaysound/todaysound_server/global/application/FcmTokenLifecycleService.java index facbd8e..f468587 100644 --- a/src/main/java/com/todaysound/todaysound_server/global/application/FcmTokenLifecycleService.java +++ b/src/main/java/com/todaysound/todaysound_server/global/application/FcmTokenLifecycleService.java @@ -14,6 +14,9 @@ public class FcmTokenLifecycleService { private final FCMRepository fcmRepository; + /** + * 트랜잭션 없이 실행되는 직접 발송 경로에서도 토큰 무효화만 독립적으로 커밋한다. + */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void deactivateAllIfTokenMatches(Collection attemptedTokens) { if (attemptedTokens.isEmpty()) {