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