From 573216c1061a529a625024fc63cf228bfd965444 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: Thu, 13 Aug 2026 22:40:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A0=84=EC=97=AD=20=EA=B0=80=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EA=B2=BD=EA=B3=A0=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A3=B0=EC=9D=B4=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=98=95=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #927 로 넣은 경고 로그가 한국어 산문("아이템 등록 전역 가용량 경고선 도달 — used=...")이라, Loki 알림 룰이 그 문구 자체를 검색어로 삼아야 했다. 문구를 다듬는 순간 매칭이 깨지는데 알림이 안 울리는 것은 정상 상태와 구분되지 않아 아무도 알아채지 못한다. 죽은 알림을 안고 가는 셈이라 룰을 걸기 전에 형식부터 고쳤다 - 이 repo 에 이미 돌고 있는 파싱 실패 알림의 규약을 따른다: 고정 이벤트 키(`item.parse.result`)로 시작하고 나머지는 logfmt(`키=값`). Grafana 룰이 `|= "<키>" | logfmt | __error__=""` 로 집는 구조다. 키를 확인하지 않고 사람이 읽기 좋은 문구로 쓴 것이 원래 실수였다 - 값이 logfmt 여야 라벨로 추출돼 Discord 문구에 실린다. 지금 형태로는 "뭔가 울렸다" 까지만 알 수 있고, 바꾼 뒤에는 사용량·경고선·상한이 알림 본문에 들어간다 - window 는 Duration.toString(PT1H)이 아니라 초로 남긴다. logfmt 값이 숫자여야 알림에서 비교·표시가 된다 - 이벤트 키를 상수(`ItemQuotaGuard.CAPACITY_ALERT_EVENT`)로 빼 테스트가 같은 값을 참조하게 했다. 이 문자열은 Grafana 룰과의 계약이라 바꾸면 룰도 함께 바꿔야 한다 - 렌더된 로그 한 줄을 받아 형식을 정규식으로 고정하는 통합 테스트를 더했다. Loki 가 보는 것이 렌더 결과이므로 템플릿이 아니라 결과를 검사한다. 산문 복귀·logfmt 이탈·Duration 표기 회귀가 전부 여기서 걸린다 - 검증: window 를 Duration 표기로 되돌려 보니 그 테스트만 실패하고 메시지에 `window=PT1H` 가 그대로 찍혔다 --- .../piki/common/ratelimit/ItemQuotaGuard.kt | 21 ++++++- .../ratelimit/ItemQuotaIntegrationTest.kt | 61 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) 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 5c5d2246..2f49c372 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt @@ -65,7 +65,7 @@ class ItemQuotaGuard( } // 전역 가용량이 경고선을 넘긴 순간 한 줄 남긴다. 상한에 닿으면 이미 사용자가 막히고 있어 늦으므로, - // 이 로그가 실질 방어선이다 — 알림 룰이 이 문구를 집어 Discord 로 보낸다. + // 이 로그가 실질 방어선이다 — Loki 알림 룰이 이 줄을 집어 Discord 로 보낸다. // // 대응은 "상한을 올린다" 가 기본이 아니다. 정상 성장인지, 특정 사용자·IP 의 이상 패턴인지, 파싱 실패로 인한 // 재시도 폭증인지를 먼저 가르고, 정상 성장으로 확인된 뒤에만 올린다. @@ -74,12 +74,27 @@ class ItemQuotaGuard( amount: Int, ) { if (!properties.crossedCapacityAlert(capacityUsed, amount)) return + // 알림이 매칭하는 줄이라 **사람이 읽는 문구가 아니라 기계가 읽는 형식**으로 쓴다(item.parse.result 와 같은 규약): + // 고정 이벤트 키 + logfmt(`키=값`). 사람이 읽을 설명은 알림 룰의 summary 가 한국어로 담는다. + // + // 한국어 산문으로 쓰면 알림이 그 문구를 검색어로 삼게 되는데, 문구를 다듬는 순간 매칭이 깨지고 + // **알림이 조용히 죽는다** — "안 울림" 은 정상 상태와 구분되지 않아 아무도 알아채지 못한다. + // 값도 logfmt 여야 라벨로 추출돼 Discord 문구에 실린다("뭔가 울렸다" 가 아니라 "사용량 1980 / 한도 3000"). + // + // window 는 Duration.toString(PT1H)이 아니라 초로 남긴다 — logfmt 값은 숫자여야 알림에서 비교·표시가 쉽다. log.warn( - "아이템 등록 전역 가용량 경고선 도달 — used={} threshold={} limit={} window={}", + "{} used={} threshold={} limit={} windowSeconds={}", + CAPACITY_ALERT_EVENT, capacityUsed, properties.capacityAlertThreshold, properties.capacityLimit, - properties.window, + properties.window.seconds, ) } + + companion object { + // Loki 알림 룰의 매칭 앵커. **이 문자열이 곧 알림 계약이다** — 바꾸면 룰도 함께 바꿔야 하고, + // 안 바꾸면 알림이 조용히 죽는다. 상수로 빼 테스트가 같은 값을 참조하게 해 오타·표류를 막는다. + const val CAPACITY_ALERT_EVENT = "item.quota.capacity.alert" + } } 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 96b14573..c17b6058 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -1,5 +1,9 @@ package com.depromeet.piki.common.ratelimit +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.item.domain.Item @@ -18,6 +22,7 @@ import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.domain.WishErrorCode import com.depromeet.piki.wishlist.repository.WishJpaRepository import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.redis.core.StringRedisTemplate import org.springframework.http.HttpHeaders @@ -39,6 +44,7 @@ import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue // 아이템 등록 한도(#339)의 계약 검증. 한도 자체의 산술(창 경계·잔액 판정)은 RedisItemQuotaStore 쪽 검증이 // 맡고, 여기서는 "진입점에서 무엇이 얼마나 차감되고 넘치면 어떤 응답이 나가는가" 라는 계약만 본다. @@ -111,6 +117,54 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { assertEquals(properties.userLimit.toLong(), currentCount(userId)) } + @Test + fun `전역 가용량이 경고선을 넘으면 알림 룰이 매칭하는 형식으로 경고 로그를 남긴다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + // 경고선 직전까지 채운다 — 이 등록 1건이 경계를 넘긴다. + redisTemplate + .opsForValue() + .set( + RedisItemQuotaStore.CAPACITY_KEY, + (properties.capacityAlertThreshold - 1).toString(), + properties.window, + ) + // Loki 가 실제로 보는 것은 렌더된 메시지 한 줄이므로, 그 줄을 그대로 받아 형식을 검사한다. + val logger = LoggerFactory.getLogger(ItemQuotaGuard::class.java) as Logger + val appender = ListAppender().apply { start() } + logger.addAppender(appender) + + try { + mockMvc + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/13"}"""), + ).andExpect(status().isCreated) + + val warned = + appender.list + .filter { it.level == Level.WARN } + .map { it.formattedMessage } + .filter { it.startsWith(ItemQuotaGuard.CAPACITY_ALERT_EVENT) } + assertEquals(1, warned.size, "경고선을 넘긴 요청은 경고를 정확히 한 줄 남겨야 한다: ${appender.list.map { it.formattedMessage }}") + + // 이 형식이 곧 알림 계약이다. 한국어 산문으로 되돌아가거나 필드가 logfmt(`키=값`)를 벗어나면 + // Loki 룰이 매칭에 실패해 **알림이 조용히 죽는다** — 안 울리는 것은 정상 상태와 구분되지 않는다. + // windowSeconds 가 숫자가 아니게 되는 회귀(Duration.toString 의 PT1H)도 여기서 걸린다. + assertTrue( + ALERT_LINE_FORMAT.matches(warned.single()), + "경고 로그가 알림 룰이 매칭하는 형식이 아니다: ${warned.single()}", + ) + } finally { + logger.detachAppender(appender) + // 전역 카운터는 서비스에 하나뿐이라 UUID 로 격리할 수 없다(아래 503 테스트와 같은 이유). + redisTemplate.delete(RedisItemQuotaStore.CAPACITY_KEY) + } + } + @Test fun `전역 가용량이 소진되면 자기 몫이 남아 있어도 503 과 SERVER-BUSY code, Retry-After 헤더를 반환한다`() { val mockMvc = buildMockMvc() @@ -486,4 +540,11 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .asString() return UUID.fromString(userId) } + + companion object { + // Loki 룰이 `|= "item.quota.capacity.alert" | logfmt` 로 집는 형식. 고정 이벤트 키로 시작하고 + // 나머지가 전부 `키=숫자` 여야 필드가 라벨로 추출돼 Discord 문구에 실린다. + private val ALERT_LINE_FORMAT = + Regex("""^item\.quota\.capacity\.alert used=\d+ threshold=\d+ limit=\d+ windowSeconds=\d+$""") + } }