-
Notifications
You must be signed in to change notification settings - Fork 0
아이템 등록 사용량 한도와 게스트 권한 정리 #904
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
f87ebe8
feat: 토너먼트 생성을 회원 전용으로 제한
m-a-king 50c526f
feat: 아이템 등록에 계정 단위 사용량 한도 적용
m-a-king 2d0c995
docs: 아이템 등록 한도의 기준을 LLM 에서 외부 비용 전반으로 정정
m-a-king b1d5644
refactor: 아이템 등록 한도 판정을 요청량 비교에서 잔액 방식으로 변경
m-a-king 9c793d8
Merge remote-tracking branch 'origin/dev' into feat/339-user-item-rat…
m-a-king 6eb085b
fix: 아이템 등록 한도의 검증 구멍과 차감 순서를 CodeRabbit 리뷰대로 정정
m-a-king b7dd384
test: 이미지 presign·confirm 이중 차감 방지를 테스트로 고정
m-a-king File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 11 additions & 0 deletions
11
src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
43 changes: 43 additions & 0 deletions
43
src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } | ||
| } | ||
56 changes: 56 additions & 0 deletions
56
src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
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) | ||
| } | ||
| } | ||
| } | ||
55 changes: 55 additions & 0 deletions
55
src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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:"), | ||
| } |
11 changes: 11 additions & 0 deletions
11
src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.