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 {