Skip to content

Commit da4a161

Browse files
authored
Merge pull request #149 from billilge/fix/#148
fix: 푸시 유효 시간을 10분으로 단축
2 parents 644f746 + 1f54af0 commit da4a161

3 files changed

Lines changed: 53 additions & 11 deletions

File tree

‎CLAUDE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,9 @@ PENDING ─┬─ 발송 성공 ──────────────→ SE
111111
└─ 생성 후 1시간 경과 ─────→ EXPIRED
112112
```
113113

114-
- **백오프는 `30초 → 2분 → 5분 → 15분`, 최대 4회** — `NotificationPushOutbox`의 상수로 정의
115-
- **생성 후 1시간이 지나면 포기한다(`EXPIRED`)** — 늦게 도착하는 푸시는 의미가 없다. 인앱 알림은 이미 저장돼 있음
114+
- **유효 시간(TTL)은 10분** — 사용자 알림은 지금 과방에 갈지를 결정하는 정보고, 관리자 알림도 학생이 기다리는 상태에서 처리해야 하는 일이라 둘 다 실시간성이 중요하다. 지나면 `EXPIRED`로 포기하며, 인앱 알림은 이미 저장돼 있으므로 정보가 사라지는 것은 아니다
115+
- **백오프는 `30초 → 2분 → 5분`, 최대 3회** — 누적 7분 30초로 유효 시간 안에 들어온다
116+
- **다음 재시도 시각이 TTL을 넘기면 예약하지 않고 즉시 포기한다** — 어차피 만료될 시도를 기다리지 않는다
116117
- **즉시 발송과 재시도가 같은 경로를 탄다** — 새 row의 `nextRetryAt`은 60초 뒤로 잡혀, 즉시 시도와 폴러가 겹치지 않는다
117118
- **메시지 본문은 저장하지 않는다** — 연결된 `Notification`의 status와 formatValues로 재구성
118119
- 인스턴스를 여러 대로 늘리면 조회에 잠금(`FOR UPDATE SKIP LOCKED`)이나 ShedLock이 필요하다

‎src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,15 @@ class NotificationPushOutbox(
109109
return
110110
}
111111

112-
nextRetryAt = now.plusSeconds(BACKOFF_SECONDS[retryCount])
112+
val nextAttemptAt = now.plusSeconds(BACKOFF_SECONDS[retryCount])
113+
114+
// 다음 시도 시각이 이미 유효 시간을 넘긴다면 기다릴 이유가 없다
115+
if (isExpired(nextAttemptAt)) {
116+
markExpired()
117+
return
118+
}
119+
120+
nextRetryAt = nextAttemptAt
113121
retryCount++
114122
}
115123

@@ -122,11 +130,16 @@ class NotificationPushOutbox(
122130
/** 즉시 발송 시도와 폴러가 겹치지 않도록 두는 간격 */
123131
private val FIRST_POLL_DELAY: Duration = Duration.ofSeconds(60)
124132

125-
/** 늦게 도착하는 푸시는 의미가 없으므로 1시간까지만 재시도한다 */
126-
private val TIME_TO_LIVE: Duration = Duration.ofHours(1)
133+
/**
134+
* 늦게 도착하는 푸시는 의미가 없으므로 10분까지만 재시도한다.
135+
*
136+
* 사용자 알림은 지금 과방에 갈지를 결정하는 정보고, 관리자 알림도 학생이 기다리는
137+
* 상태에서 처리해야 하는 일이라 둘 다 실시간성이 중요하다.
138+
*/
139+
private val TIME_TO_LIVE: Duration = Duration.ofMinutes(10)
127140

128-
/** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수 */
129-
private val BACKOFF_SECONDS = longArrayOf(30, 120, 300, 900)
141+
/** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수. 누적 7분 30초로 유효 시간 안에 들어온다 */
142+
private val BACKOFF_SECONDS = longArrayOf(30, 120, 300)
130143

131144
private const val MAX_ERROR_LENGTH = 500
132145
}

‎src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package site.billilge.api.backend.domain.notification.entity
22

33
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Assertions.assertFalse
45
import org.junit.jupiter.api.Assertions.assertTrue
56
import org.junit.jupiter.api.DisplayName
67
import org.junit.jupiter.api.Test
@@ -26,7 +27,7 @@ class NotificationPushOutboxTest {
2627
val outbox = createOutbox()
2728
val now = LocalDateTime.now()
2829

29-
val expectedBackoffSeconds = listOf(30L, 120L, 300L, 900L)
30+
val expectedBackoffSeconds = listOf(30L, 120L, 300L)
3031

3132
expectedBackoffSeconds.forEachIndexed { index, seconds ->
3233
outbox.recordRetryableFailure("UNAVAILABLE", now)
@@ -37,13 +38,24 @@ class NotificationPushOutboxTest {
3738
}
3839
}
3940

41+
@Test
42+
@DisplayName("재시도는 모두 유효 시간(10분) 안에서 이뤄진다")
43+
fun `마지막 재시도 시각이 유효 시간을 넘지 않는다`() {
44+
val outbox = createOutbox()
45+
val now = LocalDateTime.now()
46+
47+
repeat(3) { outbox.recordRetryableFailure("UNAVAILABLE", now) }
48+
49+
assertFalse(outbox.isExpired(outbox.nextRetryAt))
50+
}
51+
4052
@Test
4153
@DisplayName("재시도 횟수를 모두 쓰면 발송을 포기한다")
4254
fun `최대 재시도 횟수를 넘기면 FAILED가 된다`() {
4355
val outbox = createOutbox()
4456
val now = LocalDateTime.now()
4557

46-
repeat(4) { outbox.recordRetryableFailure("UNAVAILABLE", now) }
58+
repeat(3) { outbox.recordRetryableFailure("UNAVAILABLE", now) }
4759
assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus)
4860

4961
outbox.recordRetryableFailure("UNAVAILABLE", now)
@@ -57,12 +69,28 @@ class NotificationPushOutboxTest {
5769
fun `TTL을 넘기면 EXPIRED가 된다`() {
5870
val outbox = createOutbox()
5971

60-
outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusHours(2))
72+
outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(11))
6173

6274
assertEquals(PushDeliveryStatus.EXPIRED, outbox.deliveryStatus)
6375
assertEquals(0, outbox.retryCount)
6476
}
6577

78+
@Test
79+
@DisplayName("다음 시도 시각이 이미 유효 시간을 넘기면 기다리지 않고 바로 포기한다")
80+
fun `유효 시간을 넘길 재시도는 예약하지 않는다`() {
81+
val outbox = createOutbox()
82+
83+
// 유효 시간 10분을 40초 남긴 시점의 실패 — 다음 간격(30초)은 들어가지만
84+
outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(9).plusSeconds(20))
85+
assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus)
86+
87+
// 20초 남은 시점의 실패 — 다음 간격(30초)이면 이미 만료다
88+
outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusMinutes(9).plusSeconds(40))
89+
90+
assertEquals(PushDeliveryStatus.EXPIRED, outbox.deliveryStatus)
91+
assertEquals(1, outbox.retryCount)
92+
}
93+
6694
@Test
6795
@DisplayName("발송에 성공하면 직전 실패 기록을 지운다")
6896
fun `markSent는 lastError를 비운다`() {
@@ -82,7 +110,7 @@ class NotificationPushOutboxTest {
82110

83111
outbox.markSent()
84112

85-
assertTrue(!outbox.isPending())
113+
assertFalse(outbox.isPending())
86114
}
87115

88116
private fun createOutbox(): NotificationPushOutbox {

0 commit comments

Comments
 (0)