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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 는 도메인이 소유해야 한다(위시가 막힌 것과 토너먼트가
// 막힌 것은 사용자에게 다른 문구여야 하고, 토너먼트 쪽은 오너의 사용량이라는 사실을 요청자에게 노출하면 안 된다).
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.util.UUID

// 아이템 등록 경로가 부르는 한도 게이트(#339).
// 아이템 등록 경로가 부르는 한도 게이트(#339·#927).
//
// 인터셉터가 아니라 서비스가 직접 부르는 이유 둘: (1) 차감 주체가 요청자가 아닐 수 있다 — 토너먼트 축은
// tournamentId 로 오너를 찾아야 알 수 있어 핸들러 진입 시점엔 모른다. (2) 차감량이 요청 내용에 달렸다 —
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 으로 갈지 알 수 없고,
Expand All @@ -27,29 +34,54 @@ 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 이 되어
// 창이 즉시 만료된다. 그러면 매 요청이 새 창을 열어 한도가 사실상 무제한이 되는데, 설정만 보면 정상으로 보인다.
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
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading