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 운영 백오피스
아이템 등록에 걸리는 사용량 한도를 배포 없이 조절합니다.
+ +| 무엇을 세나 | +요청 수가 아니라 새로 파싱하는 아이템 수입니다. 이미지 5장 등록은 5를 씁니다. 새로고침도 파싱이 다시 도니 1을 씁니다. 위시에 있는 아이템을 토너먼트로 담는 것은 세지 않습니다. |
+
|---|---|
| 계정 한도 | +계정 하나의 몫입니다. 위시 등록과 토너먼트 아이템 추가가 같은 몫을 씁니다. 토너먼트는 참여 게스트가 넣은 것도 오너의 몫에서 빠집니다. |
+
| 전역 상한 | +서비스 전체가 한 시간에 처리하겠다고 정한 총량입니다. 넘으면 503으로 흘려보냅니다. 정상 운영에서는 닿지 않아야 하는 선이라, 도달은 인기 신호가 아니라 이상 신호입니다. |
+
| 경고선 | +전역 상한의 몇 %에서 Discord 알림을 보낼지입니다. 상한에 닿으면 이미 사용자가 막히고 있어 늦으므로 이 지점이 실질 방어선입니다. |
+
| 빈 칸 | +비워 두면 그 항목은 서버 기본값으로 동작합니다. 값을 넣으면 그것이 기본값을 덮습니다. | +
| 반영 시점 | +저장 즉시 이 서버에 적용되고, 다른 서버에는 최대 5분 안에 퍼집니다. 이미 쓴 사용량은 그대로 두고 한도만 새 값으로 판정합니다. 한도를 내리면 이미 넘긴 사용자는 바로 막힙니다. |
+
| 적용 범위 | +이 설정은 이 환경에만 적용됩니다. dev 에서 바꿔도 prod 는 그대로입니다. | +
이번 창에 서비스 전체가 얼마나 썼는지입니다.
+특정 계정이 이번 창에 얼마나 썼는지 봅니다. 상위 사용자 목록은 두지 않습니다 - 그러려면 활성 사용자 키를 전부 훑어야 해서 로그인이 함께 느려집니다.
+ +비워 두면 기본값으로 동작합니다. 네 항목을 한 번에 저장하므로 이 화면이 곧 최종 상태입니다.
+ +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 @@ + + +
+ + + +
+ + + +
+