From f87ebe8e9259cbc96ed854f1514839c751b7f063 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: Sun, 9 Aug 2026 00:23:01 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=EC=9D=84=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게스트 계정이 POST /auth/guest 로 입력값 없이 무한 발급되는 것을 dev 에서 실측 확인(3연속 호출에 서로 다른 계정 3개). userId 를 키로 쓰는 어떤 사용량 제한도 계정을 갈아타면 리셋되므로, 비용이 드는 행위의 소유자를 회원으로 못박아 소셜 계정 생성 비용이 sybil 방어를 하게 한다 - 위시리스트는 이미 requireMember 로 회원 전용인데 토너먼트만 그 원칙에서 빠져 있었다. 새 원칙이 아니라 기존 원칙의 일관 적용이다 - 게스트에게 열어 두는 것: 참여·아이템 추가·플레이. 플레이(recordMatch)는 외부 호출이 없어 비용이 0 이고, 초대받아 바로 참여하는 흐름이 서비스의 핵심이다 - createFromPlayLink 는 게이트를 두지 않는다. 거기서 만들어지는 CLONE 은 아이템 추가가 막혀 있어(clonedTournamentCannotAddItems) 추출 비용을 만들 수 없다 - requireMember 는 findActiveById 가 아니라 findById + Elvis 로 둔다. 처음 findActiveById 로 두었더니 users 행 없이 인증만으로 호출되던 기존 계약이 404 로 바뀌어 토너먼트 테스트 151개가 깨졌다. 게스트는 발급이 곧 users 행 생성이라 행 부재를 통과시켜도 게이트에 구멍이 나지 않는다(FCM 토큰 등록의 rejectIfWithdrawnForUpdate 와 같은 결) - 동시성·presign 테스트 5종이 owner 를 GUEST 로 만들어 토너먼트를 생성하고 있었다. 각 테스트의 관심사는 경합·발급 흐름이지 게스트 권한이 아니므로 owner 만 MEMBER 로 정정했고, play-link 테스트의 cloner 는 GUEST 로 남겨 게스트 클론 경로가 열려 있음을 함께 검증한다 --- .../tournament/controller/TournamentApi.kt | 10 ++++++++ .../controller/TournamentApiExamples.kt | 1 + .../tournament/service/TournamentErrorCode.kt | 4 ++++ .../tournament/service/TournamentException.kt | 5 ++++ .../tournament/service/TournamentService.kt | 19 +++++++++++++++ ...tFromPlayLinkConcurrencyIntegrationTest.kt | 7 ++++-- .../controller/TournamentIntegrationTest.kt | 24 +++++++++++++++++++ ...tItemImageAddConcurrencyIntegrationTest.kt | 6 +++-- ...namentItemImagePresignedIntegrationTest.kt | 24 ++++++++++--------- ...urnamentStartConcurrencyIntegrationTest.kt | 5 ++-- ...namentWishAddConcurrencyIntegrationTest.kt | 5 ++-- 11 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index c3bbbc33..cbdc7894 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -363,6 +363,16 @@ interface TournamentApi { ), ], ), + ApiResponse( + responseCode = "403", + description = "권한 없음 (GUEST 권한으로 접근 불가 · MEMBER 필요 — code: TOURNAMENT-036)", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun create( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index 5cf73105..ae339b24 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -103,6 +103,7 @@ class TournamentApiExamples( ), ) unauthorized() + add(TournamentException.guestCannotCreateTournament(), name = "게스트의 토너먼트 생성 거부 (회원 전용)") } handlerMethod.binds(TournamentController::join) -> diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index 0e2ea321..00b57127 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -53,4 +53,8 @@ enum class TournamentErrorCode( // 034~035 는 매치 로직 백엔드 이관(#683)에서 추가됐다. 브래킷 무결성 검증이 처음 들어오며 생긴 두 사유다. INVALID_MATCH_PAIR("TOURNAMENT-034", ErrorCategory.INVALID_INPUT, "지금 대결할 수 있는 조합이 아니에요."), MATCH_ALREADY_RECORDED("TOURNAMENT-035", ErrorCategory.CONFLICT, "이미 결과가 기록된 대결이에요."), + + // 036 은 게스트 권한 정리(#339)에서 추가됐다. 토너먼트 생성은 회원 전용이 되고, 생성된 토너먼트에 + // 아이템을 담는 것과 플레이는 게스트에게 그대로 열려 있다 — "게스트는 소비만, 생산은 회원만". + GUEST_CANNOT_CREATE_TOURNAMENT("TOURNAMENT-036", ErrorCategory.FORBIDDEN, "토너먼트 만들기는 회원만 이용할 수 있어요."), } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt index e00a9daa..b088e9da 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt @@ -98,5 +98,10 @@ class TournamentException private constructor( // 이미 기록된 매치에 다른 승자를 보낸 경우(#683). 같은 승자면 멱등 성공이고, 다른 승자는 결과 뒤집기라 → 409. fun matchAlreadyRecorded(): TournamentException = TournamentException(TournamentErrorCode.MATCH_ALREADY_RECORDED) + + // 토너먼트 만들기는 회원 전용(#339) — 게스트(인증은 됐으나 회원 아님)가 정상 요청으로 닿을 수 있는 계약 응답이라 커스텀 예외(403). + // Security 에서 MEMBER 만 허용하면 detail 없는 권한 없음 403 으로 떨어져 "회원 전용" 사유를 못 전달하므로, + // authenticated() 로 통과시킨 뒤 서비스가 이 예외로 막는다(WishException.guestCannotUseWishlist 와 같은 패턴). + fun guestCannotCreateTournament(): TournamentException = TournamentException(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT) } } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 9e2fadcd..85a8b6e9 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -38,6 +38,7 @@ import com.depromeet.piki.tournament.service.dto.TournamentItemDetail import com.depromeet.piki.tournament.service.dto.StartResult import com.depromeet.piki.tournament.service.dto.TournamentStartResult import com.depromeet.piki.tournament.service.dto.TournamentSummary +import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.wishlist.repository.WishRepository import org.springframework.context.ApplicationEventPublisher @@ -58,11 +59,26 @@ class TournamentService( private val wishRepository: WishRepository, private val eventPublisher: ApplicationEventPublisher, ) { + // 토너먼트 만들기는 회원 전용(#339). 게스트는 이미 만들어진 토너먼트에 참여·아이템 추가·플레이만 한다. + // 게스트 계정은 입력 없이 무한 발급되므로(POST /auth/guest), 비용이 드는 행위의 소유자는 항상 회원이어야 + // 계정을 갈아타며 한도를 리셋하는 우회가 성립하지 않는다 — 소셜 계정 생성 비용이 그 우회를 막는다. + // 인증 principal 은 userId 뿐이라 identityType 은 조회로 확인한다. + // + // users 행 존재는 강제하지 않는다(findActiveById 가 아니라 findById + Elvis) — 인증만 되면 행 없이도 호출되던 + // 기존 계약을 이 게이트가 404 로 바꾸지 않기 위해서다(FCM 토큰 등록의 rejectIfWithdrawnForUpdate 와 같은 결). + // 게이트에 구멍을 내지 않는다: 게스트는 발급이 곧 users 행 생성이라(UserService.createGuest) 반드시 행이 있고, + // 토큰은 우리가 서명하므로 "행 없는 유효 토큰" 은 정상 경로에서 만들어지지 않는다. + private fun requireMember(userId: UUID) { + val user = userRepository.findById(userId) ?: return + if (user.identityType != IdentityType.MEMBER) throw TournamentException.guestCannotCreateTournament() + } + @Transactional fun create( userId: UUID, command: CreateTournament, ): CreateTournamentResult { + requireMember(userId) val inviteCode = generateUniqueInviteCode() val inviteExpiresAt = LocalDateTime .now() @@ -906,6 +922,9 @@ class TournamentService( ) } + // create 와 달리 회원 게이트를 두지 않는다(#339) — 여기서 만들어지는 것은 CLONE 이고, CLONE 은 + // 아이템 추가가 막혀 있어(clonedTournamentCannotAddItems) 추출·LLM 비용을 만들 수 없다. 플레이 링크로 + // 들어와 바로 플레이하는 것은 게스트의 핵심 시나리오라, 비용이 0 인 이 경로까지 회원 전용으로 만들지 않는다. @Transactional fun createFromPlayLink( userId: UUID, diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt index 7556b503..890132dc 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt @@ -53,7 +53,10 @@ class TournamentFromPlayLinkConcurrencyIntegrationTest : IntegrationTestSupport( fun `같은 유저가 from-play-link 를 동시에 두 번 요청해도 클론은 하나만 생성되고 둘 다 200 으로 같은 id 를 받는다`() { val ownerId = UUID.randomUUID() val clonerId = UUID.randomUUID() - userJpaRepository.save(User(id = ownerId, nickname = "race-owner", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.GUEST)) + // owner 는 소스 토너먼트를 만들어야 해서 MEMBER 다(토너먼트 생성은 회원 전용, #339). + // cloner 는 GUEST 로 둔다 — 플레이 링크로 클론을 만드는 경로는 아이템 추가가 막힌 CLONE 이라 비용이 0 이고, + // 게스트에게 그대로 열려 있다. 이 조합이 곧 "게스트도 플레이 링크로 참여할 수 있다" 는 계약 검증이 된다. + userJpaRepository.save(User(id = ownerId, nickname = "race-owner", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER)) userJpaRepository.save(User(id = clonerId, nickname = "race-clone", profileImage = "https://cdn.example.com/c.jpg", identityType = IdentityType.GUEST)) val mockMvc = MockMvcBuilders @@ -61,7 +64,7 @@ class TournamentFromPlayLinkConcurrencyIntegrationTest : IntegrationTestSupport( .apply(springSecurity()) .build() - val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.GUEST)}" + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" val clonerAuth = "Bearer ${jwtProvider.generateAccessToken(clonerId, IdentityType.GUEST)}" // 소스 토너먼트 준비 (단일 스레드, 직렬) diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 21fe336a..7ed59607 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -127,6 +127,30 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.inviteExpiresAt").isString) } + @Test + fun `POST tournaments 는 게스트가 요청하면 403 과 GUEST_CANNOT_CREATE_TOURNAMENT code 를 반환한다`() { + val mockMvc = buildMockMvc() + val guestId = UUID.randomUUID() + userJpaRepository.save( + User(id = guestId, nickname = "게스트유저", profileImage = "https://cdn.example.com/g.jpg", identityType = IdentityType.GUEST), + ) + val tournamentsBefore = tournamentJpaRepository.count() + + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${jwtProvider.generateAccessToken(guestId, IdentityType.GUEST)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"게스트 토너먼트"}"""), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT.code)) + .andExpect(jsonPath("$.detail").value(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT.message)) + .andExpect(jsonPath("$.data").value(nullValue())) + + // 거부가 응답으로만 끝나지 않고 실제로 아무것도 만들지 않았는지 확인한다 — 게이트가 saveTournament 앞에 선다. + assertEquals(tournamentsBefore, tournamentJpaRepository.count()) + } + @Test fun `POST tournaments 에서 inviteDurationMinutes 를 지정하면 해당 시간으로 만료 시각이 설정된다`() { val mockMvc = buildMockMvc() diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt index 60fc7e81..a5330d17 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt @@ -70,7 +70,9 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( val ownerId = UUID.randomUUID() userJpaRepository.save( - User(id = ownerId, nickname = "race-image", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.GUEST), + // 토너먼트 생성은 회원 전용(#339)이라 owner 는 MEMBER 다. 이 테스트의 관심사는 동시 추가 경합이지 + // 게스트 권한이 아니므로, 계약을 맞추기만 하고 나머지 시나리오는 그대로 둔다. + User(id = ownerId, nickname = "race-image", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) val mockMvc = MockMvcBuilders @@ -78,7 +80,7 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( .apply(springSecurity()) .build() - val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.GUEST)}" + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" // 이 테스트가 새로 만드는 item/snapshot 의 하한 — finally 에서 이보다 큰 id 만 지워 추가분(사전 27 + 성공 5)까지 정리한다. val maxItemIdBefore = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) FROM items", Long::class.java) ?: 0L diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImagePresignedIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImagePresignedIntegrationTest.kt index 49459fa3..185a1f2c 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImagePresignedIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImagePresignedIntegrationTest.kt @@ -51,7 +51,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `presigned 발급하면 요청한 개수만큼 uploadUrl 을 받는다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { tournamentId = createTournament(mockMvc, ownerId) @@ -75,9 +75,9 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `참여자가 아니면 발급이 403 으로 거부된다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) val outsiderId = UUID.randomUUID() - insertGuest(outsiderId) + insertMember(outsiderId) var tournamentId = 0L try { tournamentId = createTournament(mockMvc, ownerId) @@ -100,7 +100,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `발급받은 key 로 confirm 하면 200 으로 아이템이 추가된다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { // 자동 dispatch(1s)가 PENDING 을 집어 워커를 돌리므로 깨끗한 추출 결과를 세팅해 둔다. @@ -135,7 +135,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `업로드하지 않은 key 로 confirm 하면 400 으로 거부되고 아이템이 생기지 않는다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { // S3 에 실제로 올라오지 않은 상황 재현 — HEAD 존재확인이 false 를 돌려준다. @@ -171,7 +171,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `지원하지 않는 content-type 으로 발급하면 400 으로 거부된다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { tournamentId = createTournament(mockMvc, ownerId) @@ -194,7 +194,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `content-type 이 6개면 개수 위반으로 400 이 반환된다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { tournamentId = createTournament(mockMvc, ownerId) @@ -216,7 +216,7 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { fun `발급 형식이 아닌 key 로 confirm 하면 400 으로 거부된다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { tournamentId = createTournament(mockMvc, ownerId) @@ -287,16 +287,18 @@ class TournamentItemImagePresignedIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - private fun insertGuest(userId: UUID) { + // 토너먼트 생성이 회원 전용(#339)이 되면서 MEMBER 로 바뀌었다. 이 테스트의 관심사는 presign·confirm 흐름이지 + // 게스트 권한이 아니라, 참여자 검증(403)을 확인하는 outsider 까지 MEMBER 로 둬도 시나리오가 그대로 성립한다. + 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), - "GUEST", + "MEMBER", ) } - private fun token(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.GUEST) + private fun token(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.MEMBER) private fun cleanup( ownerId: UUID, diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt index db404e0c..dee9b0b9 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt @@ -52,7 +52,8 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { fun `같은 주최자가 start 를 동시에 두 번 요청하면 하나만 200 나머지는 409 로 처리된다`() { val ownerId = UUID.randomUUID() userJpaRepository.save( - User(id = ownerId, nickname = "race-start", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.GUEST), + // 토너먼트 생성은 회원 전용(#339)이라 owner 는 MEMBER 다. 이 테스트의 관심사는 동시 start 경합이다. + User(id = ownerId, nickname = "race-start", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) val mockMvc = MockMvcBuilders @@ -60,7 +61,7 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.GUEST)}" + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" // PENDING 토너먼트 + READY 아이템 2개 준비 (단일 스레드, 직렬). @Transactional 이 없어 실제 커밋되므로, // 만든 item/snapshot id 를 끝의 정리에서 쓰기 위해 메서드 스코프로 잡는다(다른 테스트의 item 카운트 오염 방지). diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt index e60e63bc..e0ea8bbe 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt @@ -54,7 +54,8 @@ class TournamentWishAddConcurrencyIntegrationTest : IntegrationTestSupport() { fun `위시 아이템 담기를 동시에 두 번 요청하면 행 락으로 직렬화되어 32개 상한을 초과하지 않는다`() { val ownerId = UUID.randomUUID() userJpaRepository.save( - User(id = ownerId, nickname = "race-wish", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.GUEST), + // 토너먼트 생성은 회원 전용(#339)이라 owner 는 MEMBER 다. 위시 아이템을 담는 시나리오라 MEMBER 가 원래 자연스럽다. + User(id = ownerId, nickname = "race-wish", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) val mockMvc = MockMvcBuilders @@ -62,7 +63,7 @@ class TournamentWishAddConcurrencyIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.GUEST)}" + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" // 아이템 40개 + READY 스냅샷 생성 — A 요청(1-20), B 요청(21-40) 에서 나눠 씀. // 합산 40개는 TOURNAMENT_MAX_ITEM_COUNT(32)를 초과해 두 요청이 직렬화되면 하나는 반드시 차단된다. From 50c526fe03801f28ef3c2088c93976a7dd63d428 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: Sun, 9 Aug 2026 00:44:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=97=90=20=EA=B3=84=EC=A0=95=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM 을 태우는 등록 경로(위시 링크·이미지, 토너먼트 링크·이미지, 위시 재추출)에만 quota 를 건다. 조회·플레이 등 비용이 0 인 경로는 대상이 아니다 - 세는 단위를 요청 수가 아니라 큐에 넣는 item 수로 잡았다. 이미지 등록은 한 요청이 최대 5장이고 장당 LLM 호출이 1회씩 붙어, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 호출량이 5배까지 벌어진다 - 토너먼트 축은 요청자가 아니라 토너먼트 오너의 몫에서 차감한다. 참여자에 게스트가 섞이는데 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 리셋할 수 있다. 오너는 반드시 회원이라 소셜 계정 생성 비용이 그 우회를 막는다 - 위시와 토너먼트를 별개 키로 분리했다. 한 축으로 합치면 친구들이 내 토너먼트에 아이템을 넣은 만큼 내가 내 위시리스트를 못 쓰게 된다 - 오너 체감을 줄이는 방법으로 차감 가중치(0.5 등)를 검토했으나 한도를 키우는 쪽으로 정했다. 비용과 카운터가 어긋나면 메트릭으로 실제 호출량을 읽을 수 없어진다. 차감은 1:1 로 두고 tournament-limit 을 wish-limit 보다 크게 잡는다 - 이미지 v2 는 presign 시점에 차감하고 confirm 은 차감하지 않는다. confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, confirm 에서만 세면 그 경로가 통째로 한도를 우회한다 - 저장소는 Bucket4j 대신 StringRedisTemplate + Lua 고정 윈도우로 두었다. Bucket4j 는 버킷 상태를 객체로 직렬화해 저장해 무중단 배포 중 구·신버전 호환성을 테스트로 고정해야 하는데(테스트 규약의 직렬화/호환성 분류), 필요한 것은 창당 N 개라는 카운터뿐이다 - 판정과 차감을 한 Lua 로 원자화하고 거부 시에는 INCRBY 를 하지 않는다. 거부분까지 누적하면 한도에 걸린 사용자가 재시도할수록 카운터가 올라 창이 끝나도 넘긴 상태로 시작한다 - Redis 장애는 fail-open 으로 통과시킨다. 한도 인프라 때문에 등록이 멈추는 것보다 낫고, Redis 가 죽으면 refresh 토큰 저장소도 함께 죽어 그 창에서 대량 호출이 지속되기 어렵다 - ErrorCategory 에 TOO_MANY_REQUESTS(429)를 신설하고 RetryAfter 인터페이스를 구현한 예외만 Retry-After 헤더를 받게 했다. 예외 클래스 전체에 nullable 필드를 다는 대신 타입으로 가려, 재시도 시점을 모르는 예외에 0 같은 거짓값이 실리지 않는다 - 토너먼트 429 문구는 오너의 사용량을 드러내지 않는다. 이 응답은 참여 게스트도 받는데 남의 사용량은 요청자에게 알릴 정보가 아니다 - 폴링 백스톱 테스트가 게스트로 토너먼트를 만들고 있어 owner 를 MEMBER 로 정정했다(앞선 커밋의 회원 전용 게이트에 걸리던 것) --- .../piki/common/exception/CommonErrorCode.kt | 1 + .../piki/common/exception/ErrorCategory.kt | 4 + .../exception/GlobalExceptionHandler.kt | 8 + .../piki/common/exception/RetryAfter.kt | 11 + .../common/ratelimit/ItemQuotaException.kt | 39 +++ .../piki/common/ratelimit/ItemQuotaGuard.kt | 53 ++++ .../common/ratelimit/ItemQuotaProperties.kt | 43 +++ .../piki/common/ratelimit/ItemQuotaScope.kt | 16 + .../piki/common/ratelimit/ItemQuotaVerdict.kt | 11 + .../common/ratelimit/RedisItemQuotaStore.kt | 88 ++++++ .../controller/TournamentItemApi.kt | 38 +++ .../controller/TournamentItemApiExamples.kt | 9 + .../tournament/service/TournamentErrorCode.kt | 8 + .../service/TournamentItemService.kt | 42 +++ .../tournament/service/TournamentService.kt | 2 + .../piki/wishlist/controller/WishlistApi.kt | 46 +++ .../controller/WishlistApiExamples.kt | 10 + .../piki/wishlist/domain/WishErrorCode.kt | 4 + .../piki/wishlist/service/WishlistService.kt | 18 ++ src/main/resources/application.yml | 11 + .../ratelimit/ItemQuotaExceptionTest.kt | 40 +++ .../ratelimit/ItemQuotaIntegrationTest.kt | 285 ++++++++++++++++++ .../ratelimit/ItemQuotaPropertiesTest.kt | 40 +++ .../ItemQuotaStoreIntegrationTest.kt | 107 +++++++ .../PendingUploadPollingIntegrationTest.kt | 13 +- 25 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt create mode 100644 src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/CommonErrorCode.kt b/src/main/kotlin/com/depromeet/piki/common/exception/CommonErrorCode.kt index 80d279f8..271e2ce9 100644 --- a/src/main/kotlin/com/depromeet/piki/common/exception/CommonErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/common/exception/CommonErrorCode.kt @@ -28,6 +28,7 @@ enum class CommonErrorCode( METHOD_NOT_ALLOWED("COMMON-METHOD-NOT-ALLOWED", ErrorCategory.METHOD_NOT_ALLOWED, "지원하지 않는 요청 방식이에요."), UNSUPPORTED_MEDIA_TYPE("COMMON-UNSUPPORTED-MEDIA-TYPE", ErrorCategory.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 형식이에요."), INVALID_INPUT("COMMON-INVALID-INPUT", ErrorCategory.INVALID_INPUT, "요청 값을 다시 확인해 주세요."), + TOO_MANY_REQUESTS("COMMON-TOO-MANY-REQUESTS", ErrorCategory.TOO_MANY_REQUESTS, "요청이 너무 많아요. 잠시 후 다시 시도해 주세요."), RETRYABLE("COMMON-RETRYABLE", ErrorCategory.RETRYABLE, "일시적인 오류예요. 잠시 후 다시 시도해 주세요."), SERVER_BUSY("COMMON-SERVER-BUSY", ErrorCategory.SERVER_BUSY, "지금 요청이 많아요. 잠시 후 다시 시도해 주세요."), SERVER_ERROR("COMMON-SERVER-ERROR", ErrorCategory.SERVER_ERROR, "서버에 문제가 발생했어요. 불편을 드려 죄송해요."), diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/ErrorCategory.kt b/src/main/kotlin/com/depromeet/piki/common/exception/ErrorCategory.kt index 0f71507b..238269b4 100644 --- a/src/main/kotlin/com/depromeet/piki/common/exception/ErrorCategory.kt +++ b/src/main/kotlin/com/depromeet/piki/common/exception/ErrorCategory.kt @@ -19,6 +19,10 @@ enum class ErrorCategory( UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 형식이에요."), CONFLICT(HttpStatus.CONFLICT, "요청을 처리하지 못했어요. 다시 확인해 주세요."), + // 429 — 사용자·리소스 단위 사용량 한도를 넘긴 경우(#339). 5xx 의 SERVER_BUSY(503, 서버 전체가 바쁨)와 다르다: + // 이쪽은 서버가 멀쩡하고 "이 요청자의 몫"만 소진된 상태라 4xx 이며, 기다리면 창이 갱신돼 다시 쓸 수 있다. + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많아요. 잠시 후 다시 시도해 주세요."), + // RETRYABLE·SERVER_BUSY·SERVER_ERROR 는 셋 다 5xx 지만 클라의 재시도 방식이 다르다. description 을 구분해 // 재시도 여부 오해를 없앤다: RETRYABLE=즉시 재시도, SERVER_BUSY=대기 후 재시도, SERVER_ERROR=재시도 무의미. RETRYABLE(HttpStatus.BAD_GATEWAY, "일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요."), diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt index 3baa265b..3fa69e6c 100644 --- a/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/common/exception/GlobalExceptionHandler.kt @@ -50,8 +50,13 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() { errorCode ?.let { ApiResponseBody.fail(it, e.message) } ?: ApiResponseBody.fail(category, e.message) + // 재시도 시점을 아는 예외(RetryAfter)만 Retry-After 를 싣는다 — 429 한도 초과가 현재 유일한 구현체다. + // 모르는 예외에 0 같은 거짓 대기값을 실어 클라가 즉시 재시도하게 만들지 않도록 타입으로 가린다. + val headers = HttpHeaders() + (e as? RetryAfter)?.let { headers.set(HttpHeaders.RETRY_AFTER, it.retryAfterSeconds.toString()) } return ResponseEntity .status(status) + .headers(headers) .body(body) } @@ -111,6 +116,9 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() { status == HttpStatus.METHOD_NOT_ALLOWED -> ErrorCategory.METHOD_NOT_ALLOWED status == HttpStatus.UNSUPPORTED_MEDIA_TYPE -> ErrorCategory.UNSUPPORTED_MEDIA_TYPE status == HttpStatus.CONFLICT -> ErrorCategory.CONFLICT + // is4xxClientError 보다 앞에 둔다 — 뒤에 두면 429 가 INVALID_INPUT(400 code)으로 뭉개져 + // status 는 429 인데 code 는 COMMON-INVALID-INPUT 인 어긋난 응답이 나간다. + status == HttpStatus.TOO_MANY_REQUESTS -> ErrorCategory.TOO_MANY_REQUESTS status.is4xxClientError -> ErrorCategory.INVALID_INPUT status == HttpStatus.BAD_GATEWAY -> ErrorCategory.RETRYABLE status == HttpStatus.SERVICE_UNAVAILABLE -> ErrorCategory.SERVER_BUSY diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt b/src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt new file mode 100644 index 00000000..5a533327 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/exception/RetryAfter.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.common.exception + +// "언제 다시 시도하면 되는지" 가 응답 계약의 일부인 예외가 구현한다(#339 아이템 등록 한도). +// GlobalExceptionHandler 가 이 값을 Retry-After 헤더에 delta-seconds 형식으로 싣는다(RFC 9110 §10.2.3). +// +// HttpMappable(status·category)과 분리해 둔 이유: 같은 예외 클래스의 대다수 사유는 재시도 시점을 모른다. +// 예외 클래스 전체에 nullable 필드를 다는 대신, 재시도 시점을 아는 예외만 이 인터페이스를 구현하고 +// 핸들러가 `as?` 로 가려 헤더를 붙인다 — 헤더 유무가 타입으로 드러나고, 모르는 예외에 0 같은 거짓값이 안 실린다. +interface RetryAfter { + val retryAfterSeconds: Long +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt new file mode 100644 index 00000000..8535ea86 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt @@ -0,0 +1,39 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.common.exception.BaseException +import com.depromeet.piki.common.exception.ErrorCategory +import com.depromeet.piki.common.exception.ErrorCode +import com.depromeet.piki.common.exception.HttpMappable +import com.depromeet.piki.common.exception.RetryAfter +import org.springframework.http.HttpStatus + +// 아이템 등록 한도 초과(#339). 클라이언트가 정상 요청으로 닿을 수 있는 계약 응답이라 커스텀 예외(429)다. +// +// errorCode 를 생성자로 받는 이유: 사유 문구와 code 는 도메인이 소유해야 한다(위시가 막힌 것과 토너먼트가 +// 막힌 것은 사용자에게 다른 문구여야 하고, 토너먼트 쪽은 오너의 사용량이라는 사실을 요청자에게 노출하면 안 된다). +// 그렇다고 도메인마다 예외 클래스를 늘리면 RetryAfter 를 구현하는 클래스가 도메인 수만큼 생기고, 그 클래스의 +// 나머지 사유들까지 재시도 시점을 들어야 하는 nullable 필드를 떠안는다. 그래서 클래스는 여기 하나로 두고 +// code·문구만 도메인이 넘긴다. +class ItemQuotaException private constructor( + override val errorCode: ErrorCode, + override val retryAfterSeconds: Long, +) : BaseException(errorCode.message), + HttpMappable, + RetryAfter { + override val category: ErrorCategory get() = errorCode.category + override val httpStatus: HttpStatus get() = errorCode.category.httpStatus + + companion object { + // retryAfterSeconds 는 창이 리셋되기까지 남은 시간이다. 숫자만 받으므로 응답 detail 에 내부 정보가 실리지 않는다 + // (문구는 errorCode 가 고정으로 소유한다 — 임의 문자열을 message 에 싣는 팩토리를 두지 않는 이유). + fun exceeded( + errorCode: ErrorCode, + retryAfterSeconds: Long, + ): ItemQuotaException { + require(errorCode.category == ErrorCategory.TOO_MANY_REQUESTS) { + "한도 초과 예외의 category 는 TOO_MANY_REQUESTS 여야 한다: ${errorCode.code} → ${errorCode.category}" + } + return ItemQuotaException(errorCode, retryAfterSeconds) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt new file mode 100644 index 00000000..b36ca2d1 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt @@ -0,0 +1,53 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.common.exception.ErrorCode +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.util.UUID + +// 아이템 등록 경로가 부르는 한도 게이트(#339). +// +// 인터셉터가 아니라 서비스가 직접 부르는 이유 둘: (1) 차감 주체가 요청자가 아닐 수 있다 — 토너먼트 축은 +// tournamentId 로 오너를 찾아야 알 수 있어 핸들러 진입 시점엔 모른다. (2) 차감량이 요청 내용에 달렸다 — +// 이미지 장수만큼 깎아야 하는데 인터셉터에서 multipart 를 파싱해 세는 것은 본문을 두 번 읽는 일이다. +@Component +class ItemQuotaGuard( + private val store: RedisItemQuotaStore, + private val properties: ItemQuotaProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + // 한도를 넘으면 ItemQuotaException(429)을 던지고, 통과하면 그만큼 차감한 뒤 반환한다. + // errorCode 는 호출 도메인이 넘긴다 — 사용자에게 보일 문구와 code 의 소유권은 도메인에 있다. + fun consume( + scope: ItemQuotaScope, + ownerId: UUID, + amount: Int, + errorCode: ErrorCode, + ) { + if (!properties.enabled) return + + val verdict = + runCatching { + store.tryConsume( + key = scope.keyPrefix + ownerId, + amount = amount, + limit = properties.limitOf(scope), + windowMillis = properties.window.toMillis(), + ) + }.getOrElse { e -> + // fail-open — Redis 장애로 등록 기능 전체가 멈추는 것보다, 한도가 잠시 안 걸리는 쪽이 낫다. + // 이 선택의 위험(장애 창 동안 한도 없이 호출됨)은 제한적이다: Redis 가 죽으면 refresh 토큰 저장소도 + // 함께 죽어 로그인 흐름이 이미 망가지므로, 그 창에서 대량 호출이 지속되기 어렵다. + // 외부 의존성 실패라 warn (로그 레벨 정책). + log.warn("아이템 한도 검사 실패 — 통과시킨다(fail-open). scope={} ownerId={} amount={}", scope, ownerId, amount, e) + return + } + + when (verdict) { + is ItemQuotaVerdict.Allowed -> return + // 429 는 클라이언트 계약 위반이라 GlobalExceptionHandler 가 info 로 남긴다 — 여기서 또 찍지 않는다. + is ItemQuotaVerdict.Exceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt new file mode 100644 index 00000000..26ba39b0 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -0,0 +1,43 @@ +package com.depromeet.piki.common.ratelimit + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration + +// 아이템 등록 한도(#339) 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. +// +// 세는 단위는 요청 수가 아니라 **큐에 넣는 item 수**다 — 이미지 등록은 한 요청이 최대 5장이고 장당 LLM 호출이 +// 1회씩 붙으므로, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 호출량이 5배까지 벌어진다. +// +// 창은 고정 윈도우(fixed window)다. 첫 차감 시점부터 window 동안이 한 창이고 TTL 만료로 리셋된다. +// 창 경계에서 최대 2배 버스트가 가능하지만(창 끝 + 다음 창 시작), 목적이 "한 계정이 시간당 대략 N개"라 +// 그 정도 오차는 비용 방어에 영향을 주지 않는다. 정확한 평활화가 필요해지면 sliding window 로 올린다. +// 모든 사용자가 같은 시각에 리셋되는 창 인덱스 방식은 쓰지 않는다(thundering herd) — 사용자별로 창이 어긋난다. +@ConfigurationProperties(prefix = "item-quota") +data class ItemQuotaProperties( + // 끄면 차감·판정을 통째로 건너뛴다. 한도가 잘못 잡혀 정상 사용자를 막을 때 배포 없이 되돌리는 스위치다. + val enabled: Boolean = true, + val window: Duration = Duration.ofHours(1), + // 위시 등록 — 요청자 본인이 차감 주체다. 이미지 등록(최대 5장) 2번 또는 링크 10건에 해당한다. + val wishLimit: Int = 10, + // 토너먼트 아이템 등록 — 오너 한 명의 몫을 참여자 전원(최대 8명, 게스트 포함)이 나눠 쓴다. + // 위시보다 크게 두는 이유가 여기 있다: 같은 값이면 친구들이 넣은 만큼 오너가 체감하게 된다. + // "체감 완화" 를 차감 가중치(예: 0.5)로 풀지 않는 이유는 실제 비용과 카운터가 어긋나면 메트릭으로 + // 실제 호출량을 읽을 수 없게 되기 때문이다 — 차감은 1:1 로 정직하게 두고 한도로 조절한다. + val tournamentLimit: Int = 30, +) { + init { + require(!window.isZero && !window.isNegative) { + "item-quota.window($window)는 양수여야 한다 — 0 이면 창이 즉시 만료돼 한도가 무의미해진다." + } + require(wishLimit > 0) { "item-quota.wish-limit($wishLimit)은 양수여야 한다 — 0 이면 위시 등록이 통째로 막힌다." } + require(tournamentLimit > 0) { + "item-quota.tournament-limit($tournamentLimit)은 양수여야 한다 — 0 이면 토너먼트 아이템 등록이 통째로 막힌다." + } + } + + fun limitOf(scope: ItemQuotaScope): Int = + when (scope) { + ItemQuotaScope.WISH -> wishLimit + ItemQuotaScope.TOURNAMENT -> tournamentLimit + } +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt new file mode 100644 index 00000000..c06a4c3a --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaScope.kt @@ -0,0 +1,16 @@ +package com.depromeet.piki.common.ratelimit + +// 아이템 등록 한도(#339)의 계정 단위 축. 두 축을 분리해 한쪽의 소진이 다른 쪽을 막지 않게 한다 — +// 토너먼트 축은 참여자(게스트 포함) 전원이 오너 한 명의 몫을 함께 쓰므로, 한 축으로 합치면 +// "친구들이 내 토너먼트에 아이템을 넣어서 내가 내 위시리스트를 못 쓰는" 상황이 생긴다. +enum class ItemQuotaScope( + val keyPrefix: String, +) { + // 위시 등록 — 차감 주체가 곧 요청자다(위시는 회원 전용이라 항상 회원). + WISH("quota:item:wish:"), + + // 토너먼트 아이템 등록 — 차감 주체는 요청자가 아니라 **토너먼트 오너**다. 게스트 참여자도 아이템을 넣을 수 있는데, + // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 회원이라 + // (토너먼트 생성이 회원 전용) 소셜 계정 생성 비용이 그 우회를 막는다. + TOURNAMENT("quota:item:tournament:"), +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt new file mode 100644 index 00000000..3aae883b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaVerdict.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.common.ratelimit + +// 한도 판정 결과. 거부일 때만 재시도 시점을 들어, "허용인데 retryAfter 가 0" 같은 무의미한 상태를 타입에서 없앤다. +sealed interface ItemQuotaVerdict { + data object Allowed : ItemQuotaVerdict + + // retryAfterSeconds — 창이 리셋되기까지 남은 시간(초, 올림). Retry-After 헤더로 그대로 나간다. + data class Exceeded( + val retryAfterSeconds: Long, + ) : ItemQuotaVerdict +} diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt new file mode 100644 index 00000000..794f4662 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt @@ -0,0 +1,88 @@ +package com.depromeet.piki.common.ratelimit + +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.DefaultRedisScript +import org.springframework.stereotype.Component +import kotlin.math.max + +// 고정 윈도우 카운터를 Redis 에 두는 한도 저장소(#339). +// +// Bucket4j 같은 라이브러리 대신 Lua 를 직접 쓰는 이유: 필요한 것이 "창당 N 개" 라는 단순 카운터뿐이고, +// Bucket4j 는 버킷 상태를 **객체로 직렬화해** Redis 에 저장한다. 그러면 무중단 배포 중 구·신버전이 같은 키를 +// 공유하는 동안의 호환성을 테스트로 고정해야 한다(테스트 규약의 직렬화/호환성 분류). 문자열 카운터만 쓰면 +// 그 부담이 통째로 사라지고, 이 repo 의 기존 Redis 사용(StringRedisTemplate + Lua, RedisRefreshTokenStore)과도 같은 결이다. +@Component +class RedisItemQuotaStore( + private val redisTemplate: StringRedisTemplate, +) { + // all-or-nothing 차감. 이미지 5장 요청이 한도 3 만 남은 상태에서 3장만 통과하고 2장이 잘리면 클라이언트가 + // "일부만 등록됨" 을 다뤄야 하는데, 등록 API 는 그런 부분 성공 계약이 없다. 그래서 전부 되거나 전부 거부다. + fun tryConsume( + key: String, + amount: Int, + limit: Int, + windowMillis: Long, + ): ItemQuotaVerdict { + require(amount > 0) { "차감량($amount)은 양수여야 한다 — 0 건 등록은 애초에 이 경로에 오지 않는다." } + val result = + redisTemplate.execute( + CONSUME_SCRIPT, + listOf(key), + amount.toString(), + limit.toString(), + windowMillis.toString(), + ) ?: error("아이템 한도 Lua script 가 null 을 반환했다 (key=$key)") + + return when { + result.startsWith(ALLOWED_PREFIX) -> ItemQuotaVerdict.Allowed + result.startsWith(EXCEEDED_PREFIX) -> { + val remainingMillis = result.removePrefix(EXCEEDED_PREFIX).toLong() + // 올림 + 최소 1초 — 남은 시간이 0.2초여도 Retry-After: 0 을 주면 클라가 즉시 재시도해 또 거부된다. + ItemQuotaVerdict.Exceeded(max(1L, (remainingMillis + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND)) + } + else -> error("아이템 한도 Lua script 가 예상 못한 값을 반환했다: $result (key=$key)") + } + } + + companion object { + private const val ALLOWED_PREFIX = "A:" + private const val EXCEEDED_PREFIX = "X:" + private const val MILLIS_PER_SECOND = 1_000L + + // 판정과 차감을 한 스크립트로 원자화한다. GET → 비교 → INCRBY 를 앱에서 나눠 하면 동시 요청이 각자 + // 통과 판정을 받아 한도를 넘겨 차감할 수 있다(check-then-act race). Redis 싱글스레드 직렬화가 그걸 막는다. + // + // KEYS[1]=카운터 키, ARGV[1]=차감량, ARGV[2]=한도, ARGV[3]=창 길이(ms) + // + // 반환: + // "A:<누적>" 허용 — 차감 후 누적값 + // "X:<남은 ms>" 거부 — 차감하지 않음. 창이 리셋되기까지 남은 시간 + // + // 거부 시 INCRBY 를 하지 않는 것이 중요하다. 거부분까지 누적하면 한도에 걸린 사용자가 재시도할 때마다 + // 카운터가 계속 올라, TTL 로 창이 끝나도 이미 한도를 넘긴 상태로 시작하는 일이 생긴다(사실상 영구 차단). + // + // TTL 은 INCRBY 직후 PTTL 이 음수(-1: TTL 없음)일 때만 건다. "누적값 == 차감량이면 첫 차감" 으로 판정하면 + // 창 도중 키가 TTL 없이 남는 경로가 생겼을 때 그 키가 영구화된다 — PTTL 로 보면 그 경우까지 복구된다. + private val CONSUME_SCRIPT = + DefaultRedisScript().apply { + setScriptText( + """ + local amount = tonumber(ARGV[1]) + local limit = tonumber(ARGV[2]) + local current = tonumber(redis.call('GET', KEYS[1]) or '0') + if current + amount > limit then + local ttl = redis.call('PTTL', KEYS[1]) + if ttl < 0 then ttl = 0 end + return 'X:' .. ttl + end + local updated = redis.call('INCRBY', KEYS[1], amount) + if redis.call('PTTL', KEYS[1]) < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[3]) + end + return 'A:' .. updated + """.trimIndent(), + ) + setResultType(String::class.java) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index cdebf85e..d86cd891 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -22,6 +22,14 @@ import org.springframework.http.MediaType import org.springframework.web.multipart.MultipartFile import java.util.UUID +// 아이템 등록 한도(#339) 응답 설명. 이 축의 한도는 요청자가 아니라 **토너먼트 오너**의 몫에서 깎이므로, +// 참여자는 자기가 처음 담는 경우에도 429 를 받을 수 있다. 클라이언트가 그 상황을 오해하지 않게 명시한다. +// (응답 문구 자체는 오너의 사용량을 드러내지 않는다 — 남의 사용량은 요청자에게 알릴 정보가 아니다.) +private const val TOURNAMENT_RATE_LIMIT_DESCRIPTION = + "아이템 등록 한도 초과 (code: TOURNAMENT-037). 한도는 요청자가 아니라 토너먼트 오너의 몫에서 차감되므로 " + + "참여자가 처음 담는 경우에도 받을 수 있다. 요청 수가 아니라 등록하는 item 수로 세며(이미지 5장 = 5), " + + "Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." + @Tag(name = "Tournament Item", description = "토너먼트 아이템 API") interface TournamentItemApi { @@ -243,6 +251,16 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "429", + description = TOURNAMENT_RATE_LIMIT_DESCRIPTION, + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun addItemFromLink( @@ -326,6 +344,16 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "429", + description = TOURNAMENT_RATE_LIMIT_DESCRIPTION, + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ApiResponse( responseCode = "502", description = "이미지 저장 실패 (원본을 S3 에 적재하는 중 스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-001", @@ -417,6 +445,16 @@ interface TournamentItemApi { ), ], ), + ApiResponse( + responseCode = "429", + description = "$TOURNAMENT_RATE_LIMIT_DESCRIPTION 발급 시점에 차감하므로 이어지는 confirm 은 추가로 소모하지 않는다.", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ApiResponse( responseCode = "502", description = "presigned URL 발급 실패 (스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-002", diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt index 21c30da1..571da173 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper import com.depromeet.piki.common.openapi.binds import com.depromeet.piki.common.openapi.examples +import com.depromeet.piki.common.ratelimit.ItemQuotaException import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.storage.ImageStorageException import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse @@ -17,6 +18,7 @@ import com.depromeet.piki.tournament.controller.dto.AddTournamentItemsFromImages import com.depromeet.piki.tournament.controller.dto.AddTournamentItemsFromWishResponse import com.depromeet.piki.tournament.controller.dto.AddTournamentItemsRequest import com.depromeet.piki.tournament.controller.dto.TournamentItemDetailResponse +import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.tournament.service.TournamentException import org.springdoc.core.customizers.OperationCustomizer import org.springframework.context.annotation.Bean @@ -79,6 +81,7 @@ class TournamentItemApiExamples( add(TournamentException.clonedTournamentCannotAddItems(), name = "플레이링크 복제 토너먼트에는 아이템 추가 불가") add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (오너 몫에서 차감)") } handlerMethod.binds(TournamentItemController::addItemsFromImages) -> @@ -105,6 +108,7 @@ class TournamentItemApiExamples( add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") add(ImageStorageException.uploadFailed(), name = "이미지 저장 실패 (S3 업로드 장애)") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (오너 몫에서 이미지 장수만큼 차감)") } handlerMethod.binds(TournamentItemController::presignImageUploads) -> @@ -122,6 +126,7 @@ class TournamentItemApiExamples( add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") add(ImageStorageException.presignFailed(), name = "presigned URL 발급 실패 (스토리지 장애)") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (발급 시점에 오너 몫에서 차감)") } handlerMethod.binds(TournamentItemController::confirmImageRegistration) -> @@ -287,6 +292,10 @@ class TournamentItemApiExamples( // 사용한다(GlobalExceptionHandler.handleBaseException 과 동일). 따라서 이 cause 는 payload 에 영향을 주지 않는 더미다. private val urlFormatCause = IllegalArgumentException("example") + // 아이템 등록 한도 초과(#339). retryAfterSeconds 는 Retry-After 헤더로만 나가고 body 에는 실리지 않으므로 + // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. + private val itemQuotaExceeded = ItemQuotaException.exceeded(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, 900) + // 이미지 등록 v2 presigned 발급 응답 샘플 — 위시와 동일 구조. uploadUrl 의 서명 쿼리스트링은 예시라 실제 값이 아니다. private val presignedUploadsSample = PresignedImageUploadResponse( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index 00b57127..9f7127a1 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -57,4 +57,12 @@ enum class TournamentErrorCode( // 036 은 게스트 권한 정리(#339)에서 추가됐다. 토너먼트 생성은 회원 전용이 되고, 생성된 토너먼트에 // 아이템을 담는 것과 플레이는 게스트에게 그대로 열려 있다 — "게스트는 소비만, 생산은 회원만". GUEST_CANNOT_CREATE_TOURNAMENT("TOURNAMENT-036", ErrorCategory.FORBIDDEN, "토너먼트 만들기는 회원만 이용할 수 있어요."), + + // 037 도 #339. 차감 주체는 토너먼트 오너지만 이 응답은 참여자(게스트 포함) 누구나 받을 수 있으므로, + // 문구에 "오너의 사용량" 을 드러내지 않는다 — 남의 사용량은 요청자에게 알릴 정보가 아니다. + ITEM_QUOTA_EXCEEDED( + "TOURNAMENT-037", + ErrorCategory.TOO_MANY_REQUESTS, + "이 토너먼트에는 지금 아이템을 추가할 수 없어요. 잠시 후 다시 시도해 주세요.", + ), } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 75a4115f..ed66465e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -1,5 +1,7 @@ package com.depromeet.piki.tournament.service +import com.depromeet.piki.common.ratelimit.ItemQuotaGuard +import com.depromeet.piki.common.ratelimit.ItemQuotaScope import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage @@ -11,6 +13,7 @@ import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.routing.ExtractionRoutingPolicy import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository +import com.depromeet.piki.tournament.repository.TournamentUserRepository import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.util.UUID @@ -23,8 +26,27 @@ class TournamentItemService( private val imagePresignService: ImagePresignService, private val tournamentRepository: TournamentRepository, private val tournamentItemRepository: TournamentItemRepository, + private val tournamentUserRepository: TournamentUserRepository, private val itemSnapshotRepository: ItemSnapshotRepository, + private val itemQuotaGuard: ItemQuotaGuard, ) { + // 아이템 등록 비용은 요청자가 아니라 **토너먼트 오너**의 몫에서 깎는다(#339). 참여자에는 게스트가 섞이는데 + // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 + // 회원이라(토너먼트 생성이 회원 전용) 그 우회가 성립하지 않는다. + // + // tournament 를 다시 조회하는 것은 verifyCanAddItems 와 중복이지만, 오너를 함께 돌려주도록 그 시그니처를 + // 바꾸면 호출부 전부가 쓰지도 않는 값을 받게 된다. 조회 1회 비용을 택했다. + private fun ownerIdOf(tournamentId: Long): UUID { + val tournament = + tournamentRepository.findTournamentById(tournamentId) + ?: throw TournamentException.notFoundTournament() + val owner = + tournamentUserRepository.findByIds(listOf(tournament.ownerTournamentUserId)).firstOrNull() + // 토너먼트가 있으면 오너 TournamentUser 도 반드시 있다(create 가 함께 만든다) — 없으면 데이터 파손이다. + ?: error("토너먼트 오너 행이 없다 (tournamentId=$tournamentId, ownerTournamentUserId=${tournament.ownerTournamentUserId})") + return owner.userId + } + fun addItemFromLink( userId: UUID, tournamentId: Long, @@ -34,6 +56,10 @@ class TournamentItemService( // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다(400). // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. extractionRoutingPolicy.verifyRegistrable(link) + // 권한·상태를 차감 전에 확인한다(이미지 경로와 같은 이유) — 참여자도 아닌 요청이 오너의 몫을 깎으면 안 된다. + // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. + tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) + itemQuotaGuard.consume(ItemQuotaScope.TOURNAMENT, ownerIdOf(tournamentId), 1, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) // URL 경로는 PENDING snapshot 을 커밋만 하고(작업 큐 적재) 즉시 반환한다. 파싱은 디스패처(@Scheduled)가 // PENDING 을 집어 워커에 넘긴다 — @Async 유실과 무관하게 최소 1회는 claim 된다(at-least-once). // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). @@ -51,6 +77,13 @@ class TournamentItemService( tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } + // 장당 OCR 이 1회씩 붙으므로 장수만큼 오너 몫에서 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. + itemQuotaGuard.consume( + ItemQuotaScope.TOURNAMENT, + ownerIdOf(tournamentId), + images.size, + TournamentErrorCode.ITEM_QUOTA_EXCEEDED, + ) // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. val imageKeys = productImages.map { uploadRaw(it) } // 사전검증을 통과해도 정원은 persist 의 FOR UPDATE 가 최종 판정한다(동시 추가 race). 거기서 거부되면 방금 올린 raw 가 @@ -74,6 +107,14 @@ class TournamentItemService( ): List { if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) + // 위시 v2 와 같은 이유로 발급 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, + // confirm 에서만 세면 그 경로가 한도를 우회한다. confirm 은 차감하지 않는다(이중 차감 방지). + itemQuotaGuard.consume( + ItemQuotaScope.TOURNAMENT, + ownerIdOf(tournamentId), + contentTypes.size, + TournamentErrorCode.ITEM_QUOTA_EXCEEDED, + ) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.tournament(key, userId, tournamentId, expiresAt) } @@ -90,6 +131,7 @@ class TournamentItemService( ): List { if (imageKeys.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) + // 한도는 여기서 차감하지 않는다 — 이 key 들은 presignImageUploads 에서 이미 차감된 몫이다(이중 차감 방지). imagePresignService.verifyUploaded(imageKeys) return tournamentItemPersistenceService .registerClaimedImages(imageKeys, userId, tournamentId) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 85a8b6e9..c3cbf671 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -125,6 +125,8 @@ class TournamentService( eventPublisher.publishEvent(TournamentJoined(tournamentId = tournamentId, actorId = userId)) } + // 아이템 등록 한도(#339)를 차감하지 않는다 — 위시에 이미 있는 item 을 참조만 하므로 새 파싱·LLM 호출이 없다. + // 그 item 을 위시에 담을 때 이미 한 번 차감됐다. @Transactional fun addItemsFromWish( userId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index 13bc681f..d00cfb1a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -19,6 +19,12 @@ import org.springframework.http.MediaType import org.springframework.web.multipart.MultipartFile import java.util.UUID +// 아이템 등록 한도(#339) 응답 설명. 위시 등록 계열 4개 엔드포인트가 같은 문구를 쓰므로 한 곳에 둔다. +// 세는 단위가 요청 수가 아니라 item 수라는 점이 클라이언트 계약의 핵심이라 명시한다. +private const val RATE_LIMIT_DESCRIPTION = + "아이템 등록 한도 초과 (code: WISH-010). 한도는 요청 수가 아니라 등록하는 item 수로 센다 — " + + "이미지 5장 등록은 5 를 소모한다. Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." + @Tag(name = "Wishlist", description = "위시리스트 등록/조회/복구/삭제 API") interface WishlistApi { @Operation( @@ -93,6 +99,16 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "429", + description = RATE_LIMIT_DESCRIPTION, + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun registerFromUrl( @@ -417,6 +433,16 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "429", + description = RATE_LIMIT_DESCRIPTION, + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun refreshWishItem( @@ -615,6 +641,16 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "429", + description = RATE_LIMIT_DESCRIPTION, + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ApiResponse( responseCode = "502", description = "이미지 저장 실패 (원본을 S3 에 적재하는 중 스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-001", @@ -695,6 +731,16 @@ interface WishlistApi { ), ], ), + ApiResponse( + responseCode = "429", + description = "$RATE_LIMIT_DESCRIPTION 발급 시점에 차감하므로 이어지는 confirm 은 추가로 소모하지 않는다.", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ApiResponse( responseCode = "502", description = "presigned URL 발급 실패 (스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-002", diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index 8dcfc14e..0a70d76a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper import com.depromeet.piki.common.openapi.binds import com.depromeet.piki.common.openapi.examples +import com.depromeet.piki.common.ratelimit.ItemQuotaException import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse import com.depromeet.piki.common.storage.ImageStorageException @@ -18,6 +19,7 @@ import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.wishlist.controller.dto.WishDetailResponse import com.depromeet.piki.wishlist.controller.dto.WishItemResponse import com.depromeet.piki.wishlist.controller.dto.WishlistUpdateRequest +import com.depromeet.piki.wishlist.domain.WishErrorCode import com.depromeet.piki.wishlist.domain.WishException import org.springdoc.core.customizers.OperationCustomizer import org.springframework.context.annotation.Bean @@ -51,6 +53,7 @@ class WishlistApiExamples( unauthorized() add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과") } } if (handlerMethod.binds(WishlistController::getWishlist)) { @@ -163,6 +166,7 @@ class WishlistApiExamples( add(WishException.forbiddenWishItems(), name = "본인 위시 아님") add(WishException.notFound(), name = "존재하지 않는 위시 항목") unauthorized() + add(itemQuotaExceeded, name = "아이템 등록 한도 초과") } } if (handlerMethod.binds(WishlistController::deleteWish)) { @@ -208,6 +212,7 @@ class WishlistApiExamples( unauthorized() add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (이미지 장수만큼 소모)") } } if (handlerMethod.binds(WishlistController::presignImageUploads)) { @@ -223,6 +228,7 @@ class WishlistApiExamples( unauthorized() add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") + add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (발급 시점에 장수만큼 소모)") } } if (handlerMethod.binds(WishlistController::confirmImageRegistration)) { @@ -248,6 +254,10 @@ class WishlistApiExamples( // 사용한다(GlobalExceptionHandler.handleBaseException 과 동일). 따라서 이 cause 는 payload 에 영향을 주지 않는 더미다. private val urlFormatCause = IllegalArgumentException("example") + // 아이템 등록 한도 초과(#339). retryAfterSeconds 는 Retry-After 헤더로만 나가고 body 에는 실리지 않으므로 + // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. + private val itemQuotaExceeded = ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, 900) + // 상세 조회 — 지금 보이는 값(item)과 그 상품의 가격 기록. 서버 추출값 사이에 타인이 넣은 수기(89,000원)가 섞여 있어, // source·editedByMe 로 구분해 그리는 예시다. private val wishDetailSample = diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt index 3e94afee..baae83f8 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt @@ -25,4 +25,8 @@ enum class WishErrorCode( "추출에 실패한 항목은 새로고침 대신 정보를 직접 입력해 복구해 주세요.", ), ALREADY_EXISTS("WISH-009", ErrorCategory.CONFLICT, "이미 위시리스트에 등록된 상품이에요."), + + // 010 은 아이템 등록 한도(#339)에서 추가됐다. 창이 지나면 다시 담을 수 있으므로 "다 썼다" 가 아니라 + // "잠시 후" 로 안내한다 — 남은 시간은 문구가 아니라 Retry-After 헤더가 전한다(문구를 고정으로 두는 이유). + ITEM_QUOTA_EXCEEDED("WISH-010", ErrorCategory.TOO_MANY_REQUESTS, "지금은 더 담을 수 없어요. 잠시 후 다시 시도해 주세요."), } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index bc42a988..9d4efc61 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -1,5 +1,7 @@ package com.depromeet.piki.wishlist.service +import com.depromeet.piki.common.ratelimit.ItemQuotaGuard +import com.depromeet.piki.common.ratelimit.ItemQuotaScope import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage @@ -16,6 +18,7 @@ import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.service.UserService import com.depromeet.piki.wishlist.domain.WishCursor import com.depromeet.piki.wishlist.domain.WishDeleteIds +import com.depromeet.piki.wishlist.domain.WishErrorCode import com.depromeet.piki.wishlist.domain.WishException import com.depromeet.piki.wishlist.domain.WishlistSize import com.depromeet.piki.wishlist.repository.WishRepository @@ -37,6 +40,7 @@ class WishlistService( private val itemRepository: ItemRepository, private val itemSnapshotRepository: ItemSnapshotRepository, private val itemDisplayService: ItemDisplayService, + private val itemQuotaGuard: ItemQuotaGuard, private val userService: UserService, ) { // 위시리스트는 회원 전용. 게스트(인증은 됐으나 회원 아님)는 Security 가 아니라 여기서 도메인 계약으로 막아 @@ -63,6 +67,9 @@ class WishlistService( // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다. // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. extractionRoutingPolicy.verifyRegistrable(link) + // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. + // 링크 1건은 파싱이 파서로 풀리면 LLM 을 안 탈 수도 있지만, 등록 시점엔 알 수 없으므로 1 로 센다. + itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.persist(userId, Item(link)) } @@ -78,6 +85,8 @@ class WishlistService( if (images.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } + // 장당 OCR 이 1회씩 붙으므로 장수만큼 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. + itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, images.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. val imageKeys = productImages.map { uploadRaw(it) } // 위시 이미지 등록엔 정원 같은 계약 거부가 없어 정상 흐름에선 persist 가 떨어지지 않지만, 예기치 못한 영속화 실패에도 @@ -96,6 +105,10 @@ class WishlistService( ): List { requireMember(userId) if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() + // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, + // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). + // 발급만 받고 업로드를 안 하면 그만큼 몫을 손해 보지만, 그건 클라이언트가 자기 요청을 버린 경우다. + itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, contentTypes.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.wish(key, userId, expiresAt) } @@ -111,6 +124,7 @@ class WishlistService( ): List { requireMember(userId) if (imageKeys.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() + // 한도는 여기서 차감하지 않는다 — 이 key 들은 presignImageUploads 에서 이미 차감된 몫이다(이중 차감 방지). imagePresignService.verifyUploaded(imageKeys) return wishPersistenceService.registerClaimedImages(imageKeys, userId) } @@ -248,6 +262,10 @@ class WishlistService( wishId: Long, ): WishWithItem { requireMember(userId) + // 재추출도 파싱을 한 번 더 돌리므로 신규 등록과 같은 비용이다 — 1 로 차감한다. + // refresh 계약 검증(링크 없음·FAILED 항목 등)은 persistence 안쪽이라 여기선 앞서 깎이는데, 그 두 사유는 + // 클라가 refresh 버튼을 띄우지 않는 상태라 정상 흐름에서 반복 호출되지 않는다. + itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.refresh(userId = userId, wishId = wishId) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e7b71641..ebc39167 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -160,6 +160,17 @@ s3: region: ${S3_REGION:ap-northeast-2} public-base-url: ${S3_PUBLIC_BASE_URL:} +# 아이템 등록 한도(#339) — LLM 을 태우는 등록 경로에만 걸리는 계정 단위 quota. 세는 단위는 요청 수가 아니라 +# 큐에 넣는 item 수다(이미지 5장 = 5). 값은 env 로 재정의해 배포 없이 조일 수 있다. +item-quota: + # 한도가 잘못 잡혀 정상 사용자를 막을 때 되돌리는 스위치. 끄면 차감·판정을 통째로 건너뛴다. + enabled: ${ITEM_QUOTA_ENABLED:true} + window: ${ITEM_QUOTA_WINDOW:1h} + # 위시는 요청자 본인 몫 — 이미지 등록(최대 5장) 2번 또는 링크 10건. + wish-limit: ${ITEM_QUOTA_WISH_LIMIT:10} + # 토너먼트는 오너 한 명의 몫을 참여자 전원(최대 8명)이 나눠 쓰므로 위시보다 크게 둔다. + tournament-limit: ${ITEM_QUOTA_TOURNAMENT_LIMIT:30} + notification: # 알림 보존 기간(일). 생성 후 이 기간을 넘긴 알림은 N일 자동삭제 스케줄러(NotificationCleanupScheduler)가 하드삭제한다. retention-days: ${NOTIFICATION_RETENTION_DAYS:14} diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt new file mode 100644 index 00000000..205f312a --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt @@ -0,0 +1,40 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.common.exception.ErrorCategory +import com.depromeet.piki.tournament.service.TournamentErrorCode +import com.depromeet.piki.wishlist.domain.WishErrorCode +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ItemQuotaExceptionTest { + @Test + fun `도메인 code 에서 status 와 message 를 파생한다`() { + val exception = ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 900) + + assertEquals(HttpStatus.TOO_MANY_REQUESTS, exception.httpStatus) + assertEquals(ErrorCategory.TOO_MANY_REQUESTS, exception.category) + assertEquals(WishErrorCode.ITEM_QUOTA_EXCEEDED, exception.errorCode) + assertEquals(WishErrorCode.ITEM_QUOTA_EXCEEDED.message, exception.message) + assertEquals(900, exception.retryAfterSeconds) + } + + @Test + fun `토너먼트 축은 자기 code 와 문구를 쓴다`() { + // 두 축이 같은 예외 클래스를 공유하되 사용자 대면 문구·code 는 도메인이 소유한다. + val exception = ItemQuotaException.exceeded(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 60) + + assertEquals(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, exception.errorCode) + assertEquals(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message, exception.message) + } + + @Test + fun `429 가 아닌 code 로 만들면 코드 버그로 즉시 실패한다`() { + // status 는 category 가 소유하므로, 429 아닌 code 를 넘기면 "한도 초과인데 409" 같은 응답이 조용히 나간다. + // 클라이언트가 도달할 수 없는 개발자 실수라 계약 예외가 아니라 불변식 위반(require)으로 막는다. + assertFailsWith { + ItemQuotaException.exceeded(WishErrorCode.ALREADY_EXISTS, retryAfterSeconds = 60) + } + } +} diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt new file mode 100644 index 00000000..92cebbbe --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -0,0 +1,285 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.StubItemParsingWorker +import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.tournament.service.TournamentErrorCode +import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.wishlist.domain.WishErrorCode +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.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +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.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import tools.jackson.databind.ObjectMapper +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// 아이템 등록 한도(#339)의 계약 검증. 한도 자체의 산술(창 경계·all-or-nothing)은 RedisItemQuotaStore 쪽 검증이 +// 맡고, 여기서는 "진입점에서 무엇이 얼마나 차감되고 넘치면 어떤 응답이 나가는가" 라는 계약만 본다. +// +// DB 는 클래스 레벨 @Transactional 로 롤백되지만 **Redis 는 롤백되지 않는다.** 그래서 매 테스트가 새 UUID 를 +// 써서 카운터 키를 격리한다(정리 코드를 두지 않는 원칙과 같은 해법 — 동시성 테스트가 쓰는 방식). +// +// 한도까지 실제로 등록을 반복하지 않고 카운터를 미리 채워 경계 직전 상태를 만든다. 등록 10번을 태우면 +// 테스트가 느려지기만 하고 검증하는 계약은 같다. +@Transactional +class ItemQuotaIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var webApplicationContext: WebApplicationContext + + @Autowired + private lateinit var jwtProvider: JwtProvider + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var redisTemplate: StringRedisTemplate + + @Autowired + private lateinit var properties: ItemQuotaProperties + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var stubItemParsingWorker: StubItemParsingWorker + + @Test + fun `위시 링크 등록이 한도를 넘으면 429 와 WISH-010 code, Retry-After 헤더를 반환한다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + // 한도를 정확히 소진한 상태 — 다음 1건이 넘긴다. + fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit) + + 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/1"}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(jsonPath("$.code").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.code)) + .andExpect(jsonPath("$.detail").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.message)) + .andExpect(jsonPath("$.data").doesNotExist()) + // 남은 시간은 창 길이에 따라 달라지므로 값이 아니라 "양수가 실렸다" 를 계약으로 고정한다. + .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) + + val retryAfter = requireNotNull(currentCount(ItemQuotaScope.WISH, userId)) + // 거부된 요청은 카운터를 올리지 않는다 — 올리면 재시도할수록 창이 끝나도 한도를 넘긴 채 시작한다. + assertEquals(properties.wishLimit.toLong(), retryAfter) + } + + @Test + fun `이미지 등록은 요청 1건이 아니라 이미지 장수만큼 한도를 소모한다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + // 남은 몫을 2 로 만든다 — 3장은 넘치고 2장은 통과해야 한다. + fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit - 2) + + mockMvc + .perform( + post("/api/v1/wishlists/images/presigned") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentTypes":["image/png","image/png","image/png"]}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(jsonPath("$.code").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.code)) + + // all-or-nothing — 3장이 거부됐어도 부분 차감이 없어 2장은 그대로 통과한다. + mockMvc + .perform( + post("/api/v1/wishlists/images/presigned") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentTypes":["image/png","image/png"]}"""), + ).andExpect(status().isOk) + + assertEquals(properties.wishLimit.toLong(), currentCount(ItemQuotaScope.WISH, userId)) + } + + @Test + fun `토너먼트 아이템 등록은 요청한 게스트가 아니라 토너먼트 오너의 몫에서 차감된다`() { + val mockMvc = buildMockMvc() + val ownerId = UUID.randomUUID() + insertUser(ownerId, IdentityType.MEMBER) + // 파싱 워커가 미커밋 item 을 집어 warn 을 쏟지 않도록 끈다(이 테스트의 관심사는 차감 귀속이다). + stubItemParsingWorker.enabled = false + + try { + val (tournamentId, inviteCode) = createTournament(mockMvc, ownerId) + val guestId = joinAsGuest(mockMvc, tournamentId, inviteCode) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/link") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(guestId, IdentityType.GUEST)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/2"}"""), + ).andExpect(status().isOk) + + // 요청자는 게스트지만 차감은 오너 몫에서 일어난다 — 게스트 계정을 갈아타도 한도가 리셋되지 않는 근거. + assertEquals(1L, currentCount(ItemQuotaScope.TOURNAMENT, ownerId)) + assertNull(currentCount(ItemQuotaScope.TOURNAMENT, guestId)) + } finally { + stubItemParsingWorker.enabled = true + } + } + + @Test + fun `위시 한도를 다 써도 토너먼트 아이템은 담을 수 있다`() { + val mockMvc = buildMockMvc() + val ownerId = UUID.randomUUID() + insertUser(ownerId, IdentityType.MEMBER) + fillQuota(ItemQuotaScope.WISH, ownerId, properties.wishLimit) + stubItemParsingWorker.enabled = false + + try { + val (tournamentId, _) = createTournament(mockMvc, ownerId) + + // 두 축은 별개 키라 위시 소진이 토너먼트를 막지 않는다 — 합쳐 두면 "친구들이 내 토너먼트에 담아서 + // 내가 내 위시를 못 쓰는" 반대 방향 사고도 함께 생긴다. + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/link") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/3"}"""), + ).andExpect(status().isOk) + + assertEquals(1L, currentCount(ItemQuotaScope.TOURNAMENT, ownerId)) + } finally { + stubItemParsingWorker.enabled = true + } + } + + @Test + fun `토너먼트 오너의 몫이 소진되면 참여 게스트의 등록이 429 와 TOURNAMENT-037 로 거부된다`() { + val mockMvc = buildMockMvc() + val ownerId = UUID.randomUUID() + insertUser(ownerId, IdentityType.MEMBER) + stubItemParsingWorker.enabled = false + + try { + val (tournamentId, inviteCode) = createTournament(mockMvc, ownerId) + val guestId = joinAsGuest(mockMvc, tournamentId, inviteCode) + fillQuota(ItemQuotaScope.TOURNAMENT, ownerId, properties.tournamentLimit) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/link") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(guestId, IdentityType.GUEST)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url":"https://www.musinsa.com/products/4"}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(jsonPath("$.code").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.code)) + // 응답 문구가 오너의 사용량을 드러내지 않는지 — 남의 사용량은 요청자에게 알릴 정보가 아니다. + .andExpect(jsonPath("$.detail").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message)) + .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) + + assertTrue(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message.contains("토너먼트")) + } finally { + stubItemParsingWorker.enabled = true + } + } + + private fun buildMockMvc(): MockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + + private fun insertUser( + userId: UUID, + identityType: IdentityType, + ) { + 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.name, + ) + } + + private fun token( + userId: UUID, + identityType: IdentityType, + ): String = jwtProvider.generateAccessToken(userId, identityType) + + // 카운터를 미리 채워 경계 직전 상태를 만든다. 창 TTL 은 운영 경로(Lua)가 첫 차감 때 걸므로 여기서도 함께 건다 — + // TTL 없는 키를 남기면 이후 테스트가 같은 UUID 를 재사용할 때(없지만) 영구 키가 된다. + private fun fillQuota( + scope: ItemQuotaScope, + userId: UUID, + amount: Int, + ) { + redisTemplate.opsForValue().set(scope.keyPrefix + userId, amount.toString(), properties.window) + } + + private fun currentCount( + scope: ItemQuotaScope, + userId: UUID, + ): Long? = redisTemplate.opsForValue().get(scope.keyPrefix + userId)?.toLong() + + private fun createTournament( + mockMvc: MockMvc, + ownerId: UUID, + ): Pair { + val response = + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(ownerId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"한도 테스트 토너먼트"}"""), + ).andExpect(status().isCreated) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val data = objectMapper.readTree(response).path("data") + return data.path("tournamentId").asLong() to data.path("inviteCode").asString() + } + + // 게스트 합류는 계정 생성까지 겸한다. 발급 토큰은 쿠키로 내려오지만 이 테스트가 필요한 것은 그 게스트의 + // userId 뿐이라, 응답에서 userId 만 읽고 헤더용 토큰은 같은 신분으로 직접 만든다(발급 토큰과 동등하다). + private fun joinAsGuest( + mockMvc: MockMvc, + tournamentId: Long, + inviteCode: String, + ): UUID { + val response = + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/join/guest") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"inviteCode":"$inviteCode","nickname":"한도게스트"}"""), + ).andExpect(status().isCreated) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val userId = + objectMapper + .readTree(response) + .path("data") + .path("userId") + .asString() + return UUID.fromString(userId) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt new file mode 100644 index 00000000..7346878c --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt @@ -0,0 +1,40 @@ +package com.depromeet.piki.common.ratelimit + +import org.junit.jupiter.api.Test +import java.time.Duration +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ItemQuotaPropertiesTest { + @Test + fun `축마다 다른 한도를 돌려준다`() { + val properties = ItemQuotaProperties(wishLimit = 10, tournamentLimit = 30) + + assertEquals(10, properties.limitOf(ItemQuotaScope.WISH)) + assertEquals(30, properties.limitOf(ItemQuotaScope.TOURNAMENT)) + } + + @Test + fun `창 길이가 0 이면 부팅에서 실패한다`() { + // 0 이면 첫 차감의 PEXPIRE 가 키를 즉시 지워 한도가 사실상 무제한이 된다 — 조용히 무력화되지 않게 부팅에서 막는다. + assertFailsWith { ItemQuotaProperties(window = Duration.ZERO) } + } + + @Test + fun `창 길이가 음수면 부팅에서 실패한다`() { + assertFailsWith { ItemQuotaProperties(window = Duration.ofSeconds(-1)) } + } + + @Test + fun `위시 한도가 0 이하면 부팅에서 실패한다`() { + // 0 은 "무제한" 이 아니라 "전부 거부" 다. 오타로 등록 기능이 통째로 막히는 것을 부팅에서 드러낸다. + assertFailsWith { ItemQuotaProperties(wishLimit = 0) } + assertFailsWith { ItemQuotaProperties(wishLimit = -1) } + } + + @Test + fun `토너먼트 한도가 0 이하면 부팅에서 실패한다`() { + assertFailsWith { ItemQuotaProperties(tournamentLimit = 0) } + assertFailsWith { ItemQuotaProperties(tournamentLimit = -1) } + } +} diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt new file mode 100644 index 00000000..8106fb24 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt @@ -0,0 +1,107 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.support.IntegrationTestSupport +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.redis.core.StringRedisTemplate +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +// 카운터 산술의 경계 검증. 진입점 계약(429 응답 모양·차감 귀속)은 ItemQuotaIntegrationTest 가 맡고, +// 여기서는 "한도 경계에서 정확히 어떻게 갈리고 창 TTL 이 어떻게 걸리는가" 만 본다. +// +// Redis 가 필요해 통합으로 두지만 DB 는 쓰지 않으므로 @Transactional 도 두지 않는다. +// 격리는 매 테스트의 새 UUID 키로 한다(Redis 는 트랜잭션 롤백 대상이 아니다). +class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var store: RedisItemQuotaStore + + @Autowired + private lateinit var redisTemplate: StringRedisTemplate + + @Test + fun `한도까지는 허용하고 한도를 넘기는 순간 거부한다`() { + val key = newKey() + + // 경계 바로 아래·정확히 경계까지는 통과한다. + assertIs(store.tryConsume(key, amount = 7, limit = 10, windowMillis = WINDOW_MILLIS)) + assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) + // 10/10 을 쓴 상태에서 1 을 더하면 11 이라 거부다. + assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) + } + + @Test + fun `거부된 차감은 카운터를 올리지 않는다`() { + val key = newKey() + store.tryConsume(key, amount = 9, limit = 10, windowMillis = WINDOW_MILLIS) + + // 넘치는 요청을 여러 번 반복해도 누적되지 않는다 — 누적하면 창이 끝나도 한도를 넘긴 채 시작해 사실상 영구 차단된다. + repeat(3) { + val verdict = store.tryConsume(key, amount = 5, limit = 10, windowMillis = WINDOW_MILLIS) + assertIs(verdict) + } + + assertEquals("9", redisTemplate.opsForValue().get(key)) + // 거부 뒤에도 남은 1 은 그대로 쓸 수 있다. + assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) + } + + @Test + fun `all-or-nothing 이라 남은 몫보다 큰 요청은 부분 차감 없이 전부 거부된다`() { + val key = newKey() + store.tryConsume(key, amount = 8, limit = 10, windowMillis = WINDOW_MILLIS) + + // 남은 몫 2 에 3 을 요청 — 2 만 통과시키는 부분 성공은 등록 API 계약에 없다. + assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) + + assertEquals("8", redisTemplate.opsForValue().get(key)) + } + + @Test + fun `첫 차감이 창 TTL 을 걸고 이후 차감은 그 창을 연장하지 않는다`() { + val key = newKey() + + store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) + val firstTtl = requireNotNull(redisTemplate.getExpire(key, TimeUnit.MILLISECONDS)) + assertTrue(firstTtl in 1..WINDOW_MILLIS, "첫 차감이 창 TTL 을 걸어야 한다: $firstTtl") + + store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) + val secondTtl = requireNotNull(redisTemplate.getExpire(key, TimeUnit.MILLISECONDS)) + // 고정 윈도우라 창은 첫 차감 시점부터 한 번만 흐른다. 차감마다 갱신하면 계속 쓰는 사용자의 창이 영영 안 끝난다. + assertTrue(secondTtl <= firstTtl, "이후 차감이 창을 연장하면 안 된다: first=$firstTtl second=$secondTtl") + } + + @Test + fun `거부 응답의 재시도 시간은 남은 창 안에서 최소 1초 이상이다`() { + val key = newKey() + store.tryConsume(key, amount = 10, limit = 10, windowMillis = WINDOW_MILLIS) + + val verdict = store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) + + val exceeded = assertIs(verdict) + // 0 을 주면 클라가 즉시 재시도해 또 거부되므로 최소 1초를 보장한다. 상한은 창 길이다. + assertTrue( + exceeded.retryAfterSeconds in 1..(WINDOW_MILLIS / 1000), + "재시도 시간이 1초 이상 창 이하여야 한다: ${exceeded.retryAfterSeconds}", + ) + } + + @Test + fun `차감량이 0 이하면 코드 버그로 즉시 실패한다`() { + // 0 건 등록은 진입점 검증(이미지 개수 1~5)이 먼저 거르므로 여기 닿으면 호출부 버그다. + assertFailsWith { + store.tryConsume(newKey(), amount = 0, limit = 10, windowMillis = WINDOW_MILLIS) + } + } + + // 매 테스트가 자기 키를 쓴다 — Redis 는 트랜잭션 롤백이 없으므로 격리를 키 이름으로 만든다. + private fun newKey(): String = "quota:item:test:${UUID.randomUUID()}" + + companion object { + private const val WINDOW_MILLIS = 60_000L + } +} diff --git a/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt index 21c340aa..5f65dedd 100644 --- a/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt @@ -227,7 +227,7 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { fun `폴링이 업로드된 토너먼트 pending 을 등록해 아이템이 생긴다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { seedExtractor() @@ -248,7 +248,7 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { fun `정원이 부족하면 폴링도 confirm 처럼 배치 전량 거부하고 부분 등록하지 않는다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { seedExtractor() @@ -274,7 +274,7 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { fun `업로드된 채 만료됐으나 정원 초과면 폴링이 폐기하고 부분 등록하지 않는다`() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() - insertGuest(ownerId) + insertMember(ownerId) var tournamentId = 0L try { seedExtractor() @@ -349,7 +349,8 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { .perform( post("/api/v1/tournaments") .contentType(MediaType.APPLICATION_JSON) - .header(HttpHeaders.AUTHORIZATION, "Bearer ${guestToken(ownerId)}") + // 토너먼트 생성은 회원 전용(#339). 이 테스트의 관심사는 폴링 백스톱이지 게스트 권한이 아니다. + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(ownerId)}") .content("""{"name":"폴링토너먼트"}"""), ).andReturn() .response @@ -369,8 +370,6 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { private fun insertMember(userId: UUID) = insertUser(userId, "MEMBER") - private fun insertGuest(userId: UUID) = insertUser(userId, "GUEST") - private fun insertUser( userId: UUID, identityType: String, @@ -383,7 +382,7 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { ) } - private fun guestToken(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.GUEST) + private fun memberToken(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.MEMBER) private fun wishCount(userId: UUID): Int = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wishes WHERE user_id = ?", Int::class.java, uuidToBytes(userId)) ?: 0 From 2d0c99521bd8c933389c7fe697abd835d89cf4f3 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: Mon, 10 Aug 2026 05:30:52 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=95=9C=EB=8F=84=EC=9D=98=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9D=84=20LLM=20=EC=97=90=EC=84=9C=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EB=B9=84=EC=9A=A9=20=EC=A0=84=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구현은 처음부터 "큐에 넣는 item 수" 를 세어 LLM 여부와 무관했는데, 주석과 설정 문서만 LLM 중심으로 쓰여 있어 기준이 좁게 읽혔다. 코드 동작 변경 없이 문서만 실제 기준에 맞춘다 - 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도 fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를 소모한다. 프록시는 사용량 과금이라 LLM 과 별개로 돈이 나간다 - 그래서 경로별 차등을 두지 않는다. 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고, 사이트가 마크업을 바꾸면 어제 파서로 풀리던 링크가 오늘 LLM 을 탄다 - 실제 소비량에 맞춘 사후 정산은 후속 과제로 남긴다는 점을 주석에 명시 --- .../piki/common/ratelimit/ItemQuotaProperties.kt | 11 +++++++++-- .../piki/tournament/service/TournamentItemService.kt | 2 +- .../piki/wishlist/service/WishlistService.kt | 4 ++-- src/main/resources/application.yml | 6 +++++- 4 files changed, 17 insertions(+), 6 deletions(-) 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 26ba39b0..261d9f4c 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -5,8 +5,15 @@ import java.time.Duration // 아이템 등록 한도(#339) 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다. // -// 세는 단위는 요청 수가 아니라 **큐에 넣는 item 수**다 — 이미지 등록은 한 요청이 최대 5장이고 장당 LLM 호출이 -// 1회씩 붙으므로, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 호출량이 5배까지 벌어진다. +// 세는 단위는 요청 수가 아니라 **큐에 넣는 item 수**다 — 이미지 등록은 한 요청이 최대 5장이고 장마다 추출이 +// 따로 돌므로, 요청 수로 세면 링크 1건과 이미지 5장이 같은 비용으로 취급돼 실제 소비가 5배까지 벌어진다. +// +// 기준은 "LLM 을 타는가" 가 아니라 **"외부에 돈이 나가는가"** 다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도 +// fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를 +// 소모한다. 그래서 경로별 차등 없이 균일하게 1 을 센다 — 애초에 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고, +// 사이트가 마크업을 바꾸면 어제 파서로 풀리던 링크가 오늘 LLM 을 탄다. +// +// 실제 소비량에 맞춘 정밀 차감(LLM 을 탔는지·프록시 IP 를 몇 번 돌렸는지를 파싱 후에 세는 사후 정산)은 후속 과제다. // // 창은 고정 윈도우(fixed window)다. 첫 차감 시점부터 window 동안이 한 창이고 TTL 만료로 리셋된다. // 창 경계에서 최대 2배 버스트가 가능하지만(창 끝 + 다음 창 시작), 목적이 "한 계정이 시간당 대략 N개"라 diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index ed66465e..21b8d4d1 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -77,7 +77,7 @@ class TournamentItemService( tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } - // 장당 OCR 이 1회씩 붙으므로 장수만큼 오너 몫에서 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. + // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 오너 몫에서 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. itemQuotaGuard.consume( ItemQuotaScope.TOURNAMENT, ownerIdOf(tournamentId), diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index 9d4efc61..9e406db9 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -68,7 +68,7 @@ class WishlistService( // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. extractionRoutingPolicy.verifyRegistrable(link) // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. - // 링크 1건은 파싱이 파서로 풀리면 LLM 을 안 탈 수도 있지만, 등록 시점엔 알 수 없으므로 1 로 센다. + // 파서로 풀려 LLM 을 안 타도 fetch·프록시·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.persist(userId, Item(link)) } @@ -85,7 +85,7 @@ class WishlistService( if (images.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } - // 장당 OCR 이 1회씩 붙으므로 장수만큼 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. + // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. itemQuotaGuard.consume(ItemQuotaScope.WISH, userId, images.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. val imageKeys = productImages.map { uploadRaw(it) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebc39167..745a820f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -160,8 +160,12 @@ s3: region: ${S3_REGION:ap-northeast-2} public-base-url: ${S3_PUBLIC_BASE_URL:} -# 아이템 등록 한도(#339) — LLM 을 태우는 등록 경로에만 걸리는 계정 단위 quota. 세는 단위는 요청 수가 아니라 +# 아이템 등록 한도(#339) — 외부 비용이 드는 등록 경로에 걸리는 계정 단위 quota. 세는 단위는 요청 수가 아니라 # 큐에 넣는 item 수다(이미지 5장 = 5). 값은 env 로 재정의해 배포 없이 조일 수 있다. +# +# "LLM 을 타는가" 가 아니라 "외부에 돈이 나가는가" 가 기준이다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타더라도 +# fetch 대역, residential proxy 요청(HEADLESS_FIRST 사이트), 헤드리스 렌더러 시간, 이미지 저장, DB 행 영구 증가를 +# 소모한다. 그래서 LLM 여부로 차등하지 않고 등록 1건을 균일하게 센다 — 등록 시점엔 어느 경로로 풀릴지 알 수도 없다. item-quota: # 한도가 잘못 잡혀 정상 사용자를 막을 때 되돌리는 스위치. 끄면 차감·판정을 통째로 건너뛴다. enabled: ${ITEM_QUOTA_ENABLED:true} From b1d564494a7e3c5c4dd2e75aa3f053bde300beb1 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: Mon, 10 Aug 2026 06:32:20 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=95=9C=EB=8F=84=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=9A=94=EC=B2=AD=EB=9F=89=20=EB=B9=84=EA=B5=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9E=94=EC=95=A1=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판정을 "누적 + 요청량 > 한도" 에서 "누적 >= 한도" 로 바꾼다. 요청량은 이제 판정에 쓰지 않고 차감에만 쓴다 - 남은 몫이 2 인데 이미지 5장을 요청하면 기존에는 전량 거부였다. 사용자는 자기 잔액을 모르니 왜 막혔는지 알 수 없고, 몇 장으로 줄여야 통과하는지 안내할 방법도 없었다. 잔액 방식은 마지막 한 번이 항상 성공하고 그 다음부터 막혀, "이번 창의 몫을 다 썼다" 는 경계가 사용자에게 명확해진다 - 부분 성공 계약을 고민할 필요가 사라진다. 요청은 통째로 통과하거나 통째로 거부된다 - 대가로 누적이 한도를 넘어 잔액이 음수가 될 수 있다. 다만 창당 최대 소비가 (한도 + 1회 최대 요청량)으로 바운드되어 무한 초과가 아니다. 한도 10 · 이미지 5장 기준 최악 -4 이고, 그 뒤로는 전부 거부된다 - 후속 과제(#910)의 사후 정산도 같은 구조에 얹힌다. 파싱 후 확정된 실제 소비를 음수 쪽에 더하면 되고 판정 로직은 그대로다 --- .../common/ratelimit/ItemQuotaProperties.kt | 3 ++ .../common/ratelimit/RedisItemQuotaStore.kt | 23 ++++++++--- .../controller/TournamentItemApi.kt | 1 + .../piki/wishlist/controller/WishlistApi.kt | 3 +- .../ratelimit/ItemQuotaIntegrationTest.kt | 37 ++++++++++++++---- .../ItemQuotaStoreIntegrationTest.kt | 38 ++++++++++--------- 6 files changed, 73 insertions(+), 32 deletions(-) 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 261d9f4c..6759acc9 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -15,6 +15,9 @@ import java.time.Duration // // 실제 소비량에 맞춘 정밀 차감(LLM 을 탔는지·프록시 IP 를 몇 번 돌렸는지를 파싱 후에 세는 사후 정산)은 후속 과제다. // +// 판정은 잔액 방식이다 — 남은 몫이 있으면 요청 크기와 무관하게 통과시키고, 넘긴 만큼은 다음 요청이 갚는다. +// 그래서 창당 실제 소비는 한도가 아니라 (한도 + 1회 최대 요청량)까지 갈 수 있다(RedisItemQuotaStore 주석 참고). +// // 창은 고정 윈도우(fixed window)다. 첫 차감 시점부터 window 동안이 한 창이고 TTL 만료로 리셋된다. // 창 경계에서 최대 2배 버스트가 가능하지만(창 끝 + 다음 창 시작), 목적이 "한 계정이 시간당 대략 N개"라 // 그 정도 오차는 비용 방어에 영향을 주지 않는다. 정확한 평활화가 필요해지면 sliding window 로 올린다. diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt index 794f4662..5b1d859e 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/RedisItemQuotaStore.kt @@ -15,8 +15,18 @@ import kotlin.math.max class RedisItemQuotaStore( private val redisTemplate: StringRedisTemplate, ) { - // all-or-nothing 차감. 이미지 5장 요청이 한도 3 만 남은 상태에서 3장만 통과하고 2장이 잘리면 클라이언트가 - // "일부만 등록됨" 을 다뤄야 하는데, 등록 API 는 그런 부분 성공 계약이 없다. 그래서 전부 되거나 전부 거부다. + // 잔액 방식 — **요청 크기는 판정에 쓰지 않는다.** 창에 남은 몫이 있으면(누적 < 한도) 요청 크기와 무관하게 + // 통과시키고 쓴 만큼 그대로 더한다. 그래서 누적이 한도를 넘어 "빚"(잔액 음수)이 될 수 있고, 다음 요청부터 거부된다. + // + // 요청 크기를 판정에 넣던 방식(누적 + 요청량 > 한도면 전량 거부)을 버린 이유: + // - 남은 몫 2 에 이미지 5장을 요청하면 거부되는데, 사용자는 자기 잔액을 모르니 왜 막혔는지 알 수 없고 + // 몇 장으로 줄여야 통과하는지도 안내할 방법이 없다. 잔액 방식은 **마지막 한 번이 항상 성공**하고, + // 막히는 것은 그 다음부터라 "이번 창의 몫을 다 썼다" 는 경계가 사용자에게 명확해진다. + // - 부분 성공 계약을 고민할 필요가 사라진다(요청은 통째로 통과하거나 통째로 거부된다). + // + // 초과 노출은 유한하다: 창당 최대 소비는 (한도 + 1회 최대 요청량)으로 바운드된다. 잔액 1 에서 5장이 들어와도 + // -4 가 최악이고, 그 뒤로는 전부 거부되기 때문이다. 무한 초과가 아니라 계산 가능한 상한이라 받아들일 수 있다. + // (파싱 후 실제 소비를 정산하는 후속 과제 #910 이 붙으면 그 정산분도 같은 방식으로 음수에 얹힌다.) fun tryConsume( key: String, amount: Int, @@ -50,12 +60,15 @@ class RedisItemQuotaStore( private const val MILLIS_PER_SECOND = 1_000L // 판정과 차감을 한 스크립트로 원자화한다. GET → 비교 → INCRBY 를 앱에서 나눠 하면 동시 요청이 각자 - // 통과 판정을 받아 한도를 넘겨 차감할 수 있다(check-then-act race). Redis 싱글스레드 직렬화가 그걸 막는다. + // 통과 판정을 받아 잔액을 예상보다 깊게 파고들 수 있다(check-then-act race). Redis 싱글스레드 직렬화가 그걸 막는다. // // KEYS[1]=카운터 키, ARGV[1]=차감량, ARGV[2]=한도, ARGV[3]=창 길이(ms) // + // 판정은 `current >= limit` 하나다 — **요청량(amount)은 판정에 쓰지 않고 차감에만 쓴다.** 남은 몫이 + // 있으면 크기와 무관하게 들여보내고, 넘긴 만큼은 다음 요청이 갚는다(위 잔액 방식 주석). + // // 반환: - // "A:<누적>" 허용 — 차감 후 누적값 + // "A:<누적>" 허용 — 차감 후 누적값 (한도를 넘겼을 수 있다) // "X:<남은 ms>" 거부 — 차감하지 않음. 창이 리셋되기까지 남은 시간 // // 거부 시 INCRBY 를 하지 않는 것이 중요하다. 거부분까지 누적하면 한도에 걸린 사용자가 재시도할 때마다 @@ -70,7 +83,7 @@ class RedisItemQuotaStore( local amount = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local current = tonumber(redis.call('GET', KEYS[1]) or '0') - if current + amount > limit then + if current >= limit then local ttl = redis.call('PTTL', KEYS[1]) if ttl < 0 then ttl = 0 end return 'X:' .. ttl diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index d86cd891..17d023e0 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -28,6 +28,7 @@ import java.util.UUID private const val TOURNAMENT_RATE_LIMIT_DESCRIPTION = "아이템 등록 한도 초과 (code: TOURNAMENT-037). 한도는 요청자가 아니라 토너먼트 오너의 몫에서 차감되므로 " + "참여자가 처음 담는 경우에도 받을 수 있다. 요청 수가 아니라 등록하는 item 수로 세며(이미지 5장 = 5), " + + "남은 몫이 있으면 그보다 큰 요청도 통과하므로 이 응답은 몫을 이미 다 쓴 뒤부터 나온다. " + "Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." @Tag(name = "Tournament Item", description = "토너먼트 아이템 API") diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index d00cfb1a..3e28a6e4 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -23,7 +23,8 @@ import java.util.UUID // 세는 단위가 요청 수가 아니라 item 수라는 점이 클라이언트 계약의 핵심이라 명시한다. private const val RATE_LIMIT_DESCRIPTION = "아이템 등록 한도 초과 (code: WISH-010). 한도는 요청 수가 아니라 등록하는 item 수로 센다 — " + - "이미지 5장 등록은 5 를 소모한다. Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." + "이미지 5장 등록은 5 를 소모한다. 남은 몫이 있으면 그보다 큰 요청도 통과하므로(마지막 한 번은 성공) " + + "이 응답은 몫을 이미 다 쓴 뒤부터 나온다. Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." @Tag(name = "Wishlist", description = "위시리스트 등록/조회/복구/삭제 API") interface WishlistApi { 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 92cebbbe..83b07074 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -29,7 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -// 아이템 등록 한도(#339)의 계약 검증. 한도 자체의 산술(창 경계·all-or-nothing)은 RedisItemQuotaStore 쪽 검증이 +// 아이템 등록 한도(#339)의 계약 검증. 한도 자체의 산술(창 경계·잔액 판정)은 RedisItemQuotaStore 쪽 검증이 // 맡고, 여기서는 "진입점에서 무엇이 얼마나 차감되고 넘치면 어떤 응답이 나가는가" 라는 계약만 본다. // // DB 는 클래스 레벨 @Transactional 로 롤백되지만 **Redis 는 롤백되지 않는다.** 그래서 매 테스트가 새 UUID 를 @@ -91,8 +91,6 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertUser(userId, IdentityType.MEMBER) - // 남은 몫을 2 로 만든다 — 3장은 넘치고 2장은 통과해야 한다. - fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit - 2) mockMvc .perform( @@ -100,19 +98,42 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") .contentType(MediaType.APPLICATION_JSON) .content("""{"contentTypes":["image/png","image/png","image/png"]}"""), - ).andExpect(status().isTooManyRequests) - .andExpect(jsonPath("$.code").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.code)) + ).andExpect(status().isOk) + + // 요청 1건이 아니라 3 이 빠져야 한다 — 장마다 추출이 따로 돌기 때문이다. + assertEquals(3L, currentCount(ItemQuotaScope.WISH, userId)) + } + + @Test + fun `잔액이 남아 있으면 그보다 큰 요청도 통과시키고 그 다음부터 거부한다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + // 잔액을 1 만 남긴다 — 5장 요청은 그보다 크다. + fillQuota(ItemQuotaScope.WISH, userId, properties.wishLimit - 1) - // all-or-nothing — 3장이 거부됐어도 부분 차감이 없어 2장은 그대로 통과한다. + // 요청량은 판정에 쓰지 않으므로 통째로 통과한다. "2장만 남아서 안 됩니다" 로 막으면 사용자는 자기 잔액을 + // 모르는 채 몇 장으로 줄여야 할지도 알 수 없다 — 마지막 한 번은 성공시키고 그 다음부터 막는다. mockMvc .perform( post("/api/v1/wishlists/images/presigned") .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") .contentType(MediaType.APPLICATION_JSON) - .content("""{"contentTypes":["image/png","image/png"]}"""), + .content("""{"contentTypes":["image/png","image/png","image/png","image/png","image/png"]}"""), ).andExpect(status().isOk) - assertEquals(properties.wishLimit.toLong(), currentCount(ItemQuotaScope.WISH, userId)) + // 한도를 넘겨 잔액이 음수가 됐다. + assertEquals((properties.wishLimit + 4).toLong(), currentCount(ItemQuotaScope.WISH, userId)) + + // 이제부터는 크기와 무관하게 거부다. + 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/9"}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(jsonPath("$.code").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.code)) } @Test diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt index 8106fb24..2c20c15f 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaStoreIntegrationTest.kt @@ -24,41 +24,42 @@ class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { private lateinit var redisTemplate: StringRedisTemplate @Test - fun `한도까지는 허용하고 한도를 넘기는 순간 거부한다`() { + fun `잔액이 남아 있으면 허용하고 한도에 닿은 뒤부터 거부한다`() { val key = newKey() - // 경계 바로 아래·정확히 경계까지는 통과한다. assertIs(store.tryConsume(key, amount = 7, limit = 10, windowMillis = WINDOW_MILLIS)) + // 누적 7 < 10 이라 아직 잔액이 있다. 이 요청이 정확히 한도를 채운다. assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) - // 10/10 을 쓴 상태에서 1 을 더하면 11 이라 거부다. + // 누적 10 >= 10 — 잔액이 0 이므로 이제부터 거부다. assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) } @Test - fun `거부된 차감은 카운터를 올리지 않는다`() { + fun `잔액보다 큰 요청도 통과시키고 누적이 한도를 넘어 음수 잔액이 된다`() { val key = newKey() - store.tryConsume(key, amount = 9, limit = 10, windowMillis = WINDOW_MILLIS) + store.tryConsume(key, amount = 8, limit = 10, windowMillis = WINDOW_MILLIS) - // 넘치는 요청을 여러 번 반복해도 누적되지 않는다 — 누적하면 창이 끝나도 한도를 넘긴 채 시작해 사실상 영구 차단된다. - repeat(3) { - val verdict = store.tryConsume(key, amount = 5, limit = 10, windowMillis = WINDOW_MILLIS) - assertIs(verdict) - } + // 남은 몫은 2 뿐이지만 요청량은 판정에 쓰지 않으므로 3 이 통째로 통과한다. + // 사용자 입장에서 "마지막 한 번은 항상 성공" 이고, 넘긴 만큼은 다음 요청이 갚는다. + assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) + assertEquals("11", redisTemplate.opsForValue().get(key)) - assertEquals("9", redisTemplate.opsForValue().get(key)) - // 거부 뒤에도 남은 1 은 그대로 쓸 수 있다. - assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) + // 잔액이 음수(-1)라 다음 요청은 크기와 무관하게 거부된다. + assertIs(store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS)) } @Test - fun `all-or-nothing 이라 남은 몫보다 큰 요청은 부분 차감 없이 전부 거부된다`() { + fun `거부된 차감은 카운터를 올리지 않는다`() { val key = newKey() - store.tryConsume(key, amount = 8, limit = 10, windowMillis = WINDOW_MILLIS) + store.tryConsume(key, amount = 10, limit = 10, windowMillis = WINDOW_MILLIS) - // 남은 몫 2 에 3 을 요청 — 2 만 통과시키는 부분 성공은 등록 API 계약에 없다. - assertIs(store.tryConsume(key, amount = 3, limit = 10, windowMillis = WINDOW_MILLIS)) + // 넘치는 요청을 여러 번 반복해도 누적되지 않는다 — 누적하면 창이 끝나도 한도를 넘긴 채 시작해 사실상 영구 차단된다. + repeat(3) { + val verdict = store.tryConsume(key, amount = 5, limit = 10, windowMillis = WINDOW_MILLIS) + assertIs(verdict) + } - assertEquals("8", redisTemplate.opsForValue().get(key)) + assertEquals("10", redisTemplate.opsForValue().get(key)) } @Test @@ -78,6 +79,7 @@ class ItemQuotaStoreIntegrationTest : IntegrationTestSupport() { @Test fun `거부 응답의 재시도 시간은 남은 창 안에서 최소 1초 이상이다`() { val key = newKey() + // 잔액을 0 으로 만들어 다음 요청이 거부되게 한다. store.tryConsume(key, amount = 10, limit = 10, windowMillis = WINDOW_MILLIS) val verdict = store.tryConsume(key, amount = 1, limit = 10, windowMillis = WINDOW_MILLIS) From 6eb085b694bc49e383ffe70a99cda7d343a02589 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: Wed, 12 Aug 2026 17:48:41 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=95=9C=EB=8F=84=EC=9D=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B5=AC=EB=A9=8D=EA=B3=BC=20=EC=B0=A8=EA=B0=90=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A5=BC=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemQuotaException.exceeded 가 retryAfterSeconds 를 검증하지 않아 0·음수로도 만들어졌다. 지금 유일한 호출자(RedisItemQuotaStore)가 최소 1초를 보장하지만 그건 그쪽 사정이라, 어느 호출자가 오든 유효한 Retry-After 가 나가도록 팩토리 불변식으로 못박는다 - ItemQuotaGuard 의 fail-open 을 runCatching 에서 catch(Exception) 으로 바꾼다. runCatching 은 Throwable 을 잡아 OutOfMemoryError 같은 치명적 Error 까지 삼키는데, 그 상황에서 요청을 계속 받으면 장애를 키운다 - 창 길이 검증을 Duration 양수에서 toMillis() 양수로 바꾼다. Redis PEXPIRE 가 ms 단위라 0.5ms 같은 값은 양수여도 환산이 0 이 되어 창이 즉시 만료되고, 매 요청이 새 창을 열어 한도가 조용히 무제한이 된다 - 토너먼트 생성 게이트가 탈퇴(tombstone) 계정을 통과시켰다. anonymize 는 닉네임·프로필만 비우고 identityType 은 MEMBER 로 남기므로 identityType 만 보면 죽은 계정이 토너먼트를 만든다. 위시가 findActiveById 로 막는 것과 같은 사유(#691)라 deletedAt 도 함께 본다. 그에 따라 409 응답 문서화·example 도 복구 - 이미지 presign 경로가 content-type 검증 전에 차감하고 있었다. 지원하지 않는 MIME 을 보낸 요청이 몫을 깎고 400 을 받는 순서라, v1(ProductImage.of 로 먼저 거름)과 맞춰 검증을 차감 앞으로 당긴다 - 429 응답에 Retry-After 헤더를 OpenAPI 로 선언한다. 값은 이미 내려가고 있었지만 description 에만 적혀 있어 클라가 스펙으로 읽을 수 없었다 - 토너먼트 429 문구 테스트가 "토너먼트가 들어있다" 만 확인해 의도한 규칙("오너의 사용량을 드러내지 않는다")을 전혀 검증하지 못했다. 금지 단어 부재로 고정해, 문구를 "오너의 남은 사용량이 0이에요" 로 바꾸면 깨지게 한다 - 새로 건 require 3종(재시도 시점 양수·창 1ms 하한·탈퇴 계정 차단)에 검증 테스트를 함께 추가 - reject 1건: "refresh 는 새 PENDING snapshot 을 만들 때만 차감하라" 는 지적은 반영하지 않았다. 그 판정이 persistence 의 락 안에서 최종 확정되는데, 거기서 차감하면 Redis 호출이 트랜잭션 안으로 들어가 CLAUDE.md 의 "외부 호출은 트랜잭션 밖" 과 충돌한다. 멱등 재요청(이미 PROCESSING)에도 차감되는 문제는 실재하므로 사후 정산(#910)에서 함께 다룬다 --- .../common/ratelimit/ItemQuotaException.kt | 4 +++ .../piki/common/ratelimit/ItemQuotaGuard.kt | 7 +++-- .../common/ratelimit/ItemQuotaProperties.kt | 6 ++-- .../tournament/controller/TournamentApi.kt | 10 +++++++ .../controller/TournamentApiExamples.kt | 1 + .../controller/TournamentItemApi.kt | 22 ++++++++++++++ .../service/TournamentItemService.kt | 3 ++ .../tournament/service/TournamentService.kt | 5 ++++ .../piki/wishlist/controller/WishlistApi.kt | 29 +++++++++++++++++++ .../piki/wishlist/service/WishlistService.kt | 4 +++ .../ratelimit/ItemQuotaExceptionTest.kt | 12 ++++++++ .../ratelimit/ItemQuotaIntegrationTest.kt | 11 +++++-- .../ratelimit/ItemQuotaPropertiesTest.kt | 9 ++++++ .../controller/TournamentIntegrationTest.kt | 26 +++++++++++++++++ 14 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt index 8535ea86..55eead21 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaException.kt @@ -33,6 +33,10 @@ class ItemQuotaException private constructor( require(errorCode.category == ErrorCategory.TOO_MANY_REQUESTS) { "한도 초과 예외의 category 는 TOO_MANY_REQUESTS 여야 한다: ${errorCode.code} → ${errorCode.category}" } + // 0 이면 클라가 즉시 재시도해 또 거부되고, 음수는 Retry-After 로 나갈 수 없는 값이다. + // 현재 유일한 호출자(RedisItemQuotaStore)가 최소 1초를 보장하지만 그건 그쪽 사정이라, + // 이 팩토리로 만드는 예외는 어느 호출자가 오든 유효한 재시도 시점을 갖도록 여기서 못박는다. + require(retryAfterSeconds > 0) { "재시도 시점($retryAfterSeconds)은 양수여야 한다." } return ItemQuotaException(errorCode, retryAfterSeconds) } } 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 b36ca2d1..03406b8c 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaGuard.kt @@ -28,18 +28,21 @@ class ItemQuotaGuard( if (!properties.enabled) return val verdict = - runCatching { + try { store.tryConsume( key = scope.keyPrefix + ownerId, amount = amount, limit = properties.limitOf(scope), windowMillis = properties.window.toMillis(), ) - }.getOrElse { e -> + } catch (e: Exception) { // fail-open — Redis 장애로 등록 기능 전체가 멈추는 것보다, 한도가 잠시 안 걸리는 쪽이 낫다. // 이 선택의 위험(장애 창 동안 한도 없이 호출됨)은 제한적이다: Redis 가 죽으면 refresh 토큰 저장소도 // 함께 죽어 로그인 흐름이 이미 망가지므로, 그 창에서 대량 호출이 지속되기 어렵다. // 외부 의존성 실패라 warn (로그 레벨 정책). + // + // runCatching 이 아니라 catch(Exception) 인 이유: runCatching 은 Throwable 을 잡아 OutOfMemoryError + // 같은 치명적 Error 까지 삼킨다. 그런 상황에서 fail-open 으로 요청을 계속 받으면 장애를 키운다. log.warn("아이템 한도 검사 실패 — 통과시킨다(fail-open). scope={} ownerId={} amount={}", scope, ownerId, amount, e) return } 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 6759acc9..1802e9ac 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -36,8 +36,10 @@ data class ItemQuotaProperties( val tournamentLimit: Int = 30, ) { init { - require(!window.isZero && !window.isNegative) { - "item-quota.window($window)는 양수여야 한다 — 0 이면 창이 즉시 만료돼 한도가 무의미해진다." + // 밀리초로 환산해 검사한다 — Redis PEXPIRE 가 ms 단위라, 1ms 미만(예: 500us)은 양수여도 환산 결과가 0 이 되어 + // 창이 즉시 만료된다. 그러면 매 요청이 새 창을 열어 한도가 사실상 무제한이 되는데, 설정만 보면 정상으로 보인다. + require(window.toMillis() > 0) { + "item-quota.window($window)는 1ms 이상이어야 한다 — 그 미만은 창이 즉시 만료돼 한도가 무의미해진다." } require(wishLimit > 0) { "item-quota.wish-limit($wishLimit)은 양수여야 한다 — 0 이면 위시 등록이 통째로 막힌다." } require(tournamentLimit > 0) { diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index cbdc7894..4900e8e4 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -373,6 +373,16 @@ interface TournamentApi { ), ], ), + ApiResponse( + responseCode = "409", + description = "탈퇴한 계정 (JWT 는 아직 유효하나 계정이 탈퇴 상태) — code: USER-003", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), ], ) fun create( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index ae339b24..60709782 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -104,6 +104,7 @@ class TournamentApiExamples( ) unauthorized() add(TournamentException.guestCannotCreateTournament(), name = "게스트의 토너먼트 생성 거부 (회원 전용)") + add(UserException.deletedUser(), name = "탈퇴한 유저") } handlerMethod.binds(TournamentController::join) -> diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index c2f5b0cc..fe594f01 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -13,6 +13,7 @@ import com.depromeet.piki.tournament.controller.dto.TournamentItemDetailResponse import com.depromeet.piki.tournament.controller.dto.UpdateTournamentItemRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.headers.Header import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -258,6 +259,13 @@ interface TournamentItemApi { ApiResponse( responseCode = "429", description = TOURNAMENT_RATE_LIMIT_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -351,6 +359,13 @@ interface TournamentItemApi { ApiResponse( responseCode = "429", description = TOURNAMENT_RATE_LIMIT_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -452,6 +467,13 @@ interface TournamentItemApi { ApiResponse( responseCode = "429", description = "$TOURNAMENT_RATE_LIMIT_DESCRIPTION 발급 시점에 차감하므로 이어지는 confirm 은 추가로 소모하지 않는다.", + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 21b8d4d1..7e2b616f 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -107,6 +107,9 @@ class TournamentItemService( ): List { if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) + // content-type 검증을 차감 앞으로 당긴다 — 지원하지 않는 MIME 을 보낸 요청이 오너의 몫을 깎고 400 을 받지 않게 한다 + // (위시 presignImageUploads 와 같은 순서). + contentTypes.forEach { ProductImage.extensionForMimeType(it) } // 위시 v2 와 같은 이유로 발급 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 한도를 우회한다. confirm 은 차감하지 않는다(이중 차감 방지). itemQuotaGuard.consume( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index d9b3558f..63209460 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -39,6 +39,7 @@ import com.depromeet.piki.tournament.service.dto.StartResult import com.depromeet.piki.tournament.service.dto.TournamentStartResult import com.depromeet.piki.tournament.service.dto.TournamentSummary import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.wishlist.repository.WishRepository import org.springframework.context.ApplicationEventPublisher @@ -68,8 +69,12 @@ class TournamentService( // 기존 계약을 이 게이트가 404 로 바꾸지 않기 위해서다(FCM 토큰 등록의 rejectIfWithdrawnForUpdate 와 같은 결). // 게이트에 구멍을 내지 않는다: 게스트는 발급이 곧 users 행 생성이라(UserService.createGuest) 반드시 행이 있고, // 토큰은 우리가 서명하므로 "행 없는 유효 토큰" 은 정상 경로에서 만들어지지 않는다. + // 탈퇴(tombstone) 계정도 막는다 — anonymize 는 닉네임·프로필만 비우고 identityType 은 MEMBER 로 남기므로, + // identityType 만 보면 죽은 계정이 토너먼트를 만든다. 탈퇴 시 토큰 무효화가 부분 실패한 창에서 실제로 닿을 수 있다 + // (위시가 findActiveById 로 막는 것과 같은 사유, #691). private fun requireMember(userId: UUID) { val user = userRepository.findById(userId) ?: return + user.deletedAt?.let { throw UserException.deletedUser() } if (user.identityType != IdentityType.MEMBER) throw TournamentException.guestCannotCreateTournament() } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index ad3f6e14..9f85d9f5 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -10,6 +10,7 @@ import com.depromeet.piki.wishlist.controller.dto.WishlistRegisterRequest import com.depromeet.piki.wishlist.controller.dto.WishlistUpdateRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.headers.Header import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -103,6 +104,13 @@ interface WishlistApi { ApiResponse( responseCode = "429", description = RATE_LIMIT_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -443,6 +451,13 @@ interface WishlistApi { ApiResponse( responseCode = "429", description = RATE_LIMIT_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -651,6 +666,13 @@ interface WishlistApi { ApiResponse( responseCode = "429", description = RATE_LIMIT_DESCRIPTION, + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -741,6 +763,13 @@ interface WishlistApi { ApiResponse( responseCode = "429", description = "$RATE_LIMIT_DESCRIPTION 발급 시점에 차감하므로 이어지는 confirm 은 추가로 소모하지 않는다.", + headers = [ + Header( + name = "Retry-After", + description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", + schema = Schema(type = "integer", format = "int64"), + ), + ], content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index efb7e7f0..1c74eccc 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -105,6 +105,10 @@ class WishlistService( ): List { requireMember(userId) if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() + // content-type 검증을 차감 앞으로 당긴다 — presignRawUploads 안에서 걸러도 결과는 같지만, 그러면 지원하지 + // 않는 MIME 을 보낸 요청이 몫을 깎고 400 을 받는다. v1(registerFromImages)이 ProductImage.of 로 형식을 + // 먼저 거르는 것과 순서를 맞춘다. 같은 검증이 발급 시점에 한 번 더 도는 것은 부작용 없는 순수 함수라 무해하다. + contentTypes.forEach { ProductImage.extensionForMimeType(it) } // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). // 발급만 받고 업로드를 안 하면 그만큼 몫을 손해 보지만, 그건 클라이언트가 자기 요청을 버린 경우다. diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt index 205f312a..a1166ea5 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt @@ -29,6 +29,18 @@ class ItemQuotaExceptionTest { assertEquals(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message, exception.message) } + @Test + fun `재시도 시점이 0 이하면 코드 버그로 즉시 실패한다`() { + // 0 이면 클라가 즉시 재시도해 또 거부되고, 음수는 Retry-After 로 나갈 수 없는 값이다. + // 지금 유일한 호출자는 최소 1초를 보장하지만, 그건 그쪽 사정이라 팩토리가 자기 불변식으로 못박는다. + assertFailsWith { + ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 0) + } + assertFailsWith { + ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = -1) + } + } + @Test fun `429 가 아닌 code 로 만들면 코드 버그로 즉시 실패한다`() { // status 는 category 가 소유하므로, 429 아닌 code 를 넘기면 "한도 초과인데 409" 같은 응답이 조용히 나간다. 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 83b07074..14014071 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -26,8 +26,8 @@ import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull -import kotlin.test.assertTrue // 아이템 등록 한도(#339)의 계약 검증. 한도 자체의 산술(창 경계·잔액 판정)은 RedisItemQuotaStore 쪽 검증이 // 맡고, 여기서는 "진입점에서 무엇이 얼마나 차감되고 넘치면 어떤 응답이 나가는가" 라는 계약만 본다. @@ -211,11 +211,16 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .content("""{"url":"https://www.musinsa.com/products/4"}"""), ).andExpect(status().isTooManyRequests) .andExpect(jsonPath("$.code").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.code)) - // 응답 문구가 오너의 사용량을 드러내지 않는지 — 남의 사용량은 요청자에게 알릴 정보가 아니다. .andExpect(jsonPath("$.detail").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message)) .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) - assertTrue(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message.contains("토너먼트")) + // 이 응답은 참여 게스트도 받는다. 남의(오너의) 사용량은 요청자에게 알릴 정보가 아니므로 문구가 + // 그것을 드러내지 않는지 금지 단어 부재로 고정한다 — "토너먼트가 들어있다" 같은 단언은 이 규칙과 + // 무관해서, 문구를 "오너의 남은 사용량이 0이에요" 로 바꿔도 통과해버린다. + val message = TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message + listOf("오너", "소유자", "사용량", "남은").forEach { + assertFalse(message.contains(it), "429 문구가 오너의 사용량을 드러낸다: $message") + } } finally { stubItemParsingWorker.enabled = true } 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 7346878c..57ce8c10 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaPropertiesTest.kt @@ -25,6 +25,15 @@ class ItemQuotaPropertiesTest { assertFailsWith { ItemQuotaProperties(window = Duration.ofSeconds(-1)) } } + @Test + fun `창 길이가 1ms 미만이면 양수여도 부팅에서 실패한다`() { + // Redis PEXPIRE 가 ms 단위라 0.5ms 는 환산 결과가 0 이 되어 창이 즉시 만료된다. 그러면 매 요청이 새 창을 + // 열어 한도가 사실상 무제한이 되는데, 설정값만 보면 "양수니까 정상" 으로 보여 조용히 무력화된다. + assertFailsWith { ItemQuotaProperties(window = Duration.ofNanos(500_000)) } + // 경계값 1ms 는 통과해야 한다. + assertEquals(1, ItemQuotaProperties(window = Duration.ofMillis(1)).window.toMillis()) + } + @Test fun `위시 한도가 0 이하면 부팅에서 실패한다`() { // 0 은 "무제한" 이 아니라 "전부 거부" 다. 오타로 등록 기능이 통째로 막히는 것을 부팅에서 드러낸다. diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 9cecb91d..f31d26f6 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -154,6 +154,32 @@ class TournamentIntegrationTest : IntegrationTestSupport() { assertEquals(tournamentsBefore, tournamentJpaRepository.count()) } + @Test + fun `POST tournaments 는 탈퇴한 회원이 요청하면 409 를 반환한다`() { + // 탈퇴 tombstone 은 닉네임·프로필만 비우고 identityType 은 MEMBER 로 남는다. identityType 만 보면 + // 죽은 계정이 토너먼트를 만들 수 있어, 탈퇴 시 토큰 무효화가 부분 실패한 창에서 실제로 닿는다. + val mockMvc = buildMockMvc() + val withdrawnId = UUID.randomUUID() + val user = + userJpaRepository.save( + // "탈퇴" 로 시작하는 닉네임은 tombstone 예약 접두어라 입력 경계가 막는다 — 다른 이름을 쓴다. + User(id = withdrawnId, nickname = "떠날회원", profileImage = "https://cdn.example.com/w.jpg", identityType = IdentityType.MEMBER), + ) + user.softDelete() + userJpaRepository.save(user) + val tournamentsBefore = tournamentJpaRepository.count() + + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, authHeader(withdrawnId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"탈퇴 토너먼트"}"""), + ).andExpect(status().isConflict) + + assertEquals(tournamentsBefore, tournamentJpaRepository.count()) + } + @Test fun `POST tournaments 에서 inviteDurationMinutes 를 지정하면 해당 시간으로 만료 시각이 설정된다`() { val mockMvc = buildMockMvc() From b7dd384371ca4b425e62e01c3559f2d18c445343 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: Wed, 12 Aug 2026 17:56:25 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20presi?= =?UTF-8?q?gn=C2=B7confirm=20=EC=9D=B4=EC=A4=91=20=EC=B0=A8=EA=B0=90=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - presign 에서 차감하고 confirm 은 0 이라는 계약이 코드 주석에만 있고 테스트로 고정돼 있지 않았다. CodeRabbit nitpick 지적대로 성공 경로(발급 2장 → confirm 201)를 태워 카운터가 2에서 안 움직이는지 단언한다 - 공유 stub 특성상 exists 동작을 이 테스트가 명시 세팅한다 (다른 테스트가 false 로 바꿔둔 상태를 물려받지 않게) - 카운터 값을 담은 변수명이 retryAfter 로 잘못돼 있던 것을 정정하고, 폴링 테스트의 신분 리터럴을 IdentityType.MEMBER.name 으로 바꾼다 --- .../ratelimit/ItemQuotaIntegrationTest.kt | 51 ++++++++++++++++++- .../PendingUploadPollingIntegrationTest.kt | 2 +- 2 files changed, 50 insertions(+), 3 deletions(-) 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 14014071..34beb8c4 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -2,6 +2,8 @@ package com.depromeet.piki.common.ratelimit import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.StubImageParsingWorker +import com.depromeet.piki.support.StubImageStorage import com.depromeet.piki.support.StubItemParsingWorker import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.service.TournamentErrorCode @@ -60,6 +62,12 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var stubItemParsingWorker: StubItemParsingWorker + @Autowired + private lateinit var stubImageParsingWorker: StubImageParsingWorker + + @Autowired + private lateinit var stubImageStorage: StubImageStorage + @Test fun `위시 링크 등록이 한도를 넘으면 429 와 WISH-010 code, Retry-After 헤더를 반환한다`() { val mockMvc = buildMockMvc() @@ -81,9 +89,8 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { // 남은 시간은 창 길이에 따라 달라지므로 값이 아니라 "양수가 실렸다" 를 계약으로 고정한다. .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) - val retryAfter = requireNotNull(currentCount(ItemQuotaScope.WISH, userId)) // 거부된 요청은 카운터를 올리지 않는다 — 올리면 재시도할수록 창이 끝나도 한도를 넘긴 채 시작한다. - assertEquals(properties.wishLimit.toLong(), retryAfter) + assertEquals(properties.wishLimit.toLong(), currentCount(ItemQuotaScope.WISH, userId)) } @Test @@ -104,6 +111,46 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { assertEquals(3L, currentCount(ItemQuotaScope.WISH, userId)) } + @Test + fun `이미지 등록은 presign 에서만 차감하고 confirm 은 추가로 차감하지 않는다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertUser(userId, IdentityType.MEMBER) + stubImageParsingWorker.enabled = false + // 공유 stub 이라 이 테스트가 쓰는 동작을 명시 세팅한다 — "업로드가 끝났다"(exists=true)가 confirm 의 전제다. + stubImageStorage.existsBehavior = stubImageStorage.defaultExistsBehavior + + try { + val response = + mockMvc + .perform( + post("/api/v1/wishlists/images/presigned") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentTypes":["image/png","image/jpeg"]}"""), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val uploads = objectMapper.readTree(response).path("data").path("uploads") + val keys = listOf(uploads.path(0).path("imageKey").asString(), uploads.path(1).path("imageKey").asString()) + assertEquals(2L, currentCount(ItemQuotaScope.WISH, userId)) + + mockMvc + .perform( + post("/api/v1/wishlists/images/confirm") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("imageKeys" to keys))), + ).andExpect(status().isCreated) + + // 발급 시점에 이미 깎았으므로 확정은 0 이다. 여기서 또 깎으면 이미지 한 장이 두 번 세어진다. + assertEquals(2L, currentCount(ItemQuotaScope.WISH, userId)) + } finally { + stubImageParsingWorker.enabled = true + } + } + @Test fun `잔액이 남아 있으면 그보다 큰 요청도 통과시키고 그 다음부터 거부한다`() { val mockMvc = buildMockMvc() diff --git a/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt index 5f65dedd..7076ee58 100644 --- a/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/image/service/PendingUploadPollingIntegrationTest.kt @@ -368,7 +368,7 @@ class PendingUploadPollingIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - private fun insertMember(userId: UUID) = insertUser(userId, "MEMBER") + private fun insertMember(userId: UUID) = insertUser(userId, IdentityType.MEMBER.name) private fun insertUser( userId: UUID,