Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum class CommonErrorCode(
METHOD_NOT_ALLOWED("COMMON-METHOD-NOT-ALLOWED", ErrorCategory.METHOD_NOT_ALLOWED, "지원하지 않는 요청 방식이에요."),
UNSUPPORTED_MEDIA_TYPE("COMMON-UNSUPPORTED-MEDIA-TYPE", ErrorCategory.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 형식이에요."),
INVALID_INPUT("COMMON-INVALID-INPUT", ErrorCategory.INVALID_INPUT, "요청 값을 다시 확인해 주세요."),
TOO_MANY_REQUESTS("COMMON-TOO-MANY-REQUESTS", ErrorCategory.TOO_MANY_REQUESTS, "요청이 너무 많아요. 잠시 후 다시 시도해 주세요."),
RETRYABLE("COMMON-RETRYABLE", ErrorCategory.RETRYABLE, "일시적인 오류예요. 잠시 후 다시 시도해 주세요."),
SERVER_BUSY("COMMON-SERVER-BUSY", ErrorCategory.SERVER_BUSY, "지금 요청이 많아요. 잠시 후 다시 시도해 주세요."),
SERVER_ERROR("COMMON-SERVER-ERROR", ErrorCategory.SERVER_ERROR, "서버에 문제가 발생했어요. 불편을 드려 죄송해요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ enum class ErrorCategory(
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 형식이에요."),
CONFLICT(HttpStatus.CONFLICT, "요청을 처리하지 못했어요. 다시 확인해 주세요."),

// 429 — 사용자·리소스 단위 사용량 한도를 넘긴 경우(#339). 5xx 의 SERVER_BUSY(503, 서버 전체가 바쁨)와 다르다:
// 이쪽은 서버가 멀쩡하고 "이 요청자의 몫"만 소진된 상태라 4xx 이며, 기다리면 창이 갱신돼 다시 쓸 수 있다.
TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많아요. 잠시 후 다시 시도해 주세요."),

// RETRYABLE·SERVER_BUSY·SERVER_ERROR 는 셋 다 5xx 지만 클라의 재시도 방식이 다르다. description 을 구분해
// 재시도 여부 오해를 없앤다: RETRYABLE=즉시 재시도, SERVER_BUSY=대기 후 재시도, SERVER_ERROR=재시도 무의미.
RETRYABLE(HttpStatus.BAD_GATEWAY, "일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
errorCode
?.let { ApiResponseBody.fail<Nothing>(it, e.message) }
?: ApiResponseBody.fail(category, e.message)
// 재시도 시점을 아는 예외(RetryAfter)만 Retry-After 를 싣는다 — 429 한도 초과가 현재 유일한 구현체다.
// 모르는 예외에 0 같은 거짓 대기값을 실어 클라가 즉시 재시도하게 만들지 않도록 타입으로 가린다.
val headers = HttpHeaders()
(e as? RetryAfter)?.let { headers.set(HttpHeaders.RETRY_AFTER, it.retryAfterSeconds.toString()) }
return ResponseEntity
.status(status)
.headers(headers)
.body(body)
}

Expand Down Expand Up @@ -111,6 +116,9 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
status == HttpStatus.METHOD_NOT_ALLOWED -> ErrorCategory.METHOD_NOT_ALLOWED
status == HttpStatus.UNSUPPORTED_MEDIA_TYPE -> ErrorCategory.UNSUPPORTED_MEDIA_TYPE
status == HttpStatus.CONFLICT -> ErrorCategory.CONFLICT
// is4xxClientError 보다 앞에 둔다 — 뒤에 두면 429 가 INVALID_INPUT(400 code)으로 뭉개져
// status 는 429 인데 code 는 COMMON-INVALID-INPUT 인 어긋난 응답이 나간다.
status == HttpStatus.TOO_MANY_REQUESTS -> ErrorCategory.TOO_MANY_REQUESTS
status.is4xxClientError -> ErrorCategory.INVALID_INPUT
status == HttpStatus.BAD_GATEWAY -> ErrorCategory.RETRYABLE
status == HttpStatus.SERVICE_UNAVAILABLE -> ErrorCategory.SERVER_BUSY
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.depromeet.piki.common.exception

// "언제 다시 시도하면 되는지" 가 응답 계약의 일부인 예외가 구현한다(#339 아이템 등록 한도).
// GlobalExceptionHandler 가 이 값을 Retry-After 헤더에 delta-seconds 형식으로 싣는다(RFC 9110 §10.2.3).
//
// HttpMappable(status·category)과 분리해 둔 이유: 같은 예외 클래스의 대다수 사유는 재시도 시점을 모른다.
// 예외 클래스 전체에 nullable 필드를 다는 대신, 재시도 시점을 아는 예외만 이 인터페이스를 구현하고
// 핸들러가 `as?` 로 가려 헤더를 붙인다 — 헤더 유무가 타입으로 드러나고, 모르는 예외에 0 같은 거짓값이 안 실린다.
interface RetryAfter {
val retryAfterSeconds: Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.depromeet.piki.common.ratelimit

import com.depromeet.piki.common.exception.BaseException
import com.depromeet.piki.common.exception.ErrorCategory
import com.depromeet.piki.common.exception.ErrorCode
import com.depromeet.piki.common.exception.HttpMappable
import com.depromeet.piki.common.exception.RetryAfter
import org.springframework.http.HttpStatus

// 아이템 등록 한도 초과(#339). 클라이언트가 정상 요청으로 닿을 수 있는 계약 응답이라 커스텀 예외(429)다.
//
// errorCode 를 생성자로 받는 이유: 사유 문구와 code 는 도메인이 소유해야 한다(위시가 막힌 것과 토너먼트가
// 막힌 것은 사용자에게 다른 문구여야 하고, 토너먼트 쪽은 오너의 사용량이라는 사실을 요청자에게 노출하면 안 된다).
// 그렇다고 도메인마다 예외 클래스를 늘리면 RetryAfter 를 구현하는 클래스가 도메인 수만큼 생기고, 그 클래스의
// 나머지 사유들까지 재시도 시점을 들어야 하는 nullable 필드를 떠안는다. 그래서 클래스는 여기 하나로 두고
// code·문구만 도메인이 넘긴다.
class ItemQuotaException private constructor(
override val errorCode: ErrorCode,
override val retryAfterSeconds: Long,
) : BaseException(errorCode.message),
HttpMappable,
RetryAfter {
override val category: ErrorCategory get() = errorCode.category
override val httpStatus: HttpStatus get() = errorCode.category.httpStatus

companion object {
// retryAfterSeconds 는 창이 리셋되기까지 남은 시간이다. 숫자만 받으므로 응답 detail 에 내부 정보가 실리지 않는다
// (문구는 errorCode 가 고정으로 소유한다 — 임의 문자열을 message 에 싣는 팩토리를 두지 않는 이유).
fun exceeded(
errorCode: ErrorCode,
retryAfterSeconds: Long,
): ItemQuotaException {
require(errorCode.category == ErrorCategory.TOO_MANY_REQUESTS) {
"한도 초과 예외의 category 는 TOO_MANY_REQUESTS 여야 한다: ${errorCode.code} → ${errorCode.category}"
}
// 0 이면 클라가 즉시 재시도해 또 거부되고, 음수는 Retry-After 로 나갈 수 없는 값이다.
// 현재 유일한 호출자(RedisItemQuotaStore)가 최소 1초를 보장하지만 그건 그쪽 사정이라,
// 이 팩토리로 만드는 예외는 어느 호출자가 오든 유효한 재시도 시점을 갖도록 여기서 못박는다.
require(retryAfterSeconds > 0) { "재시도 시점($retryAfterSeconds)은 양수여야 한다." }
return ItemQuotaException(errorCode, retryAfterSeconds)
Comment thread
m-a-king marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.depromeet.piki.common.ratelimit

import com.depromeet.piki.common.exception.ErrorCode
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.util.UUID

// 아이템 등록 경로가 부르는 한도 게이트(#339).
//
// 인터셉터가 아니라 서비스가 직접 부르는 이유 둘: (1) 차감 주체가 요청자가 아닐 수 있다 — 토너먼트 축은
// tournamentId 로 오너를 찾아야 알 수 있어 핸들러 진입 시점엔 모른다. (2) 차감량이 요청 내용에 달렸다 —
// 이미지 장수만큼 깎아야 하는데 인터셉터에서 multipart 를 파싱해 세는 것은 본문을 두 번 읽는 일이다.
@Component
class ItemQuotaGuard(
private val store: RedisItemQuotaStore,
private val properties: ItemQuotaProperties,
) {
private val log = LoggerFactory.getLogger(javaClass)

// 한도를 넘으면 ItemQuotaException(429)을 던지고, 통과하면 그만큼 차감한 뒤 반환한다.
// errorCode 는 호출 도메인이 넘긴다 — 사용자에게 보일 문구와 code 의 소유권은 도메인에 있다.
fun consume(
scope: ItemQuotaScope,
ownerId: UUID,
amount: Int,
errorCode: ErrorCode,
) {
if (!properties.enabled) return

val verdict =
try {
store.tryConsume(
key = scope.keyPrefix + ownerId,
amount = amount,
limit = properties.limitOf(scope),
windowMillis = properties.window.toMillis(),
)
} catch (e: Exception) {
// fail-open — Redis 장애로 등록 기능 전체가 멈추는 것보다, 한도가 잠시 안 걸리는 쪽이 낫다.
// 이 선택의 위험(장애 창 동안 한도 없이 호출됨)은 제한적이다: Redis 가 죽으면 refresh 토큰 저장소도
// 함께 죽어 로그인 흐름이 이미 망가지므로, 그 창에서 대량 호출이 지속되기 어렵다.
// 외부 의존성 실패라 warn (로그 레벨 정책).
//
// runCatching 이 아니라 catch(Exception) 인 이유: runCatching 은 Throwable 을 잡아 OutOfMemoryError
// 같은 치명적 Error 까지 삼킨다. 그런 상황에서 fail-open 으로 요청을 계속 받으면 장애를 키운다.
log.warn("아이템 한도 검사 실패 — 통과시킨다(fail-open). scope={} ownerId={} amount={}", scope, ownerId, amount, e)
return
}
Comment thread
m-a-king marked this conversation as resolved.

when (verdict) {
is ItemQuotaVerdict.Allowed -> return
// 429 는 클라이언트 계약 위반이라 GlobalExceptionHandler 가 info 로 남긴다 — 여기서 또 찍지 않는다.
is ItemQuotaVerdict.Exceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.depromeet.piki.common.ratelimit

import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration

// 아이템 등록 한도(#339) 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다.
//
// 세는 단위는 요청 수가 아니라 **큐에 넣는 item 수**다 — 이미지 등록은 한 요청이 최대 5장이고 장마다 추출이
// 따로 돌므로, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 소비가 5배까지 벌어진다.
//
// 기준은 "LLM 을 타는가" 가 아니라 **"외부에 돈이 나가는가"** 다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도
// fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를
// 소모한다. 그래서 경로별 차등 없이 균일하게 1 을 센다 — 애초에 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고,
// 사이트가 마크업을 바꾸면 어제 파서로 풀리던 링크가 오늘 LLM 을 탄다.
//
// 실제 소비량에 맞춘 정밀 차감(LLM 을 탔는지·프록시 IP 를 몇 번 돌렸는지를 파싱 후에 세는 사후 정산)은 후속 과제다.
//
// 판정은 잔액 방식이다 — 남은 몫이 있으면 요청 크기와 무관하게 통과시키고, 넘긴 만큼은 다음 요청이 갚는다.
// 그래서 창당 실제 소비는 한도가 아니라 (한도 + 1회 최대 요청량)까지 갈 수 있다(RedisItemQuotaStore 주석 참고).
//
// 창은 고정 윈도우(fixed window)다. 첫 차감 시점부터 window 동안이 한 창이고 TTL 만료로 리셋된다.
// 창 경계에서 최대 2배 버스트가 가능하지만(창 끝 + 다음 창 시작), 목적이 "한 계정이 시간당 대략 N개"라
// 그 정도 오차는 비용 방어에 영향을 주지 않는다. 정확한 평활화가 필요해지면 sliding window 로 올린다.
// 모든 사용자가 같은 시각에 리셋되는 창 인덱스 방식은 쓰지 않는다(thundering herd) — 사용자별로 창이 어긋난다.
@ConfigurationProperties(prefix = "item-quota")
data class ItemQuotaProperties(
// 끄면 차감·판정을 통째로 건너뛴다. 한도가 잘못 잡혀 정상 사용자를 막을 때 배포 없이 되돌리는 스위치다.
val enabled: Boolean = true,
val window: Duration = Duration.ofHours(1),
// 위시 등록 — 요청자 본인이 차감 주체다. 이미지 등록(최대 5장) 2번 또는 링크 10건에 해당한다.
val wishLimit: Int = 10,
// 토너먼트 아이템 등록 — 오너 한 명의 몫을 참여자 전원(최대 8명, 게스트 포함)이 나눠 쓴다.
// 위시보다 크게 두는 이유가 여기 있다: 같은 값이면 친구들이 넣은 만큼 오너가 체감하게 된다.
// "체감 완화" 를 차감 가중치(예: 0.5)로 풀지 않는 이유는 실제 비용과 카운터가 어긋나면 메트릭으로
// 실제 호출량을 읽을 수 없게 되기 때문이다 — 차감은 1:1 로 정직하게 두고 한도로 조절한다.
val tournamentLimit: Int = 30,
) {
init {
// 밀리초로 환산해 검사한다 — Redis PEXPIRE 가 ms 단위라, 1ms 미만(예: 500us)은 양수여도 환산 결과가 0 이 되어
// 창이 즉시 만료된다. 그러면 매 요청이 새 창을 열어 한도가 사실상 무제한이 되는데, 설정만 보면 정상으로 보인다.
require(window.toMillis() > 0) {
"item-quota.window($window)는 1ms 이상이어야 한다 — 그 미만은 창이 즉시 만료돼 한도가 무의미해진다."
}
require(wishLimit > 0) { "item-quota.wish-limit($wishLimit)은 양수여야 한다 — 0 이면 위시 등록이 통째로 막힌다." }
require(tournamentLimit > 0) {
"item-quota.tournament-limit($tournamentLimit)은 양수여야 한다 — 0 이면 토너먼트 아이템 등록이 통째로 막힌다."
}
}

fun limitOf(scope: ItemQuotaScope): Int =
when (scope) {
ItemQuotaScope.WISH -> wishLimit
ItemQuotaScope.TOURNAMENT -> tournamentLimit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.depromeet.piki.common.ratelimit

// 아이템 등록 한도(#339)의 계정 단위 축. 두 축을 분리해 한쪽의 소진이 다른 쪽을 막지 않게 한다 —
// 토너먼트 축은 참여자(게스트 포함) 전원이 오너 한 명의 몫을 함께 쓰므로, 한 축으로 합치면
// "친구들이 내 토너먼트에 아이템을 넣어서 내가 내 위시리스트를 못 쓰는" 상황이 생긴다.
enum class ItemQuotaScope(
val keyPrefix: String,
) {
// 위시 등록 — 차감 주체가 곧 요청자다(위시는 회원 전용이라 항상 회원).
WISH("quota:item:wish:"),

// 토너먼트 아이템 등록 — 차감 주체는 요청자가 아니라 **토너먼트 오너**다. 게스트 참여자도 아이템을 넣을 수 있는데,
// 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 회원이라
// (토너먼트 생성이 회원 전용) 소셜 계정 생성 비용이 그 우회를 막는다.
TOURNAMENT("quota:item:tournament:"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.depromeet.piki.common.ratelimit

// 한도 판정 결과. 거부일 때만 재시도 시점을 들어, "허용인데 retryAfter 가 0" 같은 무의미한 상태를 타입에서 없앤다.
sealed interface ItemQuotaVerdict {
data object Allowed : ItemQuotaVerdict

// retryAfterSeconds — 창이 리셋되기까지 남은 시간(초, 올림). Retry-After 헤더로 그대로 나간다.
data class Exceeded(
val retryAfterSeconds: Long,
) : ItemQuotaVerdict
}
Loading
Loading