From ff87ec072fb2f8a82d9f5f09c9a219ac92b30b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:39:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=95=9C=EB=8F=84=EB=A5=BC=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EC=97=90=EC=84=9C=20=ED=99=95=EC=9D=B8=C2=B7?= =?UTF-8?q?=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한도가 env 에만 있어 비용이 튀어 급히 조여야 할 때도, 한도가 낮아 정상 사용자가 막힐 때도 배포나 재시작을 기다려야 했다. 둘 다 분 단위가 아까운 상황이라 배포 없이 바꿀 경로를 만든다 - 판정에 쓰이는 값을 ItemQuotaProperties(env)에서 ItemQuotaSnapshot(env + DB 오버라이드)으로 옮겼다. 소비자는 ItemQuotaSettings.current() 로 값 한 벌을 받는다 — 값을 하나씩 꺼내 쓰면 판정 도중 백오피스 저장이 끼어들어 한 요청이 옛 계정 한도와 새 전역 상한을 섞어 보게 된다 - 저장 구조는 단일 행에 각 컬럼 nullable 이고 null 은 "그 노브만 env 기본값" 이다. 부분 오버라이드를 허용해, 전역 상한 하나만 급히 내릴 때 나머지 값까지 화면에서 다시 적어 넣지 않게 했다. 행이 없으면 전부 기본값이다("행 없음 = 기본" 규약 — extraction_models 와 같다) - 캐시·재적재·afterCommit reload 는 DbExtractionModelSettings 를 그대로 따랐다. @Volatile 통째 교체(reader 가 반쪽 상태를 안 봄), load 에 @Synchronized(주기 재적재가 방금 저장한 값을 덮어쓰는 역전 방지), 화면은 캐시가 아니라 저장소를 직접 조회(캐시는 afterCommit 갱신이라 방금 저장한 값이 아직 안 보임) - 재적재 실패 시 기존 스냅샷을 유지한다. env 로 되돌리면 방금 조인 값이 조용히 풀려 비용 방어가 사라진다 - 창 길이(window)는 조절 대상에서 뺐다. 바꾸면 이미 돌고 있는 카운터는 옛 TTL 로 만료되고 새 카운터만 새 창을 쓰는데, 사용자마다 창 시작 시점이 달라 중간 상태를 설명할 수 없다. 조절 노브는 사용 여부·계정 한도·전역 상한·경고선 넷이다 - 사용량 화면은 전역 현황과 특정 계정 조회까지만 둔다. 상위 사용자 목록은 활성 사용자 키를 전부 훑어야 하는데 같은 Redis 를 refresh 토큰 저장소가 함께 쓰고 있어, 화면 한 번이 로그인 지연으로 번진다 - 잔액은 음수를 0 으로 접지 않고 그대로 보인다. 마지막 한 번이 한도를 넘길 수 있는 구조라(잔액 방식) 운영자에게는 "얼마나 넘겼나" 가 곧 신호다 - 검증: 오버라이드를 무시하도록 임시로 되돌리니 "한도를 내리면 그 다음 등록부터 막힌다" 와 "되돌리기" 두 테스트만 정확히 실패했다. DB 저장이 아니라 실제 등록 요청의 판정까지 확인한다는 뜻이다 --- .../piki/admin/audit/AdminAuditAction.kt | 4 + .../admin/quota/AdminItemQuotaController.kt | 100 ++++++++ .../piki/admin/quota/AdminItemQuotaService.kt | 182 ++++++++++++++ .../piki/common/ratelimit/ItemQuotaGuard.kt | 23 +- .../common/ratelimit/ItemQuotaProperties.kt | 15 +- .../common/ratelimit/ItemQuotaSettings.kt | 66 +++++ .../ratelimit/ItemQuotaSettingsEntity.kt | 51 ++++ .../common/ratelimit/ItemQuotaSnapshot.kt | 52 ++++ .../common/ratelimit/ItemQuotaUsageReader.kt | 49 ++++ ...0815062334__create_item_quota_settings.sql | 29 +++ src/main/resources/templates/admin/index.html | 4 + .../resources/templates/admin/item-quota.html | 205 ++++++++++++++++ .../quota/AdminItemQuotaIntegrationTest.kt | 230 ++++++++++++++++++ .../ratelimit/ItemQuotaIntegrationTest.kt | 23 +- .../ratelimit/ItemQuotaPropertiesTest.kt | 36 --- .../common/ratelimit/ItemQuotaSnapshotTest.kt | 107 ++++++++ 16 files changed, 1108 insertions(+), 68 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaController.kt create mode 100644 src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaService.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettingsEntity.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshot.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaUsageReader.kt create mode 100644 src/main/resources/db/migration/V20260815062334__create_item_quota_settings.sql create mode 100644 src/main/resources/templates/admin/item-quota.html create mode 100644 src/test/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaIntegrationTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshotTest.kt diff --git a/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt b/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt index bcb1b6e2..9fed5fd7 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt @@ -14,6 +14,10 @@ enum class AdminAuditAction { // 모델 교체는 추출 품질·비용에 직결되므로 행위자가 남아야 한다. EXTRACTION_MODEL_UPDATE, + // 아이템 등록 한도(#934) — 누가 어느 노브를 무슨 값으로 바꿨는지. 한도는 비용·사용자 차단에 직결되고 + // 배포 없이 바뀌므로, 값이 왜 이렇게 되어 있는지를 되짚을 유일한 기록이 이 로그다. + ITEM_QUOTA_UPDATE, + // 공지 행위자 추적(#558) — 등록·예약·예약취소·발송을 각각 다른 코드로 남겨 audit 에서 action 별로 가른다. // (이전엔 예약·취소·발송이 ANNOUNCEMENT_SEND 한 코드로 뭉쳐 detail 문자열로만 구분됐다.) ANNOUNCEMENT_REGISTER, diff --git a/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaController.kt b/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaController.kt new file mode 100644 index 00000000..37d974b7 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaController.kt @@ -0,0 +1,100 @@ +package com.depromeet.piki.admin.quota + +import com.depromeet.piki.admin.access.AdminSession +import com.depromeet.piki.admin.config.ClientIp +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import io.swagger.v3.oas.annotations.Hidden +import jakarta.servlet.http.HttpServletRequest +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam + +// 아이템 등록 한도 화면(#934). 지금 얼마나 쓰고 있는지 보고, 한도를 배포 없이 조절한다 — +// AdminExtractionModelController 와 같은 토대(게이트는 슬랙-세션 #526, actor 는 AdminSession.actorName). +// +// 목록 하나로 끝난다(상세 화면 없음). 설정이 한 벌뿐이라 상세로 들어갈 것이 없고, 계정 사용량 조회도 +// 같은 화면의 폼 하나로 처리해 "현황을 보다가 바로 조인다" 는 흐름이 끊기지 않게 한다. +@Hidden +@Controller +@ConditionalOnAdminEnabled +@RequestMapping("/admin/item-quota") +class AdminItemQuotaController( + private val adminItemQuotaService: AdminItemQuotaService, +) { + @GetMapping + fun board(view: Model): String = boardView(view) + + // 계정 사용량 조회 — 조회일 뿐이라 GET 이고, 결과를 같은 화면에 얹는다. + @GetMapping("/usage") + fun usage( + @RequestParam("userId") rawUserId: String, + view: Model, + ): String = + try { + view.addAttribute("userUsage", adminItemQuotaService.usageOf(rawUserId)) + view.addAttribute("draftUserId", rawUserId) + boardView(view) + } catch (e: IllegalArgumentException) { + view.addAttribute("draftUserId", rawUserId) + errorView(e, view) + } + + // 빈 칸은 "그 노브를 기본값으로" 다. 네 칸을 한 번에 받으므로 제출된 화면이 곧 최종 상태다. + @PostMapping + fun save( + @RequestParam("enabled", required = false) enabled: Boolean?, + @RequestParam("userLimit", required = false) userLimit: Int?, + @RequestParam("capacityLimit", required = false) capacityLimit: Int?, + @RequestParam("capacityAlertPercent", required = false) capacityAlertPercent: Int?, + request: HttpServletRequest, + view: Model, + ): String = + try { + adminItemQuotaService.save( + ItemQuotaSettingsForm(enabled, userLimit, capacityLimit, capacityAlertPercent), + actor = AdminSession.actorName(request), + clientIp = ClientIp.of(request), + ) + "redirect:/admin/item-quota?updated" + } catch (e: IllegalArgumentException) { + // 제출값을 유지한 채 목록에 에러를 표시한다(AdminExtractionModelController.save 와 같은 결). + view.addAttribute("draftEnabled", enabled) + view.addAttribute("draftUserLimit", userLimit) + view.addAttribute("draftCapacityLimit", capacityLimit) + view.addAttribute("draftCapacityAlertPercent", capacityAlertPercent) + errorView(e, view) + } + + @PostMapping("/reset") + fun reset( + request: HttpServletRequest, + view: Model, + ): String = + try { + adminItemQuotaService.reset( + actor = AdminSession.actorName(request), + clientIp = ClientIp.of(request), + ) + "redirect:/admin/item-quota?reset" + } catch (e: IllegalArgumentException) { + errorView(e, view) + } + + // 목록 모델 채우기 단일 지점 — 정상 진입과 모든 에러 재표시가 공유한다(한쪽만 갱신돼 에러 화면에서 + // 현황이 비는 함정 방지). + private fun boardView(view: Model): String { + view.addAttribute("board", adminItemQuotaService.board()) + return "admin/item-quota" + } + + private fun errorView( + e: IllegalArgumentException, + view: Model, + ): String { + view.addAttribute("error", e.message) + return boardView(view) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaService.kt b/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaService.kt new file mode 100644 index 00000000..b05b1ca1 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaService.kt @@ -0,0 +1,182 @@ +package com.depromeet.piki.admin.quota + +import com.depromeet.piki.admin.audit.AdminAuditAction +import com.depromeet.piki.admin.audit.AdminAuditService +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import com.depromeet.piki.common.ratelimit.DbItemQuotaSettings +import com.depromeet.piki.common.ratelimit.ItemQuotaProperties +import com.depromeet.piki.common.ratelimit.ItemQuotaSettingsEntity +import com.depromeet.piki.common.ratelimit.ItemQuotaSettingsJpaRepository +import com.depromeet.piki.common.ratelimit.ItemQuotaSnapshot +import com.depromeet.piki.common.ratelimit.ItemQuotaUsage +import com.depromeet.piki.common.ratelimit.ItemQuotaUsageReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.LocalDateTime +import java.util.UUID + +// 백오피스 아이템 등록 한도 관리(#934). 배포 없이 한도를 조이거나 푼다. +// +// **왜 필요한가**: 값이 env 에만 있으면 비용이 튀어 급히 조여야 할 때도, 한도가 낮아 정상 사용자가 막힐 때도 +// 배포나 재시작을 기다려야 한다. 둘 다 분 단위가 아까운 상황이다. +@Service +@ConditionalOnAdminEnabled +class AdminItemQuotaService( + private val settings: DbItemQuotaSettings, + private val properties: ItemQuotaProperties, + private val usageReader: ItemQuotaUsageReader, + private val writer: ItemQuotaSettingsWriter, +) { + // 화면은 저장소를 직접 읽는다(캐시가 아니라) — 캐시는 afterCommit 갱신이라 방금 저장한 값이 아직 안 보인다. + @Transactional(readOnly = true) + fun board(): AdminItemQuotaView { + val override = settings.findOverride() + return AdminItemQuotaView( + effective = ItemQuotaSnapshot.of(properties, override), + defaults = ItemQuotaSnapshot.of(properties), + override = override, + capacityUsage = usageReader.capacity(), + ) + } + + @Transactional(readOnly = true) + fun usageOf(rawUserId: String): AdminItemQuotaUserUsage { + val userId = + try { + UUID.fromString(rawUserId.trim()) + } catch (e: IllegalArgumentException) { + // 형식 오류는 화면에서 되돌려 줄 계약 위반이다. 예외 메시지에 입력값을 싣지 않는다. + throw IllegalArgumentException("userId 형식이 올바르지 않습니다 (UUID).", e) + } + return AdminItemQuotaUserUsage(userId = userId, usage = usageReader.user(userId)) + } + + // 빈 칸은 "그 노브를 기본값으로 되돌린다" 는 뜻이다(null 저장). 네 칸을 한 번에 저장하므로 화면이 곧 최종 상태다. + fun save( + form: ItemQuotaSettingsForm, + actor: String, + clientIp: String?, + ) { + val previous = ItemQuotaSnapshot.of(properties, settings.findOverride()) + // 엔티티 생성자의 require 가 불변식 층이지만, 그 메시지는 개발자용이라 화면에 그대로 내보내지 않는다. + // 사용자 대면 문구는 이 경계가 소유한다(CLAUDE.md "검증은 입력 경계와 엔티티 양쪽에"). + form.userLimit?.let { require(it > 0) { "계정 한도는 1 이상이어야 합니다 (0 이면 등록이 통째로 막힙니다)." } } + form.capacityLimit?.let { require(it > 0) { "전역 상한은 1 이상이어야 합니다 (0 이면 모든 사용자가 막힙니다)." } } + form.capacityAlertPercent?.let { require(it in 1..100) { "경고선은 1 에서 100 사이여야 합니다." } } + val entity = + ItemQuotaSettingsEntity( + enabled = form.enabled, + userLimit = form.userLimit, + capacityLimit = form.capacityLimit, + capacityAlertPercent = form.capacityAlertPercent, + ) + writer.write(entity, previous, actor, clientIp) + } + + fun reset( + actor: String, + clientIp: String?, + ) = writer.reset(ItemQuotaSnapshot.of(properties, settings.findOverride()), actor, clientIp) +} + +// 영속화 전용 빈. 같은 클래스 안에서 @Transactional 메서드를 부르면 Spring AOP proxy 를 거치지 않아 트랜잭션이 +// 무력화되므로(self-invocation), 경계를 나누려면 빈 자체가 갈려야 한다(ExtractionModelWriter 와 같은 이유). +@Service +@ConditionalOnAdminEnabled +class ItemQuotaSettingsWriter( + private val repository: ItemQuotaSettingsJpaRepository, + private val settings: DbItemQuotaSettings, + private val properties: ItemQuotaProperties, + private val auditService: AdminAuditService, +) { + // upsert — PK 가 상수라 save 가 늘 같은 행을 덮어쓴다. "지우고 새로 넣기" 로 수정하면 그 사이 전부 + // 기본값으로 돌아가는 창이 생긴다(ExtractionModelWriter.write 와 같은 이유). + @Transactional + fun write( + entity: ItemQuotaSettingsEntity, + previous: ItemQuotaSnapshot, + actor: String, + clientIp: String?, + ) { + repository.save(entity) + record(previous, ItemQuotaSnapshot.of(properties, entity), actor, clientIp) + reloadAfterCommit() + } + + // 전체 초기화 — 행을 지워 네 노브를 한 번에 env 기본값으로 되돌린다. 급히 조인 값을 원복하는 자리다. + @Transactional + fun reset( + previous: ItemQuotaSnapshot, + actor: String, + clientIp: String?, + ) { + repository.deleteById(ItemQuotaSettingsEntity.SINGLE_ROW_ID) + record(previous, ItemQuotaSnapshot.of(properties), actor, clientIp) + reloadAfterCommit() + } + + // 바뀐 노브만 남긴다 — 매번 네 값을 다 적으면 로그에서 "이번에 무엇이 달라졌나" 를 사람이 다시 비교해야 한다. + private fun record( + before: ItemQuotaSnapshot, + after: ItemQuotaSnapshot, + actor: String, + clientIp: String?, + ) { + val changes = + listOfNotNull( + diff("사용", before.enabled, after.enabled), + diff("계정 한도", before.userLimit, after.userLimit), + diff("전역 상한", before.capacityLimit, after.capacityLimit), + diff("경고선(%)", before.capacityAlertPercent, after.capacityAlertPercent), + ) + // 값이 그대로여도 기록은 남긴다 — "누가 이 화면에서 저장을 눌렀나" 자체가 추적 대상이고, + // 변경 없음이 곧 "확인만 했다" 는 정보다. + auditService.record( + actor, + AdminAuditAction.ITEM_QUOTA_UPDATE, + changes.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "변경 없음", + clientIp, + ) + } + + private fun diff( + label: String, + before: T, + after: T, + ): String? = if (before == after) null else "$label: $before → $after" + + // 캐시 갱신은 커밋 후로 미룬다 — 커밋 전 reload 면 이후 단계 롤백 시 캐시만 새 값으로 남아 DB 와 어긋난다. + private fun reloadAfterCommit() { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() = settings.reload() + }, + ) + } +} + +// 화면 입력 한 벌. null 은 "그 노브는 기본값을 쓴다" 는 뜻이라 빈 칸이 그대로 전달된다. +data class ItemQuotaSettingsForm( + val enabled: Boolean?, + val userLimit: Int?, + val capacityLimit: Int?, + val capacityAlertPercent: Int?, +) + +// effective 는 지금 판정에 쓰이는 값, defaults 는 오버라이드를 걷어냈을 때의 env 값이다. 둘을 나란히 보여야 +// "이 값이 왜 이런가"(기본인가, 누가 바꾼 것인가)를 화면에서 바로 안다. override 가 null 이면 전부 기본값이다. +data class AdminItemQuotaView( + val effective: ItemQuotaSnapshot, + val defaults: ItemQuotaSnapshot, + val override: ItemQuotaSettingsEntity?, + val capacityUsage: ItemQuotaUsage, +) { + val overriddenAt: LocalDateTime? get() = override?.updatedAt +} + +data class AdminItemQuotaUserUsage( + val userId: UUID, + val usage: ItemQuotaUsage, +) 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 2f49c372..42e3474b 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt @@ -13,7 +13,7 @@ import java.util.UUID @Component class ItemQuotaGuard( private val store: RedisItemQuotaStore, - private val properties: ItemQuotaProperties, + private val settings: ItemQuotaSettings, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -30,7 +30,9 @@ class ItemQuotaGuard( amount: Int, errorCode: ErrorCode, ) { - if (!properties.enabled) return + // 값 한 벌을 한 번만 읽는다 — 판정 도중 백오피스 저장이 끼어들어도 이 요청은 끝까지 같은 값으로 판단한다. + val quota = settings.current() + if (!quota.enabled) return val verdict = try { @@ -38,9 +40,9 @@ class ItemQuotaGuard( ownerKey = RedisItemQuotaStore.USER_KEY_PREFIX + ownerId, capacityKey = RedisItemQuotaStore.CAPACITY_KEY, amount = amount, - ownerLimit = properties.userLimit, - capacityLimit = properties.capacityLimit, - windowMillis = properties.window.toMillis(), + ownerLimit = quota.userLimit, + capacityLimit = quota.capacityLimit, + windowMillis = quota.window.toMillis(), ) } catch (e: Exception) { // fail-open — Redis 장애로 등록 기능 전체가 멈추는 것보다, 한도가 잠시 안 걸리는 쪽이 낫다. @@ -55,7 +57,7 @@ class ItemQuotaGuard( } when (verdict) { - is ItemQuotaVerdict.Allowed -> warnIfCapacityAlertCrossed(verdict.capacityUsed, amount) + is ItemQuotaVerdict.Allowed -> warnIfCapacityAlertCrossed(quota, verdict.capacityUsed, amount) // 429 는 클라이언트 계약 위반이라 GlobalExceptionHandler 가 info 로 남긴다 — 여기서 또 찍지 않는다. is ItemQuotaVerdict.OwnerExceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds) // 503 도 핸들러가 warn 으로 남긴다. 한 번 차면 창이 끝날 때까지 모든 요청이 여기로 오므로, @@ -70,10 +72,11 @@ class ItemQuotaGuard( // 대응은 "상한을 올린다" 가 기본이 아니다. 정상 성장인지, 특정 사용자·IP 의 이상 패턴인지, 파싱 실패로 인한 // 재시도 폭증인지를 먼저 가르고, 정상 성장으로 확인된 뒤에만 올린다. private fun warnIfCapacityAlertCrossed( + quota: ItemQuotaSnapshot, capacityUsed: Long, amount: Int, ) { - if (!properties.crossedCapacityAlert(capacityUsed, amount)) return + if (!quota.crossedCapacityAlert(capacityUsed, amount)) return // 알림이 매칭하는 줄이라 **사람이 읽는 문구가 아니라 기계가 읽는 형식**으로 쓴다(item.parse.result 와 같은 규약): // 고정 이벤트 키 + logfmt(`키=값`). 사람이 읽을 설명은 알림 룰의 summary 가 한국어로 담는다. // @@ -86,9 +89,9 @@ class ItemQuotaGuard( "{} used={} threshold={} limit={} windowSeconds={}", CAPACITY_ALERT_EVENT, capacityUsed, - properties.capacityAlertThreshold, - properties.capacityLimit, - properties.window.seconds, + quota.capacityAlertThreshold, + quota.capacityLimit, + quota.window.seconds, ) } 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 de2445c2..421a2fc4 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -3,7 +3,10 @@ package com.depromeet.piki.common.ratelimit import org.springframework.boot.context.properties.ConfigurationProperties import java.time.Duration -// 아이템 등록 한도 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. +// 아이템 등록 한도의 **env 기본값**. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. +// +// 판정에 쓰이는 실효값은 여기가 아니라 ItemQuotaSnapshot 이다(#934) — 백오피스 오버라이드가 이 값들 위에 +// 얹히기 때문이다. 오버라이드가 없는 노브만 여기 값이 그대로 남는다. 소비자는 ItemQuotaSettings.current() 를 읽는다. // // 축이 둘이고 서로를 대체하지 않는다. **계정별**(#339)은 한 사람이 얼마나 쓸 수 있는지를, **전역**(#927)은 // 서비스 전체가 얼마나 감당하는지를 정한다. 전자는 남용을, 후자는 정상 사용자가 몰리는 상황을 막는다. @@ -74,14 +77,4 @@ data class ItemQuotaProperties( "item-quota.capacity-alert-percent($capacityAlertPercent)는 1 에서 100 사이여야 한다." } } - - // 경고선(건수). 정수 나눗셈이라 내림되지만 경고 시점이 한 건 앞당겨질 뿐이라 무해하다. - 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/ItemQuotaSettings.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt new file mode 100644 index 00000000..e510feec --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt @@ -0,0 +1,66 @@ +package com.depromeet.piki.common.ratelimit + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +// 지금 적용 중인 한도 값의 단일 조회 지점. 인터페이스/구현 분리는 ExtractionRoutingPolicy·ExtractionModelSettings 와 +// 같은 구조 — 소비자(한도 게이트)의 단위 테스트가 DB 없이 값을 대체할 수 있게 한다. +interface ItemQuotaSettings { + fun current(): ItemQuotaSnapshot +} + +// DB(item_quota_settings) 기반 구현. 백오피스가 배포 없이 한도를 바꾼다(#934) — 판정은 잦으므로(등록마다) +// 매번 DB 를 치지 않고 메모리 캐시로 읽고, 백오피스 저장(afterCommit)과 주기 재적재가 reload() 로 갱신한다 +// (DbExtractionModelSettings 와 같은 패턴). +// +// 오버라이드는 env 기본값 **위에 얹는다.** 행이 없거나 그 컬럼이 null 이면 그 노브는 env 값을 그대로 쓴다. +@Component +class DbItemQuotaSettings( + private val repository: ItemQuotaSettingsJpaRepository, + private val properties: ItemQuotaProperties, +) : ItemQuotaSettings { + private val log = LoggerFactory.getLogger(javaClass) + + // 불변 스냅샷을 통째로 교체(@Volatile)한다 — reader(등록 스레드)는 항상 옛/새 전체 중 하나만 본다. + // 초기값을 env 로 두는 이유: @PostConstruct 이전이나 DB 재적재 실패 창에서도 판정이 값 없이 멈추지 않는다. + @Volatile + private var snapshot: ItemQuotaSnapshot = ItemQuotaSnapshot.of(properties) + + // @Synchronized 인 이유: 조회와 교체가 갈라져 있으면 늦게 시작한 재적재가 먼저 끝나는 역전이 생긴다. + // 주기 재적재가 DB 를 읽는 사이 백오피스 저장의 afterCommit 재적재가 통째로 끝나 버리면, 뒤늦게 완료된 + // 주기 재적재가 방금 저장한 값을 옛 값으로 덮어쓴다 — 다음 주기(5분)까지 "저장했는데 안 바뀐" 상태가 된다. + @PostConstruct + @Synchronized + fun load() { + // 재적재 실패(일시 DB 오류)에 기존 스냅샷을 유지한다 — 한도를 env 로 되돌리면 방금 조인 값이 조용히 + // 풀려 비용 방어가 사라진다. 실패는 다음 주기에 재시도된다. + val entity = + try { + findOverride() + } catch (e: Exception) { + log.warn("아이템 한도 설정 재적재 실패 — 기존 값을 유지한다.", e) + return + } + snapshot = ItemQuotaSnapshot.of(properties, entity) + } + + // 백오피스 저장 직후(afterCommit)와 주기 재적재가 함께 부른다. 주기 재적재는 다른 인스턴스에서 바뀐 값을 + // 이 인스턴스가 따라잡는 유일한 경로다 — blue-green 공존·수평 확장에서 stale 이 이 주기로 바운드된다. + @Scheduled(fixedDelay = RELOAD_INTERVAL_MS) + fun reload() = load() + + override fun current(): ItemQuotaSnapshot = snapshot + + // 캐시 적재와 백오피스 화면이 함께 쓰는 단일 조회 지점. 화면이 캐시가 아니라 저장소를 직접 읽는 이유는 + // 캐시가 afterCommit 갱신이라 방금 저장한 값이 아직 안 보일 수 있어서다(DbExtractionModelSettings 와 같다). + fun findOverride(): ItemQuotaSettingsEntity? = + repository.findById(ItemQuotaSettingsEntity.SINGLE_ROW_ID).orElse(null) + + companion object { + // stale 상한. 한도 변경은 사람 손의 백오피스 조작이라 분 단위 전파면 충분하다 + // (DbExtractionModelSettings 와 같은 값·같은 이유). + private const val RELOAD_INTERVAL_MS = 300_000L + } +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettingsEntity.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettingsEntity.kt new file mode 100644 index 00000000..589da0c4 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettingsEntity.kt @@ -0,0 +1,51 @@ +package com.depromeet.piki.common.ratelimit + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +// 아이템 등록 한도의 백오피스 오버라이드 행(#934). **서비스 전체에 최대 하나만 존재한다** — 축별로 갈리는 +// extraction_models 와 달리 이 값들은 전역 설정이라 키가 없고, PK 를 상수로 못박아 두 행이 생기지 않게 한다. +// +// **모든 값이 nullable 이고 null 은 "이 노브는 env 기본값을 쓴다" 는 뜻이다.** 그래서 상한 하나만 급히 내릴 때 +// 나머지를 화면에서 다시 적어 넣을 필요가 없다. 행 자체가 없으면 전부 기본값이다. +@Entity +@Table(name = "item_quota_settings") +class ItemQuotaSettingsEntity( + @Column(name = "enabled") + val enabled: Boolean? = null, + @Column(name = "user_limit") + val userLimit: Int? = null, + @Column(name = "capacity_limit") + val capacityLimit: Int? = null, + @Column(name = "capacity_alert_percent") + val capacityAlertPercent: Int? = null, +) { + // 행이 하나뿐이라 PK 가 식별이 아니라 "단일 행" 이라는 제약 그 자체다. save 가 늘 같은 id 를 쓰므로 + // 저장은 자동으로 upsert 가 된다 — "지우고 새로 넣기" 로 수정하면 그 사이 전부 기본값으로 돌아가는 창이 생긴다. + @Id + @Column(name = "id") + val id: Byte = SINGLE_ROW_ID + + init { + // 불변식 층 — 값의 사용자 대면 검증은 입력 경계(AdminItemQuotaService)가 지고, 여기는 새 생성 경로가 + // 그것을 빠뜨리는 코드 버그를 잡는다(CLAUDE.md "검증은 입력 경계와 엔티티 양쪽에 둔다"). + // null 은 "기본값 사용" 이라 검증 대상이 아니다 — 값이 있을 때만 범위를 본다. + userLimit?.let { require(it > 0) { "user_limit($it)은 양수여야 한다 — 0 이면 등록이 통째로 막힌다." } } + capacityLimit?.let { require(it > 0) { "capacity_limit($it)은 양수여야 한다 — 0 이면 모든 사용자가 막힌다." } } + capacityAlertPercent?.let { require(it in 1..100) { "capacity_alert_percent($it)는 1 에서 100 사이여야 한다." } } + } + + @Column(name = "updated_at", nullable = false) + val updatedAt: LocalDateTime = LocalDateTime.now() + + companion object { + // DDL 의 CHECK (id = 1) 과 같은 값. 코드가 다른 id 를 쓰면 DB 제약이 막는다(이중 방어). + const val SINGLE_ROW_ID: Byte = 1 + } +} + +interface ItemQuotaSettingsJpaRepository : JpaRepository diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshot.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshot.kt new file mode 100644 index 00000000..c6d76e13 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshot.kt @@ -0,0 +1,52 @@ +package com.depromeet.piki.common.ratelimit + +import java.time.Duration + +// 지금 이 순간 적용 중인 한도 값 한 벌. env 기본값 위에 백오피스 오버라이드(#934)를 얹은 **결과**다. +// +// 값을 하나씩 꺼내 쓰지 않고 이 불변 객체를 통째로 읽는 이유: 판정 도중 백오피스 저장이 끼어들면 한 요청이 +// 옛 user-limit 과 새 capacity-limit 을 섞어 보게 된다. 한 번 받아 끝까지 그것만 쓰면 그런 반쪽 상태가 없다. +data class ItemQuotaSnapshot( + val enabled: Boolean, + // 창 길이는 백오피스 조절 대상이 아니다(env 전용) — 바꾸면 이미 돌고 있는 카운터는 옛 TTL 로 만료되고 + // 새 카운터만 새 창을 쓰는데, 사용자마다 창 시작 시점이 달라 중간 상태를 설명할 수 없다. + val window: Duration, + val userLimit: Int, + val capacityLimit: Int, + val capacityAlertPercent: Int, +) { + init { + // 불변식 층이다. env 는 부팅 시 ItemQuotaProperties 가, 백오피스 입력은 AdminItemQuotaService 가 먼저 거른다. + // 정상 경로로는 여기 닿지 않으며, 닿았다면 어느 경계가 검증을 빠뜨린 것이다. + require(window.toMillis() > 0) { "window($window)는 1ms 이상이어야 한다 — 그 미만은 창이 즉시 만료돼 한도가 무의미해진다." } + require(userLimit > 0) { "userLimit($userLimit)은 양수여야 한다." } + require(capacityLimit > 0) { "capacityLimit($capacityLimit)은 양수여야 한다." } + require(capacityAlertPercent in 1..100) { "capacityAlertPercent($capacityAlertPercent)는 1 에서 100 사이여야 한다." } + } + + // 경고선(건수). 정수 나눗셈이라 내림되지만 경고 시점이 한 건 앞당겨질 뿐이라 무해하다. + val capacityAlertThreshold: Int get() = capacityLimit * capacityAlertPercent / 100 + + // 이번 차감이 경고선을 **처음** 넘겼는지. 넘긴 뒤 매 요청마다 경고하면 창이 끝날 때까지 같은 줄이 반복돼 + // 알림이 무뎌지므로, "직전엔 아래였는데 지금은 위" 인 한 건만 참이 된다. + fun crossedCapacityAlert( + capacityUsed: Long, + amount: Int, + ): Boolean = capacityUsed >= capacityAlertThreshold && capacityUsed - amount < capacityAlertThreshold + + companion object { + // env 기본값 위에 백오피스 오버라이드를 얹는 단일 지점. 오버라이드가 없거나(행 없음) 그 컬럼이 null 이면 + // 그 노브만 env 값이 남는다 — 부분 오버라이드라 상한 하나만 급히 내릴 때 나머지를 다시 적을 필요가 없다. + fun of( + properties: ItemQuotaProperties, + override: ItemQuotaSettingsEntity? = null, + ): ItemQuotaSnapshot = + ItemQuotaSnapshot( + enabled = override?.enabled ?: properties.enabled, + window = properties.window, + userLimit = override?.userLimit ?: properties.userLimit, + capacityLimit = override?.capacityLimit ?: properties.capacityLimit, + capacityAlertPercent = override?.capacityAlertPercent ?: properties.capacityAlertPercent, + ) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaUsageReader.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaUsageReader.kt new file mode 100644 index 00000000..9354069e --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaUsageReader.kt @@ -0,0 +1,49 @@ +package com.depromeet.piki.common.ratelimit + +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.util.UUID +import java.util.concurrent.TimeUnit + +// 지금 창의 사용량을 읽는다(#934 백오피스 현황). 판정 경로(RedisItemQuotaStore)와 나눠 둔 이유는 책임이 달라서다 — +// 저쪽은 판정과 차감을 원자로 묶는 쓰기 경로이고, 여기는 부작용 없는 읽기다. +// +// 상위 사용자 목록(랭킹)은 두지 않는다. Redis 는 "조건에 맞는 키 전부" 를 값싸게 못 주므로 SCAN 으로 활성 사용자 +// 키를 전수 훑어야 하는데, 같은 Redis 를 refresh 토큰 저장소가 함께 쓰고 있어 화면 한 번이 로그인 지연으로 번진다. +// 특정 계정 조회(키 하나)로 충분하다 — 누가 많이 쓰는지는 메트릭·로그가 답한다. +@Component +class ItemQuotaUsageReader( + private val redisTemplate: StringRedisTemplate, + private val settings: ItemQuotaSettings, +) { + fun capacity(): ItemQuotaUsage = read(RedisItemQuotaStore.CAPACITY_KEY, settings.current().capacityLimit) + + fun user(userId: UUID): ItemQuotaUsage = + read(RedisItemQuotaStore.USER_KEY_PREFIX + userId, settings.current().userLimit) + + // 값과 TTL 을 따로 읽으므로 그 사이 창이 만료될 수 있다. 그 경우 used 는 옛 값, resetInSeconds 는 null 이 되는데 + // 운영자가 보는 현황 화면이라 한 틱 어긋나는 것은 문제가 되지 않는다(판정은 Lua 가 원자로 한다). + private fun read( + key: String, + limit: Int, + ): ItemQuotaUsage { + val used = redisTemplate.opsForValue().get(key)?.toLongOrNull() ?: 0 + // -2(키 없음)·-1(TTL 없음) 은 둘 다 "남은 창을 말할 수 없음" 이라 null 로 접는다. + val ttlSeconds = redisTemplate.getExpire(key, TimeUnit.SECONDS)?.takeIf { it > 0 } + return ItemQuotaUsage(used = used, limit = limit, resetInSeconds = ttlSeconds) + } +} + +// resetInSeconds 가 null 이면 이번 창에 아직 아무도 쓰지 않은 것이다(카운터 키가 없어 TTL 도 없다). +data class ItemQuotaUsage( + val used: Long, + val limit: Int, + val resetInSeconds: Long?, +) { + // 음수를 0 으로 접지 않는다 — 잔액 방식이라 마지막 한 번이 한도를 넘길 수 있고(예: 잔액 1 에 이미지 5장), + // 운영자에게는 "얼마나 넘겼나" 가 곧 신호다. 0 으로 보이면 그 초과가 화면에서 사라진다. + val remaining: Long get() = limit - used + + // 화면 게이지용. 상한을 넘겨도 100 을 넘지 않게 잘라 막대가 넘치지 않게 한다. + val usedPercent: Int get() = ((used * 100) / limit).coerceIn(0, 100).toInt() +} diff --git a/src/main/resources/db/migration/V20260815062334__create_item_quota_settings.sql b/src/main/resources/db/migration/V20260815062334__create_item_quota_settings.sql new file mode 100644 index 00000000..7e6cc8ac --- /dev/null +++ b/src/main/resources/db/migration/V20260815062334__create_item_quota_settings.sql @@ -0,0 +1,29 @@ +-- 아이템 등록 한도의 백오피스 조절 (#934). 배포 없이 한도를 조이거나 푼다 +-- (extraction_models · extraction_platform_policies 와 같은 동적 설정 패턴). +-- +-- 이 테이블이 없을 때 한도는 application.yml 과 환경변수(ITEM_QUOTA_*)에만 있어, 비용이 튀어 급히 조여야 할 때도 +-- 한도가 낮아 정상 사용자가 막힐 때도 배포나 재시작을 기다려야 했다. +-- +-- **행이 최대 하나인 설정 테이블이다.** id 를 상수 1 로 못박아(CHECK) 두 행이 생기는 것을 스키마가 막는다. +-- 축(target·domain)별로 갈리는 extraction_models 와 달리 이 값들은 서비스 전체에 하나뿐이라 키가 필요 없다. +-- +-- **모든 값 컬럼이 nullable 이고 NULL 은 "이 노브는 env 기본값을 쓴다" 는 뜻이다.** 행 자체가 없으면 전부 기본값이다 +-- ("행 없음 = 기본" 규약 — extraction_models 의 model 과 같다). 부분 오버라이드를 허용해, 상한 하나만 급히 +-- 내리려고 나머지 값까지 화면에서 다시 적어 넣는 일이 없게 한다. +-- +-- window(창 길이)는 여기 두지 않는다. 바꾸면 이미 돌고 있는 카운터는 옛 TTL 로 만료되고 새 카운터만 새 창을 쓰는데, +-- 사용자마다 창 시작 시점이 달라 "지금 어떤 상태인가" 를 설명할 수 없다. 창 변경은 드문 일이라 배포로 남긴다. +CREATE TABLE item_quota_settings ( + id TINYINT NOT NULL DEFAULT 1, + -- 끄면 차감·판정을 통째로 건너뛴다. 한도가 잘못 잡혀 정상 사용자를 막을 때 되돌리는 스위치. + enabled BOOLEAN NULL, + -- 계정 하나의 창당 몫. 위시 등록·토너먼트 아이템 추가(게스트가 넣은 것 포함)가 전부 여기서 깎인다. + user_limit INT NULL, + -- 서비스 전체의 창당 상한. 넘으면 503 으로 흘려보낸다. + capacity_limit INT NULL, + -- 전역 상한의 몇 %에서 경고 로그를 남길지. 상한에 닿으면 이미 늦으므로 이 지점이 실질 방어선이다. + capacity_alert_percent INT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT ck_item_quota_settings_single_row CHECK (id = 1) +); diff --git a/src/main/resources/templates/admin/index.html b/src/main/resources/templates/admin/index.html index 19aa2aae..b01be45c 100644 --- a/src/main/resources/templates/admin/index.html +++ b/src/main/resources/templates/admin/index.html @@ -62,6 +62,10 @@

운영 백오피스

추출 모델 →
링크 · 이미지 추출에 쓰는 LLM 모델을 배포 없이 지정 (저장 시 실호출로 확인)
+ +
등록 한도 →
+
계정별 · 전역 사용량을 보고 한도를 배포 없이 조절 (비용이 튀면 조이고, 정상 사용자가 막히면 푼다)
+
diff --git a/src/main/resources/templates/admin/item-quota.html b/src/main/resources/templates/admin/item-quota.html new file mode 100644 index 00000000..ed77b6d3 --- /dev/null +++ b/src/main/resources/templates/admin/item-quota.html @@ -0,0 +1,205 @@ + + + + + + + 등록 한도 · PiKi 백오피스 + + + + +
+
+

등록 한도

+

아이템 등록에 걸리는 사용량 한도를 배포 없이 조절합니다.

+ +
+ 동작 방식 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
무엇을 세나요청 수가 아니라 새로 파싱하는 아이템 수입니다. 이미지 5장 등록은 5를 씁니다.
새로고침도 파싱이 다시 도니 1을 씁니다. 위시에 있는 아이템을 토너먼트로 담는 것은 세지 않습니다.
계정 한도계정 하나의 몫입니다. 위시 등록과 토너먼트 아이템 추가가 같은 몫을 씁니다.
토너먼트는 참여 게스트가 넣은 것도 오너의 몫에서 빠집니다.
전역 상한서비스 전체가 한 시간에 처리하겠다고 정한 총량입니다. 넘으면 503으로 흘려보냅니다.
정상 운영에서는 닿지 않아야 하는 선이라, 도달은 인기 신호가 아니라 이상 신호입니다.
경고선전역 상한의 몇 %에서 Discord 알림을 보낼지입니다.
상한에 닿으면 이미 사용자가 막히고 있어 늦으므로 이 지점이 실질 방어선입니다.
빈 칸비워 두면 그 항목은 서버 기본값으로 동작합니다. 값을 넣으면 그것이 기본값을 덮습니다.
반영 시점저장 즉시 이 서버에 적용되고, 다른 서버에는 최대 5분 안에 퍼집니다.
이미 쓴 사용량은 그대로 두고 한도만 새 값으로 판정합니다. 한도를 내리면 이미 넘긴 사용자는 바로 막힙니다.
적용 범위이 설정은 이 환경에만 적용됩니다. dev 에서 바꿔도 prod 는 그대로입니다.
+
+
+ +
저장했습니다. 이 서버에 즉시 적용됐고 다른 서버에는 최대 5분 안에 퍼집니다.
+
기본값으로 되돌렸습니다.
+
+ +
+

지금 전역 사용량

+

이번 창에 서비스 전체가 얼마나 썼는지입니다.

+
+ +
+
+ 사용 0 / 0 + + 남은 몫 + 0 + + + 리셋까지 0분 + + 이번 창에 아직 사용 없음 +
+
+ +
+

계정 사용량 조회

+

특정 계정이 이번 창에 얼마나 썼는지 봅니다. 상위 사용자 목록은 두지 않습니다 - 그러려면 활성 사용자 키를 전부 훑어야 해서 로그인이 함께 느려집니다.

+
+ + +
+
+
+
+ 사용 0 / 0 + 남은 몫 + 0 + + + 리셋까지 0분 + + 이번 창에 사용 없음 +
+
+
+ +
+

한도 조절

+

비워 두면 기본값으로 동작합니다. 네 항목을 한 번에 저장하므로 이 화면이 곧 최종 상태입니다.

+
+
+
+ + + + 지금 · 기본값 +
+
+ + + 지금 30 · 기본값 30 +
+
+ + + 지금 3000 · 기본값 3000 +
+
+ + + 지금 66% (1980건) · 기본값 66% +
+
+
+ +
+
+
+ +
+ +
+ + 전부 기본값으로 동작 중 +
+
+
+ + diff --git a/src/test/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaIntegrationTest.kt new file mode 100644 index 00000000..d34056d5 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/admin/quota/AdminItemQuotaIntegrationTest.kt @@ -0,0 +1,230 @@ +package com.depromeet.piki.admin.quota + +import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.common.ratelimit.DbItemQuotaSettings +import com.depromeet.piki.common.ratelimit.ItemQuotaSettingsJpaRepository +import com.depromeet.piki.common.ratelimit.RedisItemQuotaStore +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.user.domain.IdentityType +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +// 백오피스 한도 조절 contract (#934). 이 기능의 전부는 **"화면에서 바꾼 값이 배포 없이 실제 판정에 반영되는가"** 라, +// 저장이 DB 에 남는 것으로 끝내지 않고 그 뒤 등록 요청이 실제로 막히는지까지 본다. +// +// @Transactional 자동 롤백을 쓰지 않는다 — 저장이 afterCommit 에 캐시 reload 를 걸고(롤백되면 타지 않는다), +// 설정 캐시(@Volatile)는 롤백으로 되돌아가지 않아 다른 테스트로 누수된다. 각 테스트가 자기 뒷정리를 하고 reload 한다 +// (AdminExtractionModelIntegrationTest 와 같은 이유). 설정 행은 서비스에 하나뿐이라 UUID 로 격리할 수 없다. +class AdminItemQuotaIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var webApplicationContext: WebApplicationContext + + @Autowired + private lateinit var repository: ItemQuotaSettingsJpaRepository + + @Autowired + private lateinit var settings: DbItemQuotaSettings + + @Autowired + private lateinit var redisTemplate: StringRedisTemplate + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var jwtProvider: JwtProvider + + @Test + fun `계정 한도를 내리면 배포 없이 그 다음 등록부터 막힌다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + + try { + // 한도를 1 로 내린다. 화면 폼과 같은 경로(POST)로 저장해 afterCommit reload 까지 태운다. + saveQuota(mockMvc, userLimit = 1) + + // 첫 건은 통과해 몫을 채우고, + register(mockMvc, userId, "https://www.musinsa.com/products/101").andExpect(status().isCreated) + // 두 번째는 방금 내린 한도에 걸린다 — 재시작 없이 값이 먹었다는 뜻이다. + register(mockMvc, userId, "https://www.musinsa.com/products/102").andExpect(status().isTooManyRequests) + } finally { + cleanUp(userId) + } + } + + @Test + fun `전역 상한을 내리면 자기 몫이 남아 있어도 막힌다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + + try { + saveQuota(mockMvc, capacityLimit = 1) + // 전역 카운터를 상한까지 채운다. 이 사용자는 자기 몫을 한 건도 쓰지 않았다. + redisTemplate.opsForValue().set(RedisItemQuotaStore.CAPACITY_KEY, "1", java.time.Duration.ofMinutes(5)) + + register(mockMvc, userId, "https://www.musinsa.com/products/103").andExpect(status().isServiceUnavailable) + } finally { + cleanUp(userId) + } + } + + @Test + fun `끄면 한도가 통째로 걸리지 않는다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + + try { + // 한도를 1 로 내리고 동시에 끈다 — 스위치가 이겨야 한다. Boolean false 를 "값 없음" 으로 흘리면 + // 이 요청이 429 로 막히고, 정상 사용자를 막고 있는 상태를 되돌릴 수 없게 된다. + saveQuota(mockMvc, userLimit = 1, enabled = false) + + register(mockMvc, userId, "https://www.musinsa.com/products/104").andExpect(status().isCreated) + register(mockMvc, userId, "https://www.musinsa.com/products/105").andExpect(status().isCreated) + // 꺼져 있으면 차감 자체를 건너뛴다. + assertNull(redisTemplate.opsForValue().get(RedisItemQuotaStore.USER_KEY_PREFIX + userId)) + } finally { + cleanUp(userId) + } + } + + @Test + fun `되돌리기는 오버라이드 행을 지워 기본값으로 복귀시킨다`() { + val mockMvc = buildMockMvc() + val defaultUserLimit = settings.current().userLimit + + try { + saveQuota(mockMvc, userLimit = 1) + assertEquals(1, settings.current().userLimit) + + mockMvc + .perform(post("/admin/item-quota/reset").with(csrf())) + .andExpect(redirectedUrl("/admin/item-quota?reset")) + + assertEquals(defaultUserLimit, settings.current().userLimit) + assertNull(repository.findAll().firstOrNull()) + } finally { + cleanUp(null) + } + } + + @Test + fun `범위를 벗어난 값은 저장되지 않고 화면에 사유가 표시된다`() { + val mockMvc = buildMockMvc() + + try { + mockMvc + .perform(post("/admin/item-quota").with(csrf()).param("userLimit", "0")) + // 리다이렉트가 아니라 목록을 다시 그린다 — 제출값을 잃지 않기 위해서다. + .andExpect(status().isOk) + .andExpect(content().string(org.hamcrest.Matchers.containsString("계정 한도는 1 이상이어야 합니다"))) + + assertNull(repository.findAll().firstOrNull()) + } finally { + cleanUp(null) + } + } + + @Test + fun `계정 사용량 조회는 지금 창의 사용량을 보여준다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + redisTemplate + .opsForValue() + .set(RedisItemQuotaStore.USER_KEY_PREFIX + userId, "7", java.time.Duration.ofMinutes(5)) + + try { + mockMvc + .perform(get("/admin/item-quota/usage").param("userId", userId.toString())) + .andExpect(status().isOk) + .andExpect(content().string(org.hamcrest.Matchers.containsString(userId.toString()))) + .andExpect(content().string(org.hamcrest.Matchers.containsString(">7<"))) + } finally { + cleanUp(userId) + } + } + + @Test + fun `userId 형식이 잘못되면 사유를 화면에 표시한다`() { + val mockMvc = buildMockMvc() + + mockMvc + .perform(get("/admin/item-quota/usage").param("userId", "not-a-uuid")) + .andExpect(status().isOk) + .andExpect(content().string(org.hamcrest.Matchers.containsString("userId 형식이 올바르지 않습니다"))) + } + + private fun buildMockMvc(): MockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + + // 화면 폼과 같은 경로로 저장한다 — 서비스를 직접 부르면 컨트롤러 바인딩과 afterCommit reload 를 건너뛴다. + private fun saveQuota( + mockMvc: MockMvc, + userLimit: Int? = null, + capacityLimit: Int? = null, + enabled: Boolean? = null, + ) { + val request = post("/admin/item-quota").with(csrf()) + userLimit?.let { request.param("userLimit", it.toString()) } + capacityLimit?.let { request.param("capacityLimit", it.toString()) } + enabled?.let { request.param("enabled", it.toString()) } + mockMvc.perform(request).andExpect(redirectedUrl("/admin/item-quota?updated")) + } + + private fun register( + mockMvc: MockMvc, + userId: UUID, + url: String, + ) = mockMvc.perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"$url"}"""), + ) + + private fun insertMember(userId: UUID) { + jdbcTemplate.update( + "INSERT INTO users (id, nickname, identity_type, created_at, updated_at) VALUES (?, ?, ?, NOW(6), NOW(6))", + uuidToBytes(userId), + userId.toString().take(10), + IdentityType.MEMBER.name, + ) + } + + // 설정 행·Redis 카운터·유저 행은 롤백 대상이 아니거나(@Transactional 미사용) Redis 라, 직접 되돌린다. + // 캐시도 함께 reload 해 다음 테스트가 방금 내린 한도를 물려받지 않게 한다. + private fun cleanUp(userId: UUID?) { + repository.deleteAll() + settings.reload() + redisTemplate.delete(RedisItemQuotaStore.CAPACITY_KEY) + userId?.let { + redisTemplate.delete(RedisItemQuotaStore.USER_KEY_PREFIX + it) + jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(it)) + jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(it)) + } + } +} 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 c17b6058..8458c6fb 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -69,7 +69,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { private lateinit var redisTemplate: StringRedisTemplate @Autowired - private lateinit var properties: ItemQuotaProperties + private lateinit var settings: ItemQuotaSettings @Autowired private lateinit var objectMapper: ObjectMapper @@ -98,7 +98,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertUser(userId, IdentityType.MEMBER) // 한도를 정확히 소진한 상태 — 다음 1건이 넘긴다. - fillQuota(userId, properties.userLimit) + fillQuota(userId, settings.current().userLimit) mockMvc .perform( @@ -114,7 +114,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) // 거부된 요청은 카운터를 올리지 않는다 — 올리면 재시도할수록 창이 끝나도 한도를 넘긴 채 시작한다. - assertEquals(properties.userLimit.toLong(), currentCount(userId)) + assertEquals(settings.current().userLimit.toLong(), currentCount(userId)) } @Test @@ -127,8 +127,8 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .opsForValue() .set( RedisItemQuotaStore.CAPACITY_KEY, - (properties.capacityAlertThreshold - 1).toString(), - properties.window, + (settings.current().capacityAlertThreshold - 1).toString(), + settings.current().window, ) // Loki 가 실제로 보는 것은 렌더된 메시지 한 줄이므로, 그 줄을 그대로 받아 형식을 검사한다. val logger = LoggerFactory.getLogger(ItemQuotaGuard::class.java) as Logger @@ -262,7 +262,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertUser(userId, IdentityType.MEMBER) // 잔액을 1 만 남긴다 — 5장 요청은 그보다 크다. - fillQuota(userId, properties.userLimit - 1) + fillQuota(userId, settings.current().userLimit - 1) // 요청량은 판정에 쓰지 않으므로 통째로 통과한다. "2장만 남아서 안 됩니다" 로 막으면 사용자는 자기 잔액을 // 모르는 채 몇 장으로 줄여야 할지도 알 수 없다 — 마지막 한 번은 성공시키고 그 다음부터 막는다. @@ -275,7 +275,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) // 한도를 넘겨 잔액이 음수가 됐다. - assertEquals((properties.userLimit + 4).toLong(), currentCount(userId)) + assertEquals((settings.current().userLimit + 4).toLong(), currentCount(userId)) // 이제부터는 크기와 무관하게 거부다. mockMvc @@ -321,7 +321,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() insertUser(ownerId, IdentityType.MEMBER) - fillQuota(ownerId, properties.userLimit) + fillQuota(ownerId, settings.current().userLimit) stubItemParsingWorker.enabled = false try { @@ -408,7 +408,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { try { val (tournamentId, inviteCode) = createTournament(mockMvc, ownerId) val guestId = joinAsGuest(mockMvc, tournamentId, inviteCode) - fillQuota(ownerId, properties.userLimit) + fillQuota(ownerId, settings.current().userLimit) mockMvc .perform( @@ -464,7 +464,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { ) { redisTemplate .opsForValue() - .set(RedisItemQuotaStore.USER_KEY_PREFIX + userId, amount.toString(), properties.window) + .set(RedisItemQuotaStore.USER_KEY_PREFIX + userId, amount.toString(), settings.current().window) } private fun currentCount(userId: UUID): Long? = @@ -491,9 +491,10 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { // 전역 카운터를 상한까지 채워 "서비스가 꽉 찬" 상태를 만든다. 부르는 테스트가 끝에서 반드시 키를 지운다. private fun fillCapacity() { + val quota = settings.current() redisTemplate .opsForValue() - .set(RedisItemQuotaStore.CAPACITY_KEY, properties.capacityLimit.toString(), properties.window) + .set(RedisItemQuotaStore.CAPACITY_KEY, quota.capacityLimit.toString(), quota.window) } private fun createTournament( 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 585e89b6..f15231bf 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt @@ -4,8 +4,6 @@ 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 @@ -51,38 +49,4 @@ class ItemQuotaPropertiesTest { 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 `경고선을 넘긴 첫 차감에서만 참이 된다`() { - 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/ItemQuotaSnapshotTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshotTest.kt new file mode 100644 index 00000000..3efc32a6 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSnapshotTest.kt @@ -0,0 +1,107 @@ +package com.depromeet.piki.common.ratelimit + +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 ItemQuotaSnapshotTest { + @Test + fun `오버라이드가 없으면 env 기본값이 그대로 실효값이 된다`() { + val properties = ItemQuotaProperties(userLimit = 30, capacityLimit = 3_000, capacityAlertPercent = 66) + + val snapshot = ItemQuotaSnapshot.of(properties) + + assertEquals(30, snapshot.userLimit) + assertEquals(3_000, snapshot.capacityLimit) + assertEquals(66, snapshot.capacityAlertPercent) + } + + @Test + fun `오버라이드에 값이 있는 노브만 덮고 나머지는 기본값이 남는다`() { + // 부분 오버라이드가 이 설계의 핵심이다 — 상한 하나만 급히 내릴 때 나머지를 화면에서 다시 적지 않아도 된다. + val properties = ItemQuotaProperties(userLimit = 30, capacityLimit = 3_000, capacityAlertPercent = 66) + val override = ItemQuotaSettingsEntity(capacityLimit = 500) + + val snapshot = ItemQuotaSnapshot.of(properties, override) + + assertEquals(500, snapshot.capacityLimit) + assertEquals(30, snapshot.userLimit) + assertEquals(66, snapshot.capacityAlertPercent) + } + + @Test + fun `사용 여부는 false 로도 덮인다`() { + // Boolean 오버라이드를 Elvis 로 풀 때 false 를 "값 없음" 으로 흘려보내는 실수가 흔하다. + // 그러면 "한도를 끈다" 는 조작이 조용히 무시돼, 정상 사용자를 막고 있는 상태를 되돌릴 수 없다. + val properties = ItemQuotaProperties(enabled = true) + + val snapshot = ItemQuotaSnapshot.of(properties, ItemQuotaSettingsEntity(enabled = false)) + + assertFalse(snapshot.enabled) + } + + @Test + fun `창 길이는 오버라이드 대상이 아니라 항상 env 값을 쓴다`() { + val properties = ItemQuotaProperties(window = Duration.ofMinutes(30)) + + val snapshot = ItemQuotaSnapshot.of(properties, ItemQuotaSettingsEntity(userLimit = 5)) + + assertEquals(Duration.ofMinutes(30), snapshot.window) + } + + @Test + fun `경고선은 상한의 비율만큼으로 계산된다`() { + // 운영 기본값과 같은 조합 — 3000 의 66% 는 1980 이다. + val snapshot = snapshotOf(capacityLimit = 3_000, capacityAlertPercent = 66) + + assertEquals(1_980, snapshot.capacityAlertThreshold) + } + + @Test + fun `경고선 계산은 내림한다`() { + // 정수 나눗셈이라 10 * 66 / 100 = 6.6 → 6. 경고가 한 건 앞당겨질 뿐이라 무해하다. + assertEquals(6, snapshotOf(capacityLimit = 10, capacityAlertPercent = 66).capacityAlertThreshold) + } + + @Test + fun `경고선을 넘긴 첫 차감에서만 참이 된다`() { + val snapshot = snapshotOf(capacityLimit = 3_000, capacityAlertPercent = 66) + + // 1979 까지는 아직 아래. 1980 을 만든 이 한 건이 경계를 넘긴 건이다. + assertFalse(snapshot.crossedCapacityAlert(capacityUsed = 1_979, amount = 1)) + assertTrue(snapshot.crossedCapacityAlert(capacityUsed = 1_980, amount = 1)) + // 이미 넘긴 뒤의 차감은 거짓 — 참으로 두면 창이 끝날 때까지 매 요청이 같은 경고를 반복해 알림이 무뎌진다. + assertFalse(snapshot.crossedCapacityAlert(capacityUsed = 1_981, amount = 1)) + } + + @Test + fun `한 번에 경고선을 건너뛰어도 그 차감에서 참이 된다`() { + val snapshot = snapshotOf(capacityLimit = 3_000, capacityAlertPercent = 66) + + // 이미지 5장 등록처럼 한 요청이 여러 건을 소모하면 경고선을 정확히 밟지 않고 넘어간다(1978 → 1983). + // "누적 == 경고선" 으로 판정했다면 이 경우를 통째로 놓쳐 경고가 영영 안 울린다. + assertTrue(snapshot.crossedCapacityAlert(capacityUsed = 1_983, amount = 5)) + } + + @Test + fun `범위를 벗어난 값으로는 만들 수 없다`() { + // 불변식 층이다 — env 는 부팅에서, 백오피스 입력은 admin 경계에서 먼저 걸러진다. + // 여기 닿았다면 어느 경계가 검증을 빠뜨린 것이라 코드 버그다. + assertFailsWith { snapshotOf(userLimit = 0) } + assertFailsWith { snapshotOf(capacityLimit = 0) } + assertFailsWith { snapshotOf(capacityAlertPercent = 0) } + assertFailsWith { snapshotOf(capacityAlertPercent = 101) } + assertFailsWith { snapshotOf(window = Duration.ZERO) } + } + + private fun snapshotOf( + enabled: Boolean = true, + window: Duration = Duration.ofHours(1), + userLimit: Int = 30, + capacityLimit: Int = 3_000, + capacityAlertPercent: Int = 66, + ) = ItemQuotaSnapshot(enabled, window, userLimit, capacityLimit, capacityAlertPercent) +}