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
59 changes: 54 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,62 @@ domain/{name}/
## 서비스 의존성

```
ItemService → ItemRepository, S3Service
MemberService → MemberRepository, TokenProvider, PayerService
NotificationService → NotificationRepository, FCMService, MemberService
PayerService → PayerRepository, MemberRepository, ExcelGenerator
RentalService → RentalRepository, NotificationService
ItemService → ItemRepository, S3Service
MemberService → MemberRepository, TokenProvider, PayerService
NotificationService → NotificationRepository, NotificationPushOutboxService
NotificationPushOutboxService → NotificationPushOutboxRepository
PushNotificationSender → FCMService, MemberService, NotificationPushOutboxService
PayerService → PayerRepository, MemberRepository, ExcelGenerator
RentalService → RentalRepository, ApplicationEventPublisher
```

### 알림 발송 구조

`RentalService`가 이벤트를 발행하면 `NotificationEventHandler`가 커밋 이후 비동기로 받아 처리한다.

```
RentalService → 이벤트 발행
└ NotificationEventHandler (@Async + AFTER_COMMIT, 트랜잭션 없음)
├ NotificationService.createNotification() # 알림 + 아웃박스 저장 (한 트랜잭션, 즉시 커밋)
└ PushNotificationSender.dispatch() # 트랜잭션 밖에서 FCM 호출

PushRetryScheduler (@Scheduled, 30초 간격)
└ PushNotificationSender.dispatch() # 미발송 건 재시도 (같은 경로)
```

- **푸시 발송은 트랜잭션 밖에서 수행한다** — 네트워크 I/O가 DB 커넥션을 점유하지 않도록, 그리고 푸시 실패가 저장된 알림을 롤백시키지 않도록 분리
- **`PushNotificationSender`는 예외를 전파하지 않는다** — 한 수신자의 실패가 다른 수신자에게 영향을 주면 안 됨
- **FCM 실패는 `PushResult`로 구분한다** — `InvalidToken`(토큰 제거) / `Retryable`(재시도 대상) / `Permanent`(재시도 무의미)
- **`@Async`는 알림 전용 실행기(`notificationTaskExecutor`)를 사용한다** — `AsyncConfig`에 정의

### 푸시 재시도 (아웃박스)

발송 대상을 `notification_push_outbox`에 **수신자 단위 row**로 남긴다. 알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남는다.

```
PENDING ─┬─ 발송 성공 ──────────────→ SENT
├─ Retryable 실패 ─ 백오프 → PENDING (재시도 횟수 소진 시 FAILED)
├─ InvalidToken/Permanent → FAILED
└─ 생성 후 1시간 경과 ─────→ EXPIRED
```

- **백오프는 `30초 → 2분 → 5분 → 15분`, 최대 4회** — `NotificationPushOutbox`의 상수로 정의
- **생성 후 1시간이 지나면 포기한다(`EXPIRED`)** — 늦게 도착하는 푸시는 의미가 없다. 인앱 알림은 이미 저장돼 있음
- **즉시 발송과 재시도가 같은 경로를 탄다** — 새 row의 `nextRetryAt`은 60초 뒤로 잡혀, 즉시 시도와 폴러가 겹치지 않는다
- **메시지 본문은 저장하지 않는다** — 연결된 `Notification`의 status와 formatValues로 재구성
- 인스턴스를 여러 대로 늘리면 조회에 잠금(`FOR UPDATE SKIP LOCKED`)이나 ShedLock이 필요하다

**보관 정책** — `PushOutboxPurgeScheduler`가 매일 새벽 4시(KST)에 정리한다.

| 상태 | 보존 기간 |
|---|---|
| `SENT` | 7일 |
| `FAILED`, `EXPIRED` | 30일 (실패 원인 확인용) |
| `PENDING` | 삭제하지 않음 |

- **배치(500건)로 나눠 삭제하고 배치마다 트랜잭션을 끊는다** — 락 구간을 짧게 유지
- **한 회 처리량 상한(20배치)에 도달하면 경고 로그를 남긴다** — 남은 건이 있다는 사실이 묻히지 않도록

## 대여 상태 머신

```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package site.billilge.api.backend.domain.notification.dto

import site.billilge.api.backend.domain.notification.enums.NotificationStatus

/**
* 아웃박스 한 건을 발송하는 데 필요한 값.
*
* FCM 호출은 트랜잭션 밖에서 이뤄지므로 엔티티 대신 이 값만 꺼내 넘긴다.
*/
data class PushDispatchTarget(
val outboxId: Long,
val receiverId: Long,
val studentId: String,
val fcmToken: String?,
val status: NotificationStatus,
val formatValues: List<String>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package site.billilge.api.backend.domain.notification.entity

import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import site.billilge.api.backend.domain.member.entity.Member
import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus
import java.time.Duration
import java.time.LocalDateTime

/**
* 푸시 발송 대기열.
*
* 알림 저장과 같은 트랜잭션에서 수신자 수만큼 생성된다. 발송 실패 시 상태와 재시도 시각만
* 갱신되므로 프로세스가 재시작돼도 발송 대상이 남는다.
*
* 메시지 본문은 저장하지 않는다 — 연결된 [Notification]의 status와 formatValues로 재구성한다.
*/
@Entity
@Table(
name = "notification_push_outbox",
indexes = [
Index(name = "idx_push_outbox_delivery", columnList = "delivery_status, next_retry_at"),
Index(name = "idx_push_outbox_purge", columnList = "delivery_status, created_at"),
]
)
@EntityListeners(AuditingEntityListener::class)
class NotificationPushOutbox(
@JoinColumn(name = "notification_id", nullable = false)
@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
val notification: Notification,

@JoinColumn(name = "receiver_id", nullable = false)
@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
val receiver: Member,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "notification_push_outbox_id", nullable = false)
val id: Long? = null

@Enumerated(EnumType.STRING)
@Column(name = "delivery_status", nullable = false)
var deliveryStatus: PushDeliveryStatus = PushDeliveryStatus.PENDING
protected set

@Column(name = "retry_count", nullable = false)
var retryCount: Int = 0
protected set

/**
* 폴러는 이 시각이 지난 건만 집어간다.
*
* 생성 직후에는 이벤트 핸들러가 즉시 1회 발송을 시도하므로, 그 시도와 폴러가 겹쳐
* 중복 발송되지 않도록 첫 대상 시각을 뒤로 미뤄 둔다.
*/
@Column(name = "next_retry_at", nullable = false)
var nextRetryAt: LocalDateTime = LocalDateTime.now().plus(FIRST_POLL_DELAY)
protected set

@Column(name = "last_error", length = MAX_ERROR_LENGTH)
var lastError: String? = null
protected set

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
var createdAt: LocalDateTime = LocalDateTime.now()
protected set

@LastModifiedDate
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
protected set

fun markSent() {
deliveryStatus = PushDeliveryStatus.SENT
lastError = null
}

/** 재시도해도 결과가 같은 실패 — 더 시도하지 않는다 */
fun markFailed(reason: String) {
deliveryStatus = PushDeliveryStatus.FAILED
lastError = reason.take(MAX_ERROR_LENGTH)
}

fun markExpired() {
deliveryStatus = PushDeliveryStatus.EXPIRED
}

/**
* 재시도 가능한 실패를 기록하고 다음 시도 시각을 뒤로 민다.
* 재시도 횟수를 모두 썼거나 유효 시간이 지났으면 발송을 포기한다.
*/
fun recordRetryableFailure(reason: String, now: LocalDateTime = LocalDateTime.now()) {
lastError = reason.take(MAX_ERROR_LENGTH)

if (isExpired(now)) {
markExpired()
return
}

if (retryCount >= BACKOFF_SECONDS.size) {
deliveryStatus = PushDeliveryStatus.FAILED
return
}

nextRetryAt = now.plusSeconds(BACKOFF_SECONDS[retryCount])
retryCount++
}

fun isPending(): Boolean = deliveryStatus == PushDeliveryStatus.PENDING

fun isExpired(now: LocalDateTime = LocalDateTime.now()): Boolean =
now.isAfter(createdAt.plus(TIME_TO_LIVE))

companion object {
/** 즉시 발송 시도와 폴러가 겹치지 않도록 두는 간격 */
private val FIRST_POLL_DELAY: Duration = Duration.ofSeconds(60)

/** 늦게 도착하는 푸시는 의미가 없으므로 1시간까지만 재시도한다 */
private val TIME_TO_LIVE: Duration = Duration.ofHours(1)

/** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수 */
private val BACKOFF_SECONDS = longArrayOf(30, 120, 300, 900)

private const val MAX_ERROR_LENGTH = 500
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package site.billilge.api.backend.domain.notification.enums

enum class PushDeliveryStatus {
/** 발송 대기 — 폴러가 재시도 대상으로 집어간다 */
PENDING,

/** 발송 완료 */
SENT,

/** 재시도 횟수를 모두 썼거나 재시도해도 소용없는 실패 */
FAILED,

/** 유효 시간이 지나 발송을 포기함 — 늦게 도착하는 푸시는 의미가 없다 */
EXPIRED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,42 @@ package site.billilge.api.backend.domain.notification.handler

import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import site.billilge.api.backend.domain.member.entity.Member
import site.billilge.api.backend.domain.member.service.MemberService
import site.billilge.api.backend.domain.notification.enums.NotificationStatus
import site.billilge.api.backend.domain.notification.service.NotificationService
import site.billilge.api.backend.domain.notification.service.PushNotificationSender
import site.billilge.api.backend.domain.rental.enums.RentalStatus
import site.billilge.api.backend.domain.rental.event.*

/**
* 알림 저장(DB)과 푸시 발송(FCM)을 분리해 호출한다.
*
* 핸들러 자체에는 트랜잭션을 걸지 않는다. 알림 저장은 NotificationService의 짧은 트랜잭션에서
* 즉시 커밋되고, 그 뒤 트랜잭션 밖에서 푸시를 보낸다. 푸시가 실패해도 저장된 알림은 남는다.
*/
@Component
class NotificationEventHandler(
private val notificationService: NotificationService,
private val pushNotificationSender: PushNotificationSender,
private val memberService: MemberService,
) {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleRentalApplied(event: RentalAppliedEvent) {
val member = memberService.findById(event.memberId)

notificationService.sendNotification(
notifyUser(
member,
NotificationStatus.USER_RENTAL_APPLY,
listOf(event.itemName),
needPush = true,
)

if (!event.isDevMode) {
notificationService.sendNotificationToAdmin(
notifyAdmins(
NotificationStatus.ADMIN_RENTAL_APPLY,
listOf(
member.name,
Expand All @@ -45,40 +51,37 @@ class NotificationEventHandler(
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleRentalCancelled(event: RentalCancelledEvent) {
val member = memberService.findById(event.memberId)

notificationService.sendNotificationToAdmin(
notifyAdmins(
NotificationStatus.ADMIN_RENTAL_CANCEL,
listOf(member.name, member.studentId, event.itemName),
needPush = true,
)
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleReturnApplied(event: ReturnAppliedEvent) {
val member = memberService.findById(event.memberId)

notificationService.sendNotification(
notifyUser(
member,
NotificationStatus.USER_RETURN_APPLY,
listOf(event.itemName),
needPush = true,
)

notificationService.sendNotificationToAdmin(
notifyAdmins(
NotificationStatus.ADMIN_RETURN_APPLY,
listOf(member.name, member.studentId, event.itemName),
needPush = true,
)
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleRentalStatusChanged(event: RentalStatusChangedEvent) {
val member = memberService.findById(event.memberId)
Expand All @@ -91,6 +94,36 @@ class NotificationEventHandler(
else -> return
}

notificationService.sendNotification(member, notificationStatus, listOf(event.itemName), needPush)
notifyUser(member, notificationStatus, listOf(event.itemName), needPush)
}

private fun notifyUser(
member: Member,
status: NotificationStatus,
formatValues: List<String>,
needPush: Boolean,
) {
val outboxIds = notificationService.createNotification(
member,
status,
formatValues,
pushReceivers = if (needPush) listOf(member) else emptyList(),
)

pushNotificationSender.dispatch(outboxIds)
}

private fun notifyAdmins(
status: NotificationStatus,
formatValues: List<String>,
needPush: Boolean,
) {
val outboxIds = notificationService.createAdminNotification(
status,
formatValues,
pushReceivers = if (needPush) memberService.findAllWorkers() else emptyList(),
)

pushNotificationSender.dispatch(outboxIds)
}
}
Loading
Loading