diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt index 3fa69e6c..f5ecdc68 100644 --- a/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt @@ -34,6 +34,11 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() { // HttpMappable 아닌 BaseException 도 category 가 SERVER_ERROR 라 여기로 와 스택과 함께 남는다. status.is5xxServerError && category == ErrorCategory.SERVER_ERROR -> log.error("[{}] {} -> {}", e.javaClass.simpleName, e.message, status.value(), e) + // SERVER_BUSY(503) = load shedding(#927). 서버는 멀쩡하고 가용량만 찬 상태라 스택에 담길 정보가 없고, + // 한 번 차면 창이 끝날 때까지 모든 등록 요청이 여기로 오므로 스택까지 남기면 로그량이 급증한다. + // 도달 자체는 이미 경고선 로그가 앞서 알렸으므로 여기서는 건수만 센다. + status.is5xxServerError && category == ErrorCategory.SERVER_BUSY -> + log.warn("[{}] {} -> {}", e.javaClass.simpleName, e.message, status.value()) // RETRYABLE 5xx(502) = 외부 의존성 일시 실패 → warn, cause 추적 위해 예외 동봉. 클라는 재시도로 대응 가능. status.is5xxServerError -> log.warn("[{}] {} -> {}", e.javaClass.simpleName, e.message, status.value(), e) diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt index 55eead21..19d3be6e 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt @@ -1,13 +1,16 @@ package com.depromeet.piki.common.ratelimit import com.depromeet.piki.common.exception.BaseException +import com.depromeet.piki.common.exception.CommonErrorCode 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)다. +// 아이템 등록이 한도에 걸린 경우. 클라이언트가 정상 요청으로 닿을 수 있는 계약 응답이라 커스텀 예외다. +// 사유가 둘이고 status 가 갈린다 — 요청자 몫 소진은 429(#339), 서비스 전체 가용량 소진은 503(#927). +// status 를 여기서 들지 않고 category 에서 파생하므로 팩토리가 고른 code 하나로 둘 다 정해진다. // // errorCode 를 생성자로 받는 이유: 사유 문구와 code 는 도메인이 소유해야 한다(위시가 막힌 것과 토너먼트가 // 막힌 것은 사용자에게 다른 문구여야 하고, 토너먼트 쪽은 오너의 사용량이라는 사실을 요청자에게 노출하면 안 된다). @@ -39,5 +42,16 @@ class ItemQuotaException private constructor( require(retryAfterSeconds > 0) { "재시도 시점($retryAfterSeconds)은 양수여야 한다." } return ItemQuotaException(errorCode, retryAfterSeconds) } + + // 전역 가용량 소진(#927). 요청자의 몫과 무관하게 **서비스가 꽉 찬** 상태라 4xx 가 아니라 503 이다. + // + // code 를 도메인이 넘기지 않고 공통 SERVER_BUSY 로 고정하는 이유: 위 exceeded 는 "위시가 막혔나 + // 토너먼트가 막혔나" 로 사용자에게 다른 문구를 줘야 해서 도메인이 code 를 소유했지만, 이쪽은 어느 + // 등록 경로로 닿든 원인도 안내도 하나다("지금은 서비스가 바쁘다"). 도메인마다 같은 문구의 code 를 + // 늘리면 클라가 구분해 처리할 것도 없이 매핑 표만 길어진다. + fun capacityExceeded(retryAfterSeconds: Long): ItemQuotaException { + require(retryAfterSeconds > 0) { "재시도 시점($retryAfterSeconds)은 양수여야 한다." } + return ItemQuotaException(CommonErrorCode.SERVER_BUSY, retryAfterSeconds) + } } } diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt index 03406b8c..5c5d2246 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt @@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.util.UUID -// 아이템 등록 경로가 부르는 한도 게이트(#339). +// 아이템 등록 경로가 부르는 한도 게이트(#339·#927). // // 인터셉터가 아니라 서비스가 직접 부르는 이유 둘: (1) 차감 주체가 요청자가 아닐 수 있다 — 토너먼트 축은 // tournamentId 로 오너를 찾아야 알 수 있어 핸들러 진입 시점엔 모른다. (2) 차감량이 요청 내용에 달렸다 — @@ -17,10 +17,15 @@ class ItemQuotaGuard( ) { private val log = LoggerFactory.getLogger(javaClass) - // 한도를 넘으면 ItemQuotaException(429)을 던지고, 통과하면 그만큼 차감한 뒤 반환한다. - // errorCode 는 호출 도메인이 넘긴다 — 사용자에게 보일 문구와 code 의 소유권은 도메인에 있다. + // 두 축을 함께 확인하고, 통과하면 그만큼 차감한 뒤 반환한다. + // - 요청자 몫 소진 → ItemQuotaException(429). errorCode 는 호출 도메인이 넘긴다 — 카운터는 하나지만 사용자에게 + // 보일 문구와 code 는 경로마다 달라야 한다(게스트가 남의 토너먼트에서 막힌 응답에 오너의 사용량이 드러나면 안 된다). + // - 전역 가용량 소진 → ItemQuotaException(503). 어느 도메인에서 닿든 원인이 같아 공통 code 를 쓴다. + // + // ownerId 는 요청자가 아니라 **몫의 주인**이다. 토너먼트 경로에서는 참여 게스트가 넣어도 오너의 몫에서 깎인다 — + // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있고, 오너는 반드시 + // 회원이라(토너먼트 생성이 회원 전용) 소셜 계정 생성 비용이 그 우회를 막는다. fun consume( - scope: ItemQuotaScope, ownerId: UUID, amount: Int, errorCode: ErrorCode, @@ -30,9 +35,11 @@ class ItemQuotaGuard( val verdict = try { store.tryConsume( - key = scope.keyPrefix + ownerId, + ownerKey = RedisItemQuotaStore.USER_KEY_PREFIX + ownerId, + capacityKey = RedisItemQuotaStore.CAPACITY_KEY, amount = amount, - limit = properties.limitOf(scope), + ownerLimit = properties.userLimit, + capacityLimit = properties.capacityLimit, windowMillis = properties.window.toMillis(), ) } catch (e: Exception) { @@ -43,14 +50,36 @@ class ItemQuotaGuard( // // runCatching 이 아니라 catch(Exception) 인 이유: runCatching 은 Throwable 을 잡아 OutOfMemoryError // 같은 치명적 Error 까지 삼킨다. 그런 상황에서 fail-open 으로 요청을 계속 받으면 장애를 키운다. - log.warn("아이템 한도 검사 실패 — 통과시킨다(fail-open). scope={} ownerId={} amount={}", scope, ownerId, amount, e) + log.warn("아이템 한도 검사 실패 — 통과시킨다(fail-open). ownerId={} amount={}", ownerId, amount, e) return } when (verdict) { - is ItemQuotaVerdict.Allowed -> return + is ItemQuotaVerdict.Allowed -> warnIfCapacityAlertCrossed(verdict.capacityUsed, amount) // 429 는 클라이언트 계약 위반이라 GlobalExceptionHandler 가 info 로 남긴다 — 여기서 또 찍지 않는다. - is ItemQuotaVerdict.Exceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds) + is ItemQuotaVerdict.OwnerExceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds) + // 503 도 핸들러가 warn 으로 남긴다. 한 번 차면 창이 끝날 때까지 모든 요청이 여기로 오므로, + // 거부 건마다 여기서 또 찍으면 로그가 배로 늘기만 한다. 도달 사실은 아래 경고선 로그가 이미 알렸다. + is ItemQuotaVerdict.CapacityExceeded -> throw ItemQuotaException.capacityExceeded(verdict.retryAfterSeconds) } } + + // 전역 가용량이 경고선을 넘긴 순간 한 줄 남긴다. 상한에 닿으면 이미 사용자가 막히고 있어 늦으므로, + // 이 로그가 실질 방어선이다 — 알림 룰이 이 문구를 집어 Discord 로 보낸다. + // + // 대응은 "상한을 올린다" 가 기본이 아니다. 정상 성장인지, 특정 사용자·IP 의 이상 패턴인지, 파싱 실패로 인한 + // 재시도 폭증인지를 먼저 가르고, 정상 성장으로 확인된 뒤에만 올린다. + private fun warnIfCapacityAlertCrossed( + capacityUsed: Long, + amount: Int, + ) { + if (!properties.crossedCapacityAlert(capacityUsed, amount)) return + log.warn( + "아이템 등록 전역 가용량 경고선 도달 — used={} threshold={} limit={} window={}", + capacityUsed, + properties.capacityAlertThreshold, + properties.capacityLimit, + properties.window, + ) + } } diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt index 1802e9ac..de2445c2 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -3,11 +3,18 @@ package com.depromeet.piki.common.ratelimit import org.springframework.boot.context.properties.ConfigurationProperties import java.time.Duration -// 아이템 등록 한도(#339) 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. +// 아이템 등록 한도 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. +// +// 축이 둘이고 서로를 대체하지 않는다. **계정별**(#339)은 한 사람이 얼마나 쓸 수 있는지를, **전역**(#927)은 +// 서비스 전체가 얼마나 감당하는지를 정한다. 전자는 남용을, 후자는 정상 사용자가 몰리는 상황을 막는다. // // 세는 단위는 요청 수가 아니라 **큐에 넣는 item 수**다 — 이미지 등록은 한 요청이 최대 5장이고 장마다 추출이 // 따로 돌므로, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 소비가 5배까지 벌어진다. // +// 무엇이 차감 대상인지의 기준은 **"새 파싱 작업이 큐에 들어가는가"** 하나다. 그래서 새로고침은 파싱이 한 번 더 +// 도므로 신규 등록과 같이 세고, 위시에 있는 item 을 토너먼트로 담는 것은 기존 item 을 참조만 하므로 세지 않는다 +// (그 item 은 위시에 담길 때 이미 한 번 깎였다). +// // 기준은 "LLM 을 타는가" 가 아니라 **"외부에 돈이 나가는가"** 다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도 // fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를 // 소모한다. 그래서 경로별 차등 없이 균일하게 1 을 센다 — 애초에 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고, @@ -27,13 +34,30 @@ 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, + // 계정 한 명이 창당 쓸 수 있는 총량. **등록 경로를 가리지 않는 하나의 몫**이다 — 내 위시 등록, 내가 내 + // 토너먼트에 넣는 것, 참여 게스트가 내 토너먼트에 넣는 것이 전부 여기서 깎인다. + // + // 한때 위시·토너먼트를 별개 축으로 나눴다가 합쳤다. 나눴던 이유는 "친구들이 내 토너먼트에 아이템을 넣어서 + // 내가 내 위시리스트를 못 쓰는" 상황을 막으려던 것이고, 합치면 그 상황이 생긴다. 그럼에도 합친 이유는 + // **막으려는 대상이 경로가 아니라 계정의 총 소비**이기 때문이다. 축이 둘이면 한 계정의 실제 상한이 + // 둘의 합(예전 40)이 되어, "이 계정이 시간당 얼마나 쓰나" 를 한 숫자로 말할 수 없다. + val userLimit: Int = 30, + // 전역 가용량 상한(#927) — 계정별 한도 위에 얹는 총량이다. 계정별은 "한 사람이 100번" 을 막지만 + // "100명이 각자 30번" 은 막지 못한다. 비용 방어가 아니라 **가용량 선언**이라, 정상 운영에서는 닿지 않아야 하는 + // 마지노선이다. 닿았다면 인기가 아니라 이상 신호로 읽고 원인부터 가른다. + // + // 3000 은 계정 한도(30)를 꽉 채운 사용자 100명분이다. 파싱 워커가 maxPoolSize 8 · queueCapacity 0 이라 + // 동시 처리는 최대 8건이고, 건당 소요를 파서 1~2초로 잡으면 이론 처리량이 시간당 14,000건을 넘는다 + // (실측이 아니라 timeout 상한에서 잡은 추정). 헤드리스·LLM 이 섞이면 건당 5~20초까지 늘어 이론 처리량이 + // 시간당 1,400건까지 떨어지는데, 그 구간에서는 이 상한이 워커보다 느슨해 상한에 닿기 전에 PENDING 이 쌓인다. + // 거부가 아니라 대기라 장애는 아니지만, 화이트리스트 전환으로 파서 위주가 되는 것을 전제로 잡은 값이다. + val capacityLimit: Int = 3_000, + // 상한의 몇 %에서 경고를 남길지. **상한에 닿으면 이미 늦으므로 이 지점이 실질 방어선이다** — 여기서 + // 손 쓸 시간을 벌기 위한 값이지, 도달 자체가 정상이라는 뜻이 아니다. + // + // 기본 3000 기준 1980 건에서 울린다. 상한까지 1020 건이 남으므로, 원인을 가르고(정상 성장인지·특정 계정의 + // 이상 패턴인지·파싱 실패 재시도 폭증인지) 손 쓸 여유가 창의 3분의 1 남는다. + val capacityAlertPercent: Int = 66, ) { init { // 밀리초로 환산해 검사한다 — Redis PEXPIRE 가 ms 단위라, 1ms 미만(예: 500us)은 양수여도 환산 결과가 0 이 되어 @@ -41,15 +65,23 @@ data class ItemQuotaProperties( 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 이면 토너먼트 아이템 등록이 통째로 막힌다." + require(userLimit > 0) { "item-quota.user-limit($userLimit)은 양수여야 한다 — 0 이면 아이템 등록이 통째로 막힌다." } + require(capacityLimit > 0) { + "item-quota.capacity-limit($capacityLimit)은 양수여야 한다 — 0 이면 모든 사용자의 등록이 통째로 막힌다." + } + // 상한은 100 까지 허용한다(도달 시점에만 경고). 0 이하면 첫 요청부터, 100 초과면 영원히 안 울려 둘 다 무의미하다. + require(capacityAlertPercent in 1..100) { + "item-quota.capacity-alert-percent($capacityAlertPercent)는 1 에서 100 사이여야 한다." } } - fun limitOf(scope: ItemQuotaScope): Int = - when (scope) { - ItemQuotaScope.WISH -> wishLimit - ItemQuotaScope.TOURNAMENT -> tournamentLimit - } + // 경고선(건수). 정수 나눗셈이라 내림되지만 경고 시점이 한 건 앞당겨질 뿐이라 무해하다. + val capacityAlertThreshold: Int get() = capacityLimit * capacityAlertPercent / 100 + + // 이번 차감이 경고선을 **처음** 넘겼는지. 넘긴 뒤 매 요청마다 경고하면 창이 끝날 때까지 같은 줄이 반복돼 + // 알림이 무뎌지므로, "직전엔 아래였는데 지금은 위" 인 한 건만 참이 된다. + fun crossedCapacityAlert( + capacityUsed: Long, + amount: Int, + ): Boolean = capacityUsed >= capacityAlertThreshold && capacityUsed - amount < capacityAlertThreshold } diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt deleted file mode 100644 index c06a4c3a..00000000 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.depromeet.piki.common.ratelimit - -// 아이템 등록 한도(#339)의 계정 단위 축. 두 축을 분리해 한쪽의 소진이 다른 쪽을 막지 않게 한다 — -// 토너먼트 축은 참여자(게스트 포함) 전원이 오너 한 명의 몫을 함께 쓰므로, 한 축으로 합치면 -// "친구들이 내 토너먼트에 아이템을 넣어서 내가 내 위시리스트를 못 쓰는" 상황이 생긴다. -enum class ItemQuotaScope( - val keyPrefix: String, -) { - // 위시 등록 — 차감 주체가 곧 요청자다(위시는 회원 전용이라 항상 회원). - WISH("quota:item:wish:"), - - // 토너먼트 아이템 등록 — 차감 주체는 요청자가 아니라 **토너먼트 오너**다. 게스트 참여자도 아이템을 넣을 수 있는데, - // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 회원이라 - // (토너먼트 생성이 회원 전용) 소셜 계정 생성 비용이 그 우회를 막는다. - TOURNAMENT("quota:item:tournament:"), -} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt index 3aae883b..5bf4817f 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt @@ -1,11 +1,20 @@ package com.depromeet.piki.common.ratelimit -// 한도 판정 결과. 거부일 때만 재시도 시점을 들어, "허용인데 retryAfter 가 0" 같은 무의미한 상태를 타입에서 없앤다. +// 한도 판정 결과. 거부는 두 축으로 갈린다 — 요청자 몫이 소진된 것(429)과 서비스 전체 가용량이 소진된 것(503)은 +// 원인도 응답도 다르므로 타입에서 구분한다. 거부일 때만 재시도 시점을 들어, "허용인데 retryAfter 가 0" 같은 +// 무의미한 상태를 타입에서 없앤다. sealed interface ItemQuotaVerdict { - data object Allowed : ItemQuotaVerdict + // capacityUsed — 차감 후 전역 카운터 누적값. 경고선(#927)을 이번 요청이 처음 넘겼는지 가리는 데 쓴다. + data class Allowed( + val capacityUsed: Long, + ) : ItemQuotaVerdict // retryAfterSeconds — 창이 리셋되기까지 남은 시간(초, 올림). Retry-After 헤더로 그대로 나간다. - data class Exceeded( + data class OwnerExceeded( + val retryAfterSeconds: Long, + ) : ItemQuotaVerdict + + data class CapacityExceeded( val retryAfterSeconds: Long, ) : ItemQuotaVerdict } diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt index 5b1d859e..b3c54bd7 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt @@ -5,7 +5,7 @@ import org.springframework.data.redis.core.script.DefaultRedisScript import org.springframework.stereotype.Component import kotlin.math.max -// 고정 윈도우 카운터를 Redis 에 두는 한도 저장소(#339). +// 고정 윈도우 카운터를 Redis 에 두는 한도 저장소(#339·#927). // // Bucket4j 같은 라이브러리 대신 Lua 를 직접 쓰는 이유: 필요한 것이 "창당 N 개" 라는 단순 카운터뿐이고, // Bucket4j 는 버킷 상태를 **객체로 직렬화해** Redis 에 저장한다. 그러면 무중단 배포 중 구·신버전이 같은 키를 @@ -27,72 +27,113 @@ class RedisItemQuotaStore( // 초과 노출은 유한하다: 창당 최대 소비는 (한도 + 1회 최대 요청량)으로 바운드된다. 잔액 1 에서 5장이 들어와도 // -4 가 최악이고, 그 뒤로는 전부 거부되기 때문이다. 무한 초과가 아니라 계산 가능한 상한이라 받아들일 수 있다. // (파싱 후 실제 소비를 정산하는 후속 과제 #910 이 붙으면 그 정산분도 같은 방식으로 음수에 얹힌다.) + // + // 두 축(요청자 몫 · 전역 가용량)을 **한 스크립트로 함께** 판정·차감한다. 나눠 부르면 "요청자 몫은 깎였는데 + // 전역이 차서 거부" 인 요청이 생기는데, 그 사용자는 503 을 받고 안내대로 재시도할 때마다 자기 몫을 잃는다. + // 결국 전역이 풀린 뒤에도 자기 한도에 걸려 429 를 받는다 — 재시도 안내가 사용자를 자해하게 만드는 셈이다. fun tryConsume( - key: String, + ownerKey: String, + capacityKey: String, amount: Int, - limit: Int, + ownerLimit: Int, + capacityLimit: Int, windowMillis: Long, ): ItemQuotaVerdict { require(amount > 0) { "차감량($amount)은 양수여야 한다 — 0 건 등록은 애초에 이 경로에 오지 않는다." } val result = redisTemplate.execute( CONSUME_SCRIPT, - listOf(key), + listOf(ownerKey, capacityKey), amount.toString(), - limit.toString(), + ownerLimit.toString(), + capacityLimit.toString(), windowMillis.toString(), - ) ?: error("아이템 한도 Lua script 가 null 을 반환했다 (key=$key)") + ) ?: error("아이템 한도 Lua script 가 null 을 반환했다 (ownerKey=$ownerKey)") return when { - result.startsWith(ALLOWED_PREFIX) -> ItemQuotaVerdict.Allowed - result.startsWith(EXCEEDED_PREFIX) -> { - val remainingMillis = result.removePrefix(EXCEEDED_PREFIX).toLong() - // 올림 + 최소 1초 — 남은 시간이 0.2초여도 Retry-After: 0 을 주면 클라가 즉시 재시도해 또 거부된다. - ItemQuotaVerdict.Exceeded(max(1L, (remainingMillis + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND)) - } - else -> error("아이템 한도 Lua script 가 예상 못한 값을 반환했다: $result (key=$key)") + result.startsWith(ALLOWED_PREFIX) -> + ItemQuotaVerdict.Allowed(result.removePrefix(ALLOWED_PREFIX).toLong()) + result.startsWith(OWNER_EXCEEDED_PREFIX) -> + ItemQuotaVerdict.OwnerExceeded(retryAfterSecondsOf(result.removePrefix(OWNER_EXCEEDED_PREFIX))) + result.startsWith(CAPACITY_EXCEEDED_PREFIX) -> + ItemQuotaVerdict.CapacityExceeded(retryAfterSecondsOf(result.removePrefix(CAPACITY_EXCEEDED_PREFIX))) + else -> error("아이템 한도 Lua script 가 예상 못한 값을 반환했다: $result (ownerKey=$ownerKey)") } } + // 올림 + 최소 1초 — 남은 시간이 0.2초여도 Retry-After: 0 을 주면 클라가 즉시 재시도해 또 거부된다. + private fun retryAfterSecondsOf(remainingMillis: String): Long = + max(1L, (remainingMillis.toLong() + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND) + companion object { + // 계정 몫 카운터 키 접두사. 등록 경로(위시·토너먼트)를 가리지 않는 하나의 몫이라 경로가 키에 들어가지 않는다. + const val USER_KEY_PREFIX = "quota:item:user:" + + // 전역 가용량 카운터 키(#927). 요청자 몫과 달리 서비스에 하나뿐이라 접두사 + 식별자가 아니라 고정 키다. + const val CAPACITY_KEY = "quota:item:capacity" + private const val ALLOWED_PREFIX = "A:" - private const val EXCEEDED_PREFIX = "X:" + private const val OWNER_EXCEEDED_PREFIX = "O:" + private const val CAPACITY_EXCEEDED_PREFIX = "C:" private const val MILLIS_PER_SECOND = 1_000L // 판정과 차감을 한 스크립트로 원자화한다. GET → 비교 → INCRBY 를 앱에서 나눠 하면 동시 요청이 각자 // 통과 판정을 받아 잔액을 예상보다 깊게 파고들 수 있다(check-then-act race). Redis 싱글스레드 직렬화가 그걸 막는다. // - // KEYS[1]=카운터 키, ARGV[1]=차감량, ARGV[2]=한도, ARGV[3]=창 길이(ms) + // KEYS[1]=요청자 몫 키, KEYS[2]=전역 가용량 키 + // ARGV[1]=차감량, ARGV[2]=요청자 한도, ARGV[3]=전역 상한, ARGV[4]=창 길이(ms) // - // 판정은 `current >= limit` 하나다 — **요청량(amount)은 판정에 쓰지 않고 차감에만 쓴다.** 남은 몫이 + // 판정은 각 축마다 `current >= limit` 하나다 — **요청량(amount)은 판정에 쓰지 않고 차감에만 쓴다.** 남은 몫이 // 있으면 크기와 무관하게 들여보내고, 넘긴 만큼은 다음 요청이 갚는다(위 잔액 방식 주석). // + // **요청자 몫을 먼저 본다.** 둘 다 소진된 사용자에게 503("지금 요청이 많아요")을 주면 실제 원인은 자기가 + // 다 쓴 것인데 서버 탓으로 읽힌다. 자기 몫이 남아 있을 때만 전역을 따진다. + // // 반환: - // "A:<누적>" 허용 — 차감 후 누적값 (한도를 넘겼을 수 있다) - // "X:<남은 ms>" 거부 — 차감하지 않음. 창이 리셋되기까지 남은 시간 + // "A:<전역 누적>" 허용 — 두 축 모두 차감 후 전역 카운터 값(한도를 넘겼을 수 있다) + // "O:<남은 ms>" 요청자 몫 소진 → 429. 어느 카운터도 차감하지 않음 + // "C:<남은 ms>" 전역 가용량 소진 → 503. 어느 카운터도 차감하지 않음 // // 거부 시 INCRBY 를 하지 않는 것이 중요하다. 거부분까지 누적하면 한도에 걸린 사용자가 재시도할 때마다 // 카운터가 계속 올라, TTL 로 창이 끝나도 이미 한도를 넘긴 상태로 시작하는 일이 생긴다(사실상 영구 차단). // // TTL 은 INCRBY 직후 PTTL 이 음수(-1: TTL 없음)일 때만 건다. "누적값 == 차감량이면 첫 차감" 으로 판정하면 // 창 도중 키가 TTL 없이 남는 경로가 생겼을 때 그 키가 영구화된다 — PTTL 로 보면 그 경우까지 복구된다. + // + // 키 둘을 한 스크립트에서 만지므로 Redis Cluster 로 가면 같은 해시 슬롯이어야 한다. 현재는 단일 인스턴스라 + // 제약이 없지만, 클러스터 전환 시 이 스크립트가 CROSSSLOT 으로 깨진다는 점을 여기 남겨둔다. private val CONSUME_SCRIPT = DefaultRedisScript().apply { setScriptText( """ local amount = tonumber(ARGV[1]) - local limit = tonumber(ARGV[2]) - local current = tonumber(redis.call('GET', KEYS[1]) or '0') - if current >= limit then + local ownerLimit = tonumber(ARGV[2]) + local capacityLimit = tonumber(ARGV[3]) + local windowMillis = ARGV[4] + + local owner = tonumber(redis.call('GET', KEYS[1]) or '0') + if owner >= ownerLimit then local ttl = redis.call('PTTL', KEYS[1]) if ttl < 0 then ttl = 0 end - return 'X:' .. ttl + return 'O:' .. ttl + end + + local capacity = tonumber(redis.call('GET', KEYS[2]) or '0') + if capacity >= capacityLimit then + local ttl = redis.call('PTTL', KEYS[2]) + if ttl < 0 then ttl = 0 end + return 'C:' .. ttl end - local updated = redis.call('INCRBY', KEYS[1], amount) + + redis.call('INCRBY', KEYS[1], amount) if redis.call('PTTL', KEYS[1]) < 0 then - redis.call('PEXPIRE', KEYS[1], ARGV[3]) + redis.call('PEXPIRE', KEYS[1], windowMillis) + end + local capacityUsed = redis.call('INCRBY', KEYS[2], amount) + if redis.call('PTTL', KEYS[2]) < 0 then + redis.call('PEXPIRE', KEYS[2], windowMillis) end - return 'A:' .. updated + return 'A:' .. capacityUsed """.trimIndent(), ) setResultType(String::class.java) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index fe594f01..1b15036c 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -28,10 +28,18 @@ import java.util.UUID // (응답 문구 자체는 오너의 사용량을 드러내지 않는다 — 남의 사용량은 요청자에게 알릴 정보가 아니다.) private const val TOURNAMENT_RATE_LIMIT_DESCRIPTION = "아이템 등록 한도 초과 (code: TOURNAMENT-037). 한도는 요청자가 아니라 토너먼트 오너의 몫에서 차감되므로 " + - "참여자가 처음 담는 경우에도 받을 수 있다. 요청 수가 아니라 등록하는 item 수로 세며(이미지 5장 = 5), " + + "참여자가 처음 담는 경우에도 받을 수 있다. 그 몫은 토너먼트 전용이 아니라 오너 계정 하나의 몫이라 " + + "오너의 위시 등록과도 공유된다. 요청 수가 아니라 등록하는 item 수로 세며(이미지 5장 = 5), " + "남은 몫이 있으면 그보다 큰 요청도 통과하므로 이 응답은 몫을 이미 다 쓴 뒤부터 나온다. " + "Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." +// 전역 가용량 소진(#927) 응답 설명. 429 와 원인이 다르다 — 오너의 몫이 남아 있어도 서비스 전체가 차면 나간다. +private const val CAPACITY_DESCRIPTION = + "서비스 전체의 시간당 처리 가용량 소진 (code: COMMON-SERVER-BUSY). 오너의 몫이 남아 있어도 나갈 수 있다 — " + + "서비스가 감당하기로 정한 총량이 찬 상태라 같은 시각 모든 사용자에게 동일하게 나간다. 정상 운영에서는 " + + "닿지 않는 마지노선이므로 이 응답이 반복되면 서버 쪽 이상 신호다. Retry-After 헤더에 가용량이 회복되기까지 " + + "남은 시간(초)이 실린다." + @Tag(name = "Tournament Item", description = "토너먼트 아이템 API") interface TournamentItemApi { @@ -273,6 +281,23 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "503", + description = CAPACITY_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun addItemFromLink( @@ -383,6 +408,23 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "503", + description = CAPACITY_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun addItemsFromImages( @@ -491,6 +533,23 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "503", + description = "$CAPACITY_DESCRIPTION 발급 시점에 확인하므로 이어지는 confirm 은 이 응답을 받지 않는다.", + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun presignImageUploads( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt index 7b070262..82936c19 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -82,6 +82,7 @@ class TournamentItemApiExamples( add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (오너 몫에서 차감)") + add(capacityExceeded, name = "서비스 전체 가용량 소진") } handlerMethod.binds(TournamentItemController::addItemsFromImages) -> @@ -109,6 +110,7 @@ class TournamentItemApiExamples( add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") add(ImageStorageException.uploadFailed(), name = "이미지 저장 실패 (S3 업로드 장애)") add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (오너 몫에서 이미지 장수만큼 차감)") + add(capacityExceeded, name = "서비스 전체 가용량 소진") } handlerMethod.binds(TournamentItemController::presignImageUploads) -> @@ -127,6 +129,7 @@ class TournamentItemApiExamples( add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") add(ImageStorageException.presignFailed(), name = "presigned URL 발급 실패 (스토리지 장애)") add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (발급 시점에 오너 몫에서 차감)") + add(capacityExceeded, name = "서비스 전체 가용량 소진 (발급 시점에 확인)") } handlerMethod.binds(TournamentItemController::confirmImageRegistration) -> @@ -297,6 +300,9 @@ class TournamentItemApiExamples( // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. private val itemQuotaExceeded = ItemQuotaException.exceeded(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, 900) + // 전역 가용량 소진(#927). 오너의 몫과 무관하게 서비스 전체가 찬 상태라 503 이고, code 도 도메인이 아닌 공통이다. + private val capacityExceeded = ItemQuotaException.capacityExceeded(900) + // 이미지 등록 v2 presigned 발급 응답 샘플 — 위시와 동일 구조. uploadUrl 의 서명 쿼리스트링은 예시라 실제 값이 아니다. private val presignedUploadsSample = PresignedImageUploadResponse( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 7e2b616f..59b29e8c 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -1,7 +1,6 @@ package com.depromeet.piki.tournament.service import com.depromeet.piki.common.ratelimit.ItemQuotaGuard -import com.depromeet.piki.common.ratelimit.ItemQuotaScope import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage @@ -59,7 +58,7 @@ class TournamentItemService( // 권한·상태를 차감 전에 확인한다(이미지 경로와 같은 이유) — 참여자도 아닌 요청이 오너의 몫을 깎으면 안 된다. // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) - itemQuotaGuard.consume(ItemQuotaScope.TOURNAMENT, ownerIdOf(tournamentId), 1, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(ownerIdOf(tournamentId), 1, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) // URL 경로는 PENDING snapshot 을 커밋만 하고(작업 큐 적재) 즉시 반환한다. 파싱은 디스패처(@Scheduled)가 // PENDING 을 집어 워커에 넘긴다 — @Async 유실과 무관하게 최소 1회는 claim 된다(at-least-once). // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). @@ -78,12 +77,7 @@ class TournamentItemService( // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 오너 몫에서 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. - itemQuotaGuard.consume( - ItemQuotaScope.TOURNAMENT, - ownerIdOf(tournamentId), - images.size, - TournamentErrorCode.ITEM_QUOTA_EXCEEDED, - ) + itemQuotaGuard.consume(ownerIdOf(tournamentId), images.size, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. val imageKeys = productImages.map { uploadRaw(it) } // 사전검증을 통과해도 정원은 persist 의 FOR UPDATE 가 최종 판정한다(동시 추가 race). 거기서 거부되면 방금 올린 raw 가 @@ -112,12 +106,7 @@ class TournamentItemService( contentTypes.forEach { ProductImage.extensionForMimeType(it) } // 위시 v2 와 같은 이유로 발급 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 한도를 우회한다. confirm 은 차감하지 않는다(이중 차감 방지). - itemQuotaGuard.consume( - ItemQuotaScope.TOURNAMENT, - ownerIdOf(tournamentId), - contentTypes.size, - TournamentErrorCode.ITEM_QUOTA_EXCEEDED, - ) + itemQuotaGuard.consume(ownerIdOf(tournamentId), contentTypes.size, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.tournament(key, userId, tournamentId, expiresAt) } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 63209460..5e8b0575 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -131,7 +131,8 @@ class TournamentService( } // 아이템 등록 한도(#339)를 차감하지 않는다 — 위시에 이미 있는 item 을 참조만 하므로 새 파싱·LLM 호출이 없다. - // 그 item 을 위시에 담을 때 이미 한 번 차감됐다. + // 그 item 을 위시에 담을 때 이미 한 번 차감됐다. 차감 여부의 기준은 경로가 아니라 "새 파싱 작업이 큐에 + // 들어가는가" 이고, 이동은 여기 해당하지 않는다(같은 기준으로 새로고침은 파싱이 한 번 더 돌아 차감한다). @Transactional fun addItemsFromWish( userId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index 9f85d9f5..ccfd1dfb 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -21,11 +21,21 @@ import org.springframework.web.multipart.MultipartFile import java.util.UUID // 아이템 등록 한도(#339) 응답 설명. 위시 등록 계열 4개 엔드포인트가 같은 문구를 쓰므로 한 곳에 둔다. -// 세는 단위가 요청 수가 아니라 item 수라는 점이 클라이언트 계약의 핵심이라 명시한다. +// 세는 단위가 요청 수가 아니라 item 수라는 점, 그리고 몫이 위시 전용이 아니라 계정 전체 몫이라는 점이 +// 클라이언트 계약의 핵심이라 명시한다. private const val RATE_LIMIT_DESCRIPTION = - "아이템 등록 한도 초과 (code: WISH-010). 한도는 요청 수가 아니라 등록하는 item 수로 센다 — " + - "이미지 5장 등록은 5 를 소모한다. 남은 몫이 있으면 그보다 큰 요청도 통과하므로(마지막 한 번은 성공) " + - "이 응답은 몫을 이미 다 쓴 뒤부터 나온다. Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." + "아이템 등록 한도 초과 (code: WISH-010). 몫은 위시 전용이 아니라 계정 하나의 몫이라, 이 사용자가 소유한 " + + "토너먼트에 아이템이 추가된 양(참여 게스트가 넣은 것 포함)도 같은 몫에서 빠진다. 한도는 요청 수가 아니라 " + + "등록하는 item 수로 센다 — 이미지 5장 등록은 5 를 소모하고, 새로고침도 파싱이 다시 돌므로 1 을 소모한다. " + + "남은 몫이 있으면 그보다 큰 요청도 통과하므로(마지막 한 번은 성공) 이 응답은 몫을 이미 다 쓴 뒤부터 나온다. " + + "Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." + +// 전역 가용량 소진(#927) 응답 설명. 429 와 원인이 다르다 — 요청자가 자기 몫을 다 쓴 것이 아니라 서비스가 꽉 찼다. +private const val CAPACITY_DESCRIPTION = + "서비스 전체의 시간당 처리 가용량 소진 (code: COMMON-SERVER-BUSY). 요청자가 자기 몫을 다 쓴 429 와 달리, " + + "서비스가 감당하기로 정한 총량이 찬 상태라 같은 시각 모든 사용자에게 동일하게 나간다. 정상 운영에서는 " + + "닿지 않는 마지노선이므로 이 응답이 반복되면 서버 쪽 이상 신호다. Retry-After 헤더에 가용량이 회복되기까지 " + + "남은 시간(초)이 실린다." @Tag(name = "Wishlist", description = "위시리스트 등록/조회/복구/삭제 API") interface WishlistApi { @@ -118,6 +128,23 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "503", + description = CAPACITY_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun registerFromUrl( @@ -465,6 +492,23 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "503", + description = CAPACITY_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun refreshWishItem( @@ -690,6 +734,23 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "503", + description = CAPACITY_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun registerFromImages( @@ -787,6 +848,23 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "503", + description = "$CAPACITY_DESCRIPTION 발급 시점에 확인하므로 이어지는 confirm 은 이 응답을 받지 않는다.", + headers = [ + Header( + name = "Retry-After", + description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun presignImageUploads( diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index 6ed0055a..5a930f58 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -54,6 +54,7 @@ class WishlistApiExamples( add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") add(itemQuotaExceeded, name = "아이템 등록 한도 초과") + add(capacityExceeded, name = "서비스 전체 가용량 소진") } } if (handlerMethod.binds(WishlistController::getWishlist)) { @@ -181,6 +182,7 @@ class WishlistApiExamples( add(WishException.notFound(), name = "존재하지 않는 위시 항목") unauthorized() add(itemQuotaExceeded, name = "아이템 등록 한도 초과") + add(capacityExceeded, name = "서비스 전체 가용량 소진") } } if (handlerMethod.binds(WishlistController::deleteWish)) { @@ -227,6 +229,7 @@ class WishlistApiExamples( add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (이미지 장수만큼 소모)") + add(capacityExceeded, name = "서비스 전체 가용량 소진") } } if (handlerMethod.binds(WishlistController::presignImageUploads)) { @@ -243,6 +246,7 @@ class WishlistApiExamples( add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (발급 시점에 장수만큼 소모)") + add(capacityExceeded, name = "서비스 전체 가용량 소진 (발급 시점에 확인)") } } if (handlerMethod.binds(WishlistController::confirmImageRegistration)) { @@ -272,6 +276,9 @@ class WishlistApiExamples( // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. private val itemQuotaExceeded = ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, 900) + // 전역 가용량 소진(#927). 요청자의 몫과 무관하게 서비스 전체가 찬 상태라 503 이고, code 도 도메인이 아닌 공통이다. + private val capacityExceeded = ItemQuotaException.capacityExceeded(900) + // 상세 조회 — 지금 보이는 값(item)과 그 상품의 가격 기록. 서버 추출값 사이에 타인이 넣은 수기(89,000원)가 섞여 있어, // source·editedByMe 로 구분해 그리는 예시다. private val wishDetailSample = diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index 1c74eccc..8e297743 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -1,7 +1,6 @@ package com.depromeet.piki.wishlist.service import com.depromeet.piki.common.ratelimit.ItemQuotaGuard -import com.depromeet.piki.common.ratelimit.ItemQuotaScope import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage @@ -69,7 +68,7 @@ class WishlistService( extractionRoutingPolicy.verifyRegistrable(link) // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. // 파서로 풀려 LLM 을 안 타도 fetch·프록시·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. - itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.persist(userId, Item(link)) } @@ -86,7 +85,7 @@ class WishlistService( // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. - itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, images.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, images.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. val imageKeys = productImages.map { uploadRaw(it) } // 위시 이미지 등록엔 정원 같은 계약 거부가 없어 정상 흐름에선 persist 가 떨어지지 않지만, 예기치 못한 영속화 실패에도 @@ -112,7 +111,7 @@ class WishlistService( // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). // 발급만 받고 업로드를 안 하면 그만큼 몫을 손해 보지만, 그건 클라이언트가 자기 요청을 버린 경우다. - itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, contentTypes.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, contentTypes.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.wish(key, userId, expiresAt) } @@ -280,7 +279,7 @@ class WishlistService( // 재추출도 파싱을 한 번 더 돌리므로 신규 등록과 같은 비용이다 — 1 로 차감한다. // refresh 계약 검증(링크 없음·FAILED 항목 등)은 persistence 안쪽이라 여기선 앞서 깎이는데, 그 두 사유는 // 클라가 refresh 버튼을 띄우지 않는 상태라 정상 흐름에서 반복 호출되지 않는다. - itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.refresh(userId = userId, wishId = wishId) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a9abc1ab..1767e88d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -171,14 +171,23 @@ s3: # "LLM 을 타는가" 가 아니라 "외부에 돈이 나가는가" 가 기준이다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타더라도 # fetch 대역, residential proxy 요청(HEADLESS_FIRST 사이트), 헤드리스 렌더러 시간, 이미지 저장, DB 행 영구 증가를 # 소모한다. 그래서 LLM 여부로 차등하지 않고 등록 1건을 균일하게 센다 — 등록 시점엔 어느 경로로 풀릴지 알 수도 없다. +# +# 무엇이 차감 대상인지의 기준은 "새 파싱 작업이 큐에 들어가는가" 하나다. 그래서 새로고침은 파싱이 한 번 더 도므로 +# 신규 등록과 같이 세고, 위시에 있는 item 을 토너먼트로 담는 것은 기존 item 을 참조만 하므로 세지 않는다. item-quota: # 한도가 잘못 잡혀 정상 사용자를 막을 때 되돌리는 스위치. 끄면 차감·판정을 통째로 건너뛴다. enabled: ${ITEM_QUOTA_ENABLED:true} window: ${ITEM_QUOTA_WINDOW:1h} - # 위시는 요청자 본인 몫 — 이미지 등록(최대 5장) 2번 또는 링크 10건. - wish-limit: ${ITEM_QUOTA_WISH_LIMIT:10} - # 토너먼트는 오너 한 명의 몫을 참여자 전원(최대 8명)이 나눠 쓰므로 위시보다 크게 둔다. - tournament-limit: ${ITEM_QUOTA_TOURNAMENT_LIMIT:30} + # 계정 한 명의 몫 — 등록 경로를 가리지 않는 하나의 총량이다. 내 위시 등록, 내가 내 토너먼트에 넣는 것, + # 참여 게스트가 내 토너먼트에 넣는 것이 전부 여기서 깎인다. 이미지 등록(최대 5장) 6번 또는 링크 30건. + user-limit: ${ITEM_QUOTA_USER_LIMIT:30} + # 전역 가용량 상한(#927) — 계정별 위에 얹는 총량. 계정별은 "한 사람이 100번" 을 막지만 "100명이 각자 30번" 은 + # 못 막는다. 넘으면 503(SERVER_BUSY) + Retry-After 로 흘려보낸다. 정상 운영에서는 닿지 않아야 하는 마지노선이라, + # 도달은 인기 신호가 아니라 이상 신호다. 파싱 경량화(화이트리스트 전환) 동안 더 조이고 싶으면 배포 없이 내린다. + capacity-limit: ${ITEM_QUOTA_CAPACITY_LIMIT:3000} + # 경고선(%). 상한에 닿으면 이미 사용자가 막히고 있어 늦으므로, 손 쓸 시간을 버는 이 지점이 실질 방어선이다. + # 기본 3000 기준 1980 건에서 울려 상한까지 창의 3분의 1(1020건)이 남는다. + capacity-alert-percent: ${ITEM_QUOTA_CAPACITY_ALERT_PERCENT:66} notification: # 알림 보존 기간(일). 생성 후 이 기간을 넘긴 알림은 N일 자동삭제 스케줄러(NotificationCleanupScheduler)가 하드삭제한다. diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt index a1166ea5..346615f7 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.common.ratelimit +import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.wishlist.domain.WishErrorCode @@ -41,6 +42,25 @@ class ItemQuotaExceptionTest { } } + @Test + fun `전역 가용량 소진은 429 가 아니라 503 과 공통 code 를 쓴다`() { + // 요청자가 자기 몫을 다 쓴 것이 아니라 서비스가 꽉 찬 상태라 4xx 가 아니다. 어느 등록 경로로 닿든 + // 원인도 안내도 하나라 도메인 code 를 두지 않고 공통 SERVER_BUSY 를 쓴다. + val exception = ItemQuotaException.capacityExceeded(retryAfterSeconds = 900) + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, exception.httpStatus) + assertEquals(ErrorCategory.SERVER_BUSY, exception.category) + assertEquals(CommonErrorCode.SERVER_BUSY, exception.errorCode) + assertEquals(CommonErrorCode.SERVER_BUSY.message, exception.message) + assertEquals(900, exception.retryAfterSeconds) + } + + @Test + fun `전역 가용량 소진도 재시도 시점이 0 이하면 코드 버그로 즉시 실패한다`() { + assertFailsWith { ItemQuotaException.capacityExceeded(retryAfterSeconds = 0) } + assertFailsWith { ItemQuotaException.capacityExceeded(retryAfterSeconds = -1) } + } + @Test fun `429 가 아닌 code 로 만들면 코드 버그로 즉시 실패한다`() { // status 는 category 가 소유하므로, 429 아닌 code 를 넘기면 "한도 초과인데 409" 같은 응답이 조용히 나간다. diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt index 34beb8c4..96b14573 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -1,6 +1,12 @@ package com.depromeet.piki.common.ratelimit import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.common.exception.CommonErrorCode +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubImageParsingWorker import com.depromeet.piki.support.StubImageStorage @@ -8,7 +14,9 @@ import com.depromeet.piki.support.StubItemParsingWorker import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.domain.WishErrorCode +import com.depromeet.piki.wishlist.repository.WishJpaRepository import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.redis.core.StringRedisTemplate @@ -26,6 +34,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -68,13 +77,22 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var stubImageStorage: StubImageStorage + @Autowired + private lateinit var itemJpaRepository: ItemJpaRepository + + @Autowired + private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository + + @Autowired + private lateinit var wishJpaRepository: WishJpaRepository + @Test fun `위시 링크 등록이 한도를 넘으면 429 와 WISH-010 code, Retry-After 헤더를 반환한다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertUser(userId, IdentityType.MEMBER) // 한도를 정확히 소진한 상태 — 다음 1건이 넘긴다. - fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit) + fillQuota(userId, properties.userLimit) mockMvc .perform( @@ -90,7 +108,40 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) // 거부된 요청은 카운터를 올리지 않는다 — 올리면 재시도할수록 창이 끝나도 한도를 넘긴 채 시작한다. - assertEquals(properties.wishLimit.toLong(), currentCount(ItemQuotaScope.WISH, userId)) + assertEquals(properties.userLimit.toLong(), currentCount(userId)) + } + + @Test + fun `전역 가용량이 소진되면 자기 몫이 남아 있어도 503 과 SERVER-BUSY code, Retry-After 헤더를 반환한다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + // 이 사용자는 자기 몫을 한 건도 쓰지 않았다. 그래도 막힌다는 것이 이 축의 존재 이유다 — + // 계정별 한도는 "한 사람이 100번" 을 막지만 "100명이 각자 10번" 은 막지 못한다. + fillCapacity() + + try { + mockMvc + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/9"}"""), + ).andExpect(status().isServiceUnavailable) + // 사용자 잘못이 아니라 서비스가 꽉 찬 것이라 4xx 가 아니고, 도메인 code 도 아니다. + .andExpect(jsonPath("$.code").value(CommonErrorCode.SERVER_BUSY.code)) + .andExpect(jsonPath("$.detail").value(CommonErrorCode.SERVER_BUSY.message)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) + + // 전역에서 막힌 요청은 요청자의 몫을 건드리지 않는다. 깎으면 안내대로 재시도할 때마다 자기 몫을 잃고, + // 가용량이 회복된 뒤에도 자기 한도에 걸려 429 를 받게 된다. + assertNull(currentCount(userId)) + } finally { + // 전역 카운터는 서비스에 하나뿐이라 UUID 로 격리할 수 없다. 지우지 않으면 같은 Redis 를 쓰는 + // 이후 등록 테스트가 전부 503 으로 깨진다(Redis 는 @Transactional 롤백 대상이 아니다). + redisTemplate.delete(RedisItemQuotaStore.CAPACITY_KEY) + } } @Test @@ -108,7 +159,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) // 요청 1건이 아니라 3 이 빠져야 한다 — 장마다 추출이 따로 돌기 때문이다. - assertEquals(3L, currentCount(ItemQuotaScope.WISH, userId)) + assertEquals(3L, currentCount(userId)) } @Test @@ -134,7 +185,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .getContentAsString(Charsets.UTF_8) val uploads = objectMapper.readTree(response).path("data").path("uploads") val keys = listOf(uploads.path(0).path("imageKey").asString(), uploads.path(1).path("imageKey").asString()) - assertEquals(2L, currentCount(ItemQuotaScope.WISH, userId)) + assertEquals(2L, currentCount(userId)) mockMvc .perform( @@ -145,7 +196,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isCreated) // 발급 시점에 이미 깎았으므로 확정은 0 이다. 여기서 또 깎으면 이미지 한 장이 두 번 세어진다. - assertEquals(2L, currentCount(ItemQuotaScope.WISH, userId)) + assertEquals(2L, currentCount(userId)) } finally { stubImageParsingWorker.enabled = true } @@ -157,7 +208,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertUser(userId, IdentityType.MEMBER) // 잔액을 1 만 남긴다 — 5장 요청은 그보다 크다. - fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit - 1) + fillQuota(userId, properties.userLimit - 1) // 요청량은 판정에 쓰지 않으므로 통째로 통과한다. "2장만 남아서 안 됩니다" 로 막으면 사용자는 자기 잔액을 // 모르는 채 몇 장으로 줄여야 할지도 알 수 없다 — 마지막 한 번은 성공시키고 그 다음부터 막는다. @@ -170,7 +221,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) // 한도를 넘겨 잔액이 음수가 됐다. - assertEquals((properties.wishLimit + 4).toLong(), currentCount(ItemQuotaScope.WISH, userId)) + assertEquals((properties.userLimit + 4).toLong(), currentCount(userId)) // 이제부터는 크기와 무관하게 거부다. mockMvc @@ -204,40 +255,95 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) // 요청자는 게스트지만 차감은 오너 몫에서 일어난다 — 게스트 계정을 갈아타도 한도가 리셋되지 않는 근거. - assertEquals(1L, currentCount(ItemQuotaScope.TOURNAMENT, ownerId)) - assertNull(currentCount(ItemQuotaScope.TOURNAMENT, guestId)) + assertEquals(1L, currentCount(ownerId)) + assertNull(currentCount(guestId)) } finally { stubItemParsingWorker.enabled = true } } @Test - fun `위시 한도를 다 써도 토너먼트 아이템은 담을 수 있다`() { + fun `위시로 몫을 다 쓰면 같은 계정의 토너먼트 아이템 추가도 막힌다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() insertUser(ownerId, IdentityType.MEMBER) - fillQuota(ItemQuotaScope.WISH, ownerId, properties.wishLimit) + fillQuota(ownerId, properties.userLimit) stubItemParsingWorker.enabled = false try { val (tournamentId, _) = createTournament(mockMvc, ownerId) - // 두 축은 별개 키라 위시 소진이 토너먼트를 막지 않는다 — 합쳐 두면 "친구들이 내 토너먼트에 담아서 - // 내가 내 위시를 못 쓰는" 반대 방향 사고도 함께 생긴다. + // 몫은 경로별이 아니라 계정 하나짜리다. 한때 위시·토너먼트를 별개 축으로 나눠 이 요청이 통과했는데, + // 그러면 한 계정의 실제 상한이 두 한도의 합이 되어 "이 계정이 시간당 얼마나 쓰나" 를 한 숫자로 말할 수 없다. mockMvc .perform( post("/api/v1/tournaments/$tournamentId/items/link") .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") .contentType(MediaType.APPLICATION_JSON) .content("""{"url":"https://www.musinsa.com/products/3"}"""), + ).andExpect(status().isTooManyRequests) + // 카운터는 하나지만 응답 code 는 경로가 소유한다 — 토너먼트에서 막혔으면 토너먼트 code 다. + .andExpect(jsonPath("$.code").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.code)) + } finally { + stubItemParsingWorker.enabled = true + } + } + + @Test + fun `위시 등록과 토너먼트 추가가 같은 카운터를 함께 쓴다`() { + val mockMvc = buildMockMvc() + val ownerId = UUID.randomUUID() + insertUser(ownerId, IdentityType.MEMBER) + stubItemParsingWorker.enabled = false + + try { + val (tournamentId, _) = createTournament(mockMvc, ownerId) + + mockMvc + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/10"}"""), + ).andExpect(status().isCreated) + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/link") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/11"}"""), ).andExpect(status().isOk) - assertEquals(1L, currentCount(ItemQuotaScope.TOURNAMENT, ownerId)) + // 두 경로가 각자 카운터를 가지면 여기서 1 과 1 이 되어 이 단언이 깨진다. + assertEquals(2L, currentCount(ownerId)) } finally { stubItemParsingWorker.enabled = true } } + @Test + fun `위시에 있는 아이템을 토너먼트로 담는 것은 몫을 쓰지 않는다`() { + val mockMvc = buildMockMvc() + val ownerId = UUID.randomUUID() + insertUser(ownerId, IdentityType.MEMBER) + // 등록 API 를 태우지 않고 READY 위시를 바로 만든다 — 등록분 차감을 섞지 않아야 "이동이 0" 인지가 선명하다. + // (출전은 활성 snapshot 이 READY 인 item 만 허용하므로 PENDING 인 갓 등록분으로는 이 경로를 탈 수 없다.) + val itemId = insertReadyWish(ownerId) + val (tournamentId, _) = createTournament(mockMvc, ownerId) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":[$itemId]}"""), + ).andExpect(status().isOk) + + // 이동은 이미 있는 item 을 참조만 할 뿐 새 파싱이 없다. 여기서 깎으면 같은 상품이 두 번 세어진다 + // (그 item 은 위시에 담길 때 이미 한 번 깎였다). + assertNull(currentCount(ownerId)) + } + @Test fun `토너먼트 오너의 몫이 소진되면 참여 게스트의 등록이 429 와 TOURNAMENT-037 로 거부된다`() { val mockMvc = buildMockMvc() @@ -248,7 +354,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { try { val (tournamentId, inviteCode) = createTournament(mockMvc, ownerId) val guestId = joinAsGuest(mockMvc, tournamentId, inviteCode) - fillQuota(ItemQuotaScope.TOURNAMENT, ownerId, properties.tournamentLimit) + fillQuota(ownerId, properties.userLimit) mockMvc .perform( @@ -299,17 +405,42 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { // 카운터를 미리 채워 경계 직전 상태를 만든다. 창 TTL 은 운영 경로(Lua)가 첫 차감 때 걸므로 여기서도 함께 건다 — // TTL 없는 키를 남기면 이후 테스트가 같은 UUID 를 재사용할 때(없지만) 영구 키가 된다. private fun fillQuota( - scope: ItemQuotaScope, userId: UUID, amount: Int, ) { - redisTemplate.opsForValue().set(scope.keyPrefix + userId, amount.toString(), properties.window) + redisTemplate + .opsForValue() + .set(RedisItemQuotaStore.USER_KEY_PREFIX + userId, amount.toString(), properties.window) } - private fun currentCount( - scope: ItemQuotaScope, - userId: UUID, - ): Long? = redisTemplate.opsForValue().get(scope.keyPrefix + userId)?.toLong() + private fun currentCount(userId: UUID): Long? = + redisTemplate.opsForValue().get(RedisItemQuotaStore.USER_KEY_PREFIX + userId)?.toLong() + + // 파싱이 끝난(READY) 위시 항목을 등록 API 없이 바로 만든다 — 토너먼트 출전은 활성 snapshot 이 READY 인 + // item 만 허용하므로, 등록 API 로 만든 PENDING 항목으로는 이동 경로를 탈 수 없다. itemId 를 돌려준다. + private fun insertReadyWish(userId: UUID): Long { + val item = itemJpaRepository.save(Item()) + val snapshot = + itemSnapshotJpaRepository.save( + ItemSnapshot( + itemId = item.getId(), + name = "한도 테스트 아이템", + price = 10_000, + currency = "KRW", + status = ItemStatus.READY, + extractedAt = LocalDateTime.now(), + ), + ) + wishJpaRepository.save(Wish(userId = userId, snapshotId = snapshot.getId())) + return item.getId() + } + + // 전역 카운터를 상한까지 채워 "서비스가 꽉 찬" 상태를 만든다. 부르는 테스트가 끝에서 반드시 키를 지운다. + private fun fillCapacity() { + redisTemplate + .opsForValue() + .set(RedisItemQuotaStore.CAPACITY_KEY, properties.capacityLimit.toString(), properties.window) + } private fun createTournament( mockMvc: MockMvc, diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt index 57ce8c10..585e89b6 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt @@ -4,16 +4,10 @@ import org.junit.jupiter.api.Test import java.time.Duration import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class ItemQuotaPropertiesTest { - @Test - fun `축마다 다른 한도를 돌려준다`() { - val properties = ItemQuotaProperties(wishLimit = 10, tournamentLimit = 30) - - assertEquals(10, properties.limitOf(ItemQuotaScope.WISH)) - assertEquals(30, properties.limitOf(ItemQuotaScope.TOURNAMENT)) - } - @Test fun `창 길이가 0 이면 부팅에서 실패한다`() { // 0 이면 첫 차감의 PEXPIRE 가 키를 즉시 지워 한도가 사실상 무제한이 된다 — 조용히 무력화되지 않게 부팅에서 막는다. @@ -35,15 +29,60 @@ class ItemQuotaPropertiesTest { } @Test - fun `위시 한도가 0 이하면 부팅에서 실패한다`() { + fun `계정 한도가 0 이하면 부팅에서 실패한다`() { // 0 은 "무제한" 이 아니라 "전부 거부" 다. 오타로 등록 기능이 통째로 막히는 것을 부팅에서 드러낸다. - assertFailsWith { ItemQuotaProperties(wishLimit = 0) } - assertFailsWith { ItemQuotaProperties(wishLimit = -1) } + assertFailsWith { ItemQuotaProperties(userLimit = 0) } + assertFailsWith { ItemQuotaProperties(userLimit = -1) } + } + + @Test + fun `전역 상한이 0 이하면 부팅에서 실패한다`() { + // 계정별과 달리 이 값이 0 이면 특정 사용자가 아니라 **모든** 사용자의 등록이 막힌다. + assertFailsWith { ItemQuotaProperties(capacityLimit = 0) } + assertFailsWith { ItemQuotaProperties(capacityLimit = -1) } + } + + @Test + fun `경고선 비율이 1 에서 100 밖이면 부팅에서 실패한다`() { + // 0 이하면 첫 요청부터 경고가 울려 신호가 죽고, 100 초과면 영원히 안 울려 경고선이 없는 것과 같다. + assertFailsWith { ItemQuotaProperties(capacityAlertPercent = 0) } + assertFailsWith { ItemQuotaProperties(capacityAlertPercent = 101) } + // 경계값 1·100 은 통과해야 한다 — 100 은 "상한에 닿을 때만 알린다" 는 유효한 설정이다. + assertEquals(1, ItemQuotaProperties(capacityAlertPercent = 1).capacityAlertPercent) + assertEquals(100, ItemQuotaProperties(capacityAlertPercent = 100).capacityAlertPercent) + } + + @Test + fun `경고선은 상한의 비율만큼으로 계산된다`() { + // 운영 기본값과 같은 조합 — 3000 의 66% 는 1980 이다. + val properties = ItemQuotaProperties(capacityLimit = 3_000, capacityAlertPercent = 66) + + assertEquals(1_980, properties.capacityAlertThreshold) + } + + @Test + fun `경고선 계산은 내림한다`() { + // 정수 나눗셈이라 10 * 66 / 100 = 6.6 → 6. 경고가 한 건 앞당겨질 뿐이라 무해하다. + assertEquals(6, ItemQuotaProperties(capacityLimit = 10, capacityAlertPercent = 66).capacityAlertThreshold) } @Test - fun `토너먼트 한도가 0 이하면 부팅에서 실패한다`() { - assertFailsWith { ItemQuotaProperties(tournamentLimit = 0) } - assertFailsWith { ItemQuotaProperties(tournamentLimit = -1) } + fun `경고선을 넘긴 첫 차감에서만 참이 된다`() { + val properties = ItemQuotaProperties(capacityLimit = 3_000, capacityAlertPercent = 66) + + // 1979 까지는 아직 아래. 1980 을 만든 이 한 건이 경계를 넘긴 건이다. + assertFalse(properties.crossedCapacityAlert(capacityUsed = 1_979, amount = 1)) + assertTrue(properties.crossedCapacityAlert(capacityUsed = 1_980, amount = 1)) + // 이미 넘긴 뒤의 차감은 거짓 — 참으로 두면 창이 끝날 때까지 매 요청이 같은 경고를 반복해 알림이 무뎌진다. + assertFalse(properties.crossedCapacityAlert(capacityUsed = 1_981, amount = 1)) + } + + @Test + fun `한 번에 경고선을 건너뛰어도 그 차감에서 참이 된다`() { + val properties = ItemQuotaProperties(capacityLimit = 3_000, capacityAlertPercent = 66) + + // 이미지 5장 등록처럼 한 요청이 여러 건을 소모하면 경고선을 정확히 밟지 않고 넘어간다(1978 → 1983). + // "누적 == 경고선" 으로 판정했다면 이 경우를 통째로 놓쳐 경고가 영영 안 울린다. + assertTrue(properties.crossedCapacityAlert(capacityUsed = 1_983, amount = 5)) } } diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt index 2c20c15f..1514cf1b 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt @@ -9,13 +9,15 @@ import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue -// 카운터 산술의 경계 검증. 진입점 계약(429 응답 모양·차감 귀속)은 ItemQuotaIntegrationTest 가 맡고, -// 여기서는 "한도 경계에서 정확히 어떻게 갈리고 창 TTL 이 어떻게 걸리는가" 만 본다. +// 카운터 산술의 경계 검증. 진입점 계약(429·503 응답 모양·차감 귀속)은 ItemQuotaIntegrationTest 가 맡고, +// 여기서는 "두 축이 한도 경계에서 정확히 어떻게 갈리고 창 TTL 이 어떻게 걸리는가" 만 본다. // // Redis 가 필요해 통합으로 두지만 DB 는 쓰지 않으므로 @Transactional 도 두지 않는다. -// 격리는 매 테스트의 새 UUID 키로 한다(Redis 는 트랜잭션 롤백 대상이 아니다). +// 격리는 매 테스트의 새 UUID 키로 한다(Redis 는 트랜잭션 롤백 대상이 아니다). 전역 축 키도 운영 상수를 쓰지 않고 +// 테스트마다 새로 만든다 — 운영 키를 건드리면 같은 Redis 를 쓰는 다른 테스트가 그 카운터에 걸려 깨진다. class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var store: RedisItemQuotaStore @@ -25,66 +27,122 @@ class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { @Test fun `잔액이 남아 있으면 허용하고 한도에 닿은 뒤부터 거부한다`() { - val key = newKey() + val owner = newKey() + val capacity = newKey() - assertIs(store.tryConsume(key, amount = 7, limit = 10, windowMillis = WINDOW_MILLIS)) + assertIs(consume(owner, capacity, amount = 7, ownerLimit = 10)) // 누적 7 < 10 이라 아직 잔액이 있다. 이 요청이 정확히 한도를 채운다. - assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) + assertIs(consume(owner, capacity, amount = 3, ownerLimit = 10)) // 누적 10 >= 10 — 잔액이 0 이므로 이제부터 거부다. - assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) + assertIs(consume(owner, capacity, amount = 1, ownerLimit = 10)) } @Test fun `잔액보다 큰 요청도 통과시키고 누적이 한도를 넘어 음수 잔액이 된다`() { - val key = newKey() - store.tryConsume(key, amount = 8, limit = 10, windowMillis = WINDOW_MILLIS) + val owner = newKey() + val capacity = newKey() + consume(owner, capacity, amount = 8, ownerLimit = 10) // 남은 몫은 2 뿐이지만 요청량은 판정에 쓰지 않으므로 3 이 통째로 통과한다. // 사용자 입장에서 "마지막 한 번은 항상 성공" 이고, 넘긴 만큼은 다음 요청이 갚는다. - assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) - assertEquals("11", redisTemplate.opsForValue().get(key)) + assertIs(consume(owner, capacity, amount = 3, ownerLimit = 10)) + assertEquals("11", redisTemplate.opsForValue().get(owner)) // 잔액이 음수(-1)라 다음 요청은 크기와 무관하게 거부된다. - assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) + assertIs(consume(owner, capacity, amount = 1, ownerLimit = 10)) } @Test - fun `거부된 차감은 카운터를 올리지 않는다`() { - val key = newKey() - store.tryConsume(key, amount = 10, limit = 10, windowMillis = WINDOW_MILLIS) + fun `거부된 차감은 어느 카운터도 올리지 않는다`() { + val owner = newKey() + val capacity = newKey() + consume(owner, capacity, amount = 10, ownerLimit = 10) // 넘치는 요청을 여러 번 반복해도 누적되지 않는다 — 누적하면 창이 끝나도 한도를 넘긴 채 시작해 사실상 영구 차단된다. repeat(3) { - val verdict = store.tryConsume(key, amount = 5, limit = 10, windowMillis = WINDOW_MILLIS) - assertIs(verdict) + assertIs(consume(owner, capacity, amount = 5, ownerLimit = 10)) } - assertEquals("10", redisTemplate.opsForValue().get(key)) + assertEquals("10", redisTemplate.opsForValue().get(owner)) + // 요청자 몫에서 거부된 요청이 전역 카운터를 올리면, 한 사용자의 남용이 서비스 전체 가용량을 갉아먹는다. + assertEquals("10", redisTemplate.opsForValue().get(capacity)) } @Test - fun `첫 차감이 창 TTL 을 걸고 이후 차감은 그 창을 연장하지 않는다`() { - val key = newKey() + fun `요청자 몫이 남아 있어도 전역 가용량이 차면 거부한다`() { + val owner = newKey() + val capacity = newKey() + // 전역만 소진시킨다. 이 사용자는 아직 한 건도 안 썼다. + redisTemplate.opsForValue().set(capacity, "100", java.time.Duration.ofMinutes(1)) + + val verdict = consume(owner, capacity, amount = 1, ownerLimit = 10, capacityLimit = 100) + + // "100명이 각자 10번" 을 막는 것이 이 축의 존재 이유다 — 개인 몫만 보면 전부 통과한다. + assertIs(verdict) + // 거부됐으므로 이 사용자의 몫은 손대지 않는다. 깎으면 안내대로 재시도할 때마다 자기 몫을 잃고, + // 전역이 풀린 뒤에도 자기 한도에 걸려 429 를 받게 된다. + assertNull(redisTemplate.opsForValue().get(owner)) + } + + @Test + fun `두 축이 모두 소진이면 요청자 몫 소진을 사유로 준다`() { + val owner = newKey() + val capacity = newKey() + redisTemplate.opsForValue().set(owner, "10", java.time.Duration.ofMinutes(1)) + redisTemplate.opsForValue().set(capacity, "100", java.time.Duration.ofMinutes(1)) + + // 자기가 다 쓴 사용자에게 "서버가 바빠요"(503)를 주면 원인을 서버 탓으로 오해한다. 자기 몫이 먼저다. + val verdict = consume(owner, capacity, amount = 1, ownerLimit = 10, capacityLimit = 100) + + assertIs(verdict) + } - store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) - val firstTtl = requireNotNull(redisTemplate.getExpire(key, TimeUnit.MILLISECONDS)) - assertTrue(firstTtl in 1..WINDOW_MILLIS, "첫 차감이 창 TTL 을 걸어야 한다: $firstTtl") + @Test + fun `허용된 차감은 두 카운터를 같은 양만큼 올린다`() { + val owner = newKey() + val capacity = newKey() + + val verdict = consume(owner, capacity, amount = 3, ownerLimit = 10) + + // 전역 누적값을 돌려주는 이유는 경고선 도달을 이 값으로 판정하기 때문이다. + assertEquals(3L, assertIs(verdict).capacityUsed) + assertEquals("3", redisTemplate.opsForValue().get(owner)) + assertEquals("3", redisTemplate.opsForValue().get(capacity)) + } + + @Test + fun `첫 차감이 두 축의 창 TTL 을 걸고 이후 차감은 그 창을 연장하지 않는다`() { + val owner = newKey() + val capacity = newKey() - store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) - val secondTtl = requireNotNull(redisTemplate.getExpire(key, TimeUnit.MILLISECONDS)) + consume(owner, capacity, amount = 1, ownerLimit = 10) + val firstOwnerTtl = requireNotNull(redisTemplate.getExpire(owner, TimeUnit.MILLISECONDS)) + val firstCapacityTtl = requireNotNull(redisTemplate.getExpire(capacity, TimeUnit.MILLISECONDS)) + assertTrue(firstOwnerTtl in 1..WINDOW_MILLIS, "첫 차감이 요청자 축 창 TTL 을 걸어야 한다: $firstOwnerTtl") + assertTrue(firstCapacityTtl in 1..WINDOW_MILLIS, "첫 차감이 전역 축 창 TTL 을 걸어야 한다: $firstCapacityTtl") + + consume(owner, capacity, amount = 1, ownerLimit = 10) // 고정 윈도우라 창은 첫 차감 시점부터 한 번만 흐른다. 차감마다 갱신하면 계속 쓰는 사용자의 창이 영영 안 끝난다. - assertTrue(secondTtl <= firstTtl, "이후 차감이 창을 연장하면 안 된다: first=$firstTtl second=$secondTtl") + assertTrue( + requireNotNull(redisTemplate.getExpire(owner, TimeUnit.MILLISECONDS)) <= firstOwnerTtl, + "이후 차감이 요청자 축 창을 연장하면 안 된다", + ) + assertTrue( + requireNotNull(redisTemplate.getExpire(capacity, TimeUnit.MILLISECONDS)) <= firstCapacityTtl, + "이후 차감이 전역 축 창을 연장하면 안 된다", + ) } @Test fun `거부 응답의 재시도 시간은 남은 창 안에서 최소 1초 이상이다`() { - val key = newKey() + val owner = newKey() + val capacity = newKey() // 잔액을 0 으로 만들어 다음 요청이 거부되게 한다. - store.tryConsume(key, amount = 10, limit = 10, windowMillis = WINDOW_MILLIS) + consume(owner, capacity, amount = 10, ownerLimit = 10) - val verdict = store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) + val verdict = consume(owner, capacity, amount = 1, ownerLimit = 10) - val exceeded = assertIs(verdict) + val exceeded = assertIs(verdict) // 0 을 주면 클라가 즉시 재시도해 또 거부되므로 최소 1초를 보장한다. 상한은 창 길이다. assertTrue( exceeded.retryAfterSeconds in 1..(WINDOW_MILLIS / 1000), @@ -92,18 +150,53 @@ class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { ) } + @Test + fun `전역 거부의 재시도 시간은 전역 창의 남은 시간을 따른다`() { + val owner = newKey() + val capacity = newKey() + // 요청자 축은 길게, 전역 축은 짧게 둬서 어느 창을 보고 답하는지 가른다. + consume(owner, capacity, amount = 1, ownerLimit = 10) + redisTemplate.opsForValue().set(capacity, "100", java.time.Duration.ofSeconds(5)) + + val verdict = consume(owner, capacity, amount = 1, ownerLimit = 10, capacityLimit = 100) + + val exceeded = assertIs(verdict) + // 요청자 축 창(60초)이 아니라 전역 축 창(5초)을 봐야 한다. 남의 창 시간을 주면 회복 전에 재시도하거나 + // 회복된 뒤에도 기다리게 된다. + assertTrue(exceeded.retryAfterSeconds in 1..5, "전역 창의 남은 시간이어야 한다: ${exceeded.retryAfterSeconds}") + } + @Test fun `차감량이 0 이하면 코드 버그로 즉시 실패한다`() { // 0 건 등록은 진입점 검증(이미지 개수 1~5)이 먼저 거르므로 여기 닿으면 호출부 버그다. assertFailsWith { - store.tryConsume(newKey(), amount = 0, limit = 10, windowMillis = WINDOW_MILLIS) + consume(newKey(), newKey(), amount = 0, ownerLimit = 10) } } + private fun consume( + ownerKey: String, + capacityKey: String, + amount: Int, + ownerLimit: Int, + capacityLimit: Int = SPACIOUS_CAPACITY, + ): ItemQuotaVerdict = + store.tryConsume( + ownerKey = ownerKey, + capacityKey = capacityKey, + amount = amount, + ownerLimit = ownerLimit, + capacityLimit = capacityLimit, + windowMillis = WINDOW_MILLIS, + ) + // 매 테스트가 자기 키를 쓴다 — Redis 는 트랜잭션 롤백이 없으므로 격리를 키 이름으로 만든다. private fun newKey(): String = "quota:item:test:${UUID.randomUUID()}" companion object { private const val WINDOW_MILLIS = 60_000L + + // 요청자 축을 보는 테스트가 전역 축에 걸려 엉뚱한 사유로 실패하지 않도록 넉넉히 열어둔 값. + private const val SPACIOUS_CAPACITY = 1_000_000 } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index a05c4d7f..07796721 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -62,6 +62,14 @@ jwt: refresh-token-expiry: 14d refresh-token-grace: 10s +# 아이템 등록 한도 — 계정별 축은 키가 사용자 UUID 로 갈려 테스트끼리 안 섞이지만, **전역 축은 키가 하나**라 +# 스위트 전체의 등록이 한 카운터에 누적된다(Redis 는 @Transactional 롤백 대상이 아니다). 운영 기본값(1000)을 +# 그대로 쓰면 등록이 많은 스위트가 상한에 닿아 무관한 테스트가 503 으로 깨진다. 전역 축의 강제를 검증하는 +# 테스트는 카운터를 직접 채워 상한 상태를 만들고 끝에서 지우므로(ItemQuotaIntegrationTest), 여기선 넉넉히 열어둔다. +# 나머지 값(enabled·window·wish-limit·tournament-limit)은 운영과 같은 data class 기본값을 그대로 쓴다. +item-quota: + capacity-limit: 1000000 + # S3 업로드는 ImageStorage stub 으로 격리되지만, S3Properties·S3Client 빈 자체는 컨텍스트에 생성되므로 # 더미 값으로 부팅만 통과시킨다 (S3Properties.init 의 notBlank 검증 충족). s3: