Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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이 필요하다
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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를 비운다`() {
Expand All @@ -82,7 +110,7 @@ class NotificationPushOutboxTest {

outbox.markSent()

assertTrue(!outbox.isPending())
assertFalse(outbox.isPending())
}

private fun createOutbox(): NotificationPushOutbox {
Expand Down
Loading