From 1f54af04ad3c416f5261dfb753dc991a3144940e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:07:49 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=EC=9D=84=2010=EB=B6=84=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=95=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTL 1시간은 이 서비스의 알림 성격에 비해 지나치게 길다. 대여 승인은 사용자가 지금 과방에 갈지 결정하는 정보고, 관리자 알림도 학생이 기다리는 상태에서 처리해야 하는 일이라 늦게 도착하면 의미가 없다. - 유효 시간을 1시간에서 10분으로 단축 - 도달할 수 없게 된 15분 백오프 제거 (30초 → 2분 → 5분, 최대 3회, 누적 7분 30초) - 다음 재시도 시각이 유효 시간을 넘기면 예약하지 않고 즉시 EXPIRED 처리 (기존에는 만료될 것이 뻔한 시도를 예약해 두고 폴러가 집어간 뒤에야 만료를 확인했다) Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +-- .../entity/NotificationPushOutbox.kt | 23 +++++++++--- .../entity/NotificationPushOutboxTest.kt | 36 ++++++++++++++++--- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0532a97..949226a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,8 +111,9 @@ PENDING ─┬─ 발송 성공 ──────────────→ SE └─ 생성 후 1시간 경과 ─────→ EXPIRED ``` -- **백오프는 `30초 → 2분 → 5분 → 15분`, 최대 4회** — `NotificationPushOutbox`의 상수로 정의 -- **생성 후 1시간이 지나면 포기한다(`EXPIRED`)** — 늦게 도착하는 푸시는 의미가 없다. 인앱 알림은 이미 저장돼 있음 +- **유효 시간(TTL)은 10분** — 사용자 알림은 지금 과방에 갈지를 결정하는 정보고, 관리자 알림도 학생이 기다리는 상태에서 처리해야 하는 일이라 둘 다 실시간성이 중요하다. 지나면 `EXPIRED`로 포기하며, 인앱 알림은 이미 저장돼 있으므로 정보가 사라지는 것은 아니다 +- **백오프는 `30초 → 2분 → 5분`, 최대 3회** — 누적 7분 30초로 유효 시간 안에 들어온다 +- **다음 재시도 시각이 TTL을 넘기면 예약하지 않고 즉시 포기한다** — 어차피 만료될 시도를 기다리지 않는다 - **즉시 발송과 재시도가 같은 경로를 탄다** — 새 row의 `nextRetryAt`은 60초 뒤로 잡혀, 즉시 시도와 폴러가 겹치지 않는다 - **메시지 본문은 저장하지 않는다** — 연결된 `Notification`의 status와 formatValues로 재구성 - 인스턴스를 여러 대로 늘리면 조회에 잠금(`FOR UPDATE SKIP LOCKED`)이나 ShedLock이 필요하다 diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt index 257f4c2..ddb79f3 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt @@ -109,7 +109,15 @@ class NotificationPushOutbox( return } - nextRetryAt = now.plusSeconds(BACKOFF_SECONDS[retryCount]) + val nextAttemptAt = now.plusSeconds(BACKOFF_SECONDS[retryCount]) + + // 다음 시도 시각이 이미 유효 시간을 넘긴다면 기다릴 이유가 없다 + if (isExpired(nextAttemptAt)) { + markExpired() + return + } + + nextRetryAt = nextAttemptAt retryCount++ } @@ -122,11 +130,16 @@ class NotificationPushOutbox( /** 즉시 발송 시도와 폴러가 겹치지 않도록 두는 간격 */ private val FIRST_POLL_DELAY: Duration = Duration.ofSeconds(60) - /** 늦게 도착하는 푸시는 의미가 없으므로 1시간까지만 재시도한다 */ - private val TIME_TO_LIVE: Duration = Duration.ofHours(1) + /** + * 늦게 도착하는 푸시는 의미가 없으므로 10분까지만 재시도한다. + * + * 사용자 알림은 지금 과방에 갈지를 결정하는 정보고, 관리자 알림도 학생이 기다리는 + * 상태에서 처리해야 하는 일이라 둘 다 실시간성이 중요하다. + */ + private val TIME_TO_LIVE: Duration = Duration.ofMinutes(10) - /** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수 */ - private val BACKOFF_SECONDS = longArrayOf(30, 120, 300, 900) + /** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수. 누적 7분 30초로 유효 시간 안에 들어온다 */ + private val BACKOFF_SECONDS = longArrayOf(30, 120, 300) private const val MAX_ERROR_LENGTH = 500 } diff --git a/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt b/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt index 51fc4eb..c55f160 100644 --- a/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt +++ b/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt @@ -1,6 +1,7 @@ package site.billilge.api.backend.domain.notification.entity import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -26,7 +27,7 @@ class NotificationPushOutboxTest { val outbox = createOutbox() val now = LocalDateTime.now() - val expectedBackoffSeconds = listOf(30L, 120L, 300L, 900L) + val expectedBackoffSeconds = listOf(30L, 120L, 300L) expectedBackoffSeconds.forEachIndexed { index, seconds -> outbox.recordRetryableFailure("UNAVAILABLE", now) @@ -37,13 +38,24 @@ class NotificationPushOutboxTest { } } + @Test + @DisplayName("재시도는 모두 유효 시간(10분) 안에서 이뤄진다") + fun `마지막 재시도 시각이 유효 시간을 넘지 않는다`() { + val outbox = createOutbox() + val now = LocalDateTime.now() + + repeat(3) { outbox.recordRetryableFailure("UNAVAILABLE", now) } + + assertFalse(outbox.isExpired(outbox.nextRetryAt)) + } + @Test @DisplayName("재시도 횟수를 모두 쓰면 발송을 포기한다") fun `최대 재시도 횟수를 넘기면 FAILED가 된다`() { val outbox = createOutbox() val now = LocalDateTime.now() - repeat(4) { outbox.recordRetryableFailure("UNAVAILABLE", now) } + repeat(3) { outbox.recordRetryableFailure("UNAVAILABLE", now) } assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus) outbox.recordRetryableFailure("UNAVAILABLE", now) @@ -57,12 +69,28 @@ class NotificationPushOutboxTest { fun `TTL을 넘기면 EXPIRED가 된다`() { val outbox = createOutbox() - outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusHours(2)) + outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(11)) assertEquals(PushDeliveryStatus.EXPIRED, outbox.deliveryStatus) assertEquals(0, outbox.retryCount) } + @Test + @DisplayName("다음 시도 시각이 이미 유효 시간을 넘기면 기다리지 않고 바로 포기한다") + fun `유효 시간을 넘길 재시도는 예약하지 않는다`() { + val outbox = createOutbox() + + // 유효 시간 10분을 40초 남긴 시점의 실패 — 다음 간격(30초)은 들어가지만 + outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(9).plusSeconds(20)) + assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus) + + // 20초 남은 시점의 실패 — 다음 간격(30초)이면 이미 만료다 + outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(9).plusSeconds(40)) + + assertEquals(PushDeliveryStatus.EXPIRED, outbox.deliveryStatus) + assertEquals(1, outbox.retryCount) + } + @Test @DisplayName("발송에 성공하면 직전 실패 기록을 지운다") fun `markSent는 lastError를 비운다`() { @@ -82,7 +110,7 @@ class NotificationPushOutboxTest { outbox.markSent() - assertTrue(!outbox.isPending()) + assertFalse(outbox.isPending()) } private fun createOutbox(): NotificationPushOutbox {