From 23036f94d2b9486f313459d6b6b8d0b3ab648056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:56:39 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EC=9C=84=EC=8B=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EC=9D=98=20=EB=B3=B5?= =?UTF-8?q?=EC=B0=BD=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판정 기준은 "숙련 개발자가 이 주석 없이 놓칠 정보가 있는가" 하나로 뒀다 - toResponse 위 두 줄은 앞 문장이 함수명 복창이고 뒷 문장은 DTO companion 이 빈을 못 쓴다는 Spring 기본기라 통째로 삭제 - registerFromUrl 의 attach 메타 설명은 fromRegistration 이라는 이름이 이미 등록 전용임을 말하고 실리는 값은 DTO 를 열면 보여 삭제 - confirmImageRegistration 의 201 설명은 바로 아랫줄 @ResponseStatus(CREATED) 의 복창이라 삭제 - presign 의 200 사유만 한 줄로 압축해 남겼다. 애노테이션의 부재는 실수와 구별되지 않아, 없으면 다음 사람이 빠뜨린 줄 알고 201 을 붙인다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../piki/wishlist/controller/WishlistController.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt index 44f52a09..90554a49 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt @@ -38,8 +38,6 @@ class WishlistController( private val wishlistService: WishlistService, private val sourcePlatformResolver: SourcePlatformResolver, ) : WishlistApi { - // wish 묶음 → 응답 변환의 단일 지점. sourcePlatform 판정(SourcePlatformResolver)은 빈이 필요해 DTO 의 from 이 - // 직접 못 하므로 여기서 풀어 넘긴다. private fun toResponse(result: WishWithItem): WishItemResponse = WishItemResponse.from(result.wish, result.item, result.snapshot, sourcePlatformResolver.resolve(result.item.link)) @@ -50,13 +48,12 @@ class WishlistController( @Valid @RequestBody request: WishlistRegisterRequest, ): ApiResponseBody { val result = wishlistService.registerFromUrl(rawUrl = request.url, userId = userId) - // 등록 응답만 공유 attach 메타(reused·refreshNeeded, #853)를 싣는다 — 클라의 "기존 값 사용/새로 가져오기" 선택 근거. return ApiResponseBody.created( WishItemResponse.fromRegistration(result, sourcePlatformResolver.resolve(result.item.link)), ) } - // 이미지 등록 1단계 — presigned 업로드 URL 발급. pending_uploads 에 발급 기록만 남기고 Wish·Item 은 아직 만들지 않으므로 200 OK. + // 발급 기록만 남기고 Wish·Item 은 아직 만들지 않아 201 이 아니다. @PostMapping("/images/presigned") override fun presignImageUploads( @AuthenticationPrincipal userId: UUID, @@ -66,7 +63,6 @@ class WishlistController( return ApiResponseBody.ok(PresignedImageUploadResponse.from(uploads)) } - // 이미지 등록 2단계 — 업로드 확정. PENDING 위시를 생성하므로 URL 등록과 같은 201 CREATED. @PostMapping("/images/confirm") @ResponseStatus(HttpStatus.CREATED) override fun confirmImageRegistration( From ca51bc16704771a664c808cdcb1f991da47b90ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:56:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=93=B1=EB=A1=9D=20=EC=84=9C?= =?UTF-8?q?=EB=91=90=EB=A5=BC=20ItemRegistrar=20=EB=A1=9C=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위시(registerFromUrl)와 토너먼트(addItemFromLink)가 parse + verifyRegistrable 을 각자 베껴 쓰고 있었다. 주석까지 거의 글자 그대로 같아, 같은 지식이 두 곳에 있다는 신호로 보고 관문을 뽑았다 - 이 관문의 존재 이유는 순서다. 형식·정책 위반과 중복은 차감 앞에서 걸러야 한다(#973) — 뒤로 가면 등록되지도 않을 요청이 사용자 몫을 깎는다. 두 호출자가 그 순서를 각자 외우던 동안 자격 검사 위치가 이미 어긋나 있었다(위시는 맨 앞, 토너먼트는 verifyRegistrable 뒤) - 차감은 처음엔 도메인에 두려 했다. 주인과 에러 코드가 도메인마다 달라 옮겨도 인자로 되돌아온다고 봤기 때문인데, 지켜야 할 것이 인자가 아니라 순서라 관문 안으로 넣었다 - 중복 판정만 콜백으로 남겼다. 기준이 도메인마다 다르고(내 위시 대 이 토너먼트) 차감 앞에 와야 해서, 인자로도 밖으로도 뺄 수 없는 자리다 - 반환형은 Item 이 아니라 ProductLink 로 했다. 저장하지 않은 Item 을 돌려주면 만든 것처럼 읽히고, persistLinkItem 이 link 를 받아 되레 풀어야 했다 - 토너먼트의 verifyCanAddItems 를 관문 앞으로 당겨 위시와 순서를 맞췄다. 참여자가 아닌 사람이 차단 도메인 URL 을 넣으면 이전에는 400(미지원 플랫폼), 이제는 권한 오류가 먼저 난다 - DomainAccessPolicy 의존이 위시·토너먼트 양쪽에서 사라졌다. ItemQuotaGuard 는 이미지 presign 이 장수만큼 따로 차감해 남는다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../piki/item/service/ItemRegistrar.kt | 35 +++++++++++++++++++ .../service/TournamentItemService.kt | 22 +++++------- .../piki/wishlist/service/WishlistService.kt | 25 ++++--------- 3 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt new file mode 100644 index 00000000..180c118e --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt @@ -0,0 +1,35 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.common.exception.ErrorCode +import com.depromeet.piki.common.ratelimit.ItemQuotaGuard +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.routing.DomainAccessPolicy +import org.springframework.stereotype.Component +import java.util.UUID + +// 링크로 아이템을 받아들이는 관문. 위시·토너먼트가 각자 베껴 쓰던 등록 서두를 한 자리로 모은다. +// +// 순서가 이 클래스의 존재 이유다. 형식·정책 위반과 중복은 차감 앞에서 걸러야 한다 — 뒤로 가면 +// 등록되지도 않을 요청이 사용자 몫을 깎는다(#973). 두 호출자가 이 순서를 각자 외우던 동안 +// 자격 검사 위치가 이미 서로 어긋나 있었다. +// +// 중복 판정만 콜백으로 받는다. 기준이 도메인마다 달라서다 — 위시는 내가 담은 것, 토너먼트는 이 토너먼트에 담긴 것. +@Component +class ItemRegistrar( + private val accessPolicy: DomainAccessPolicy, + private val itemQuotaGuard: ItemQuotaGuard, +) { + // quotaOwner 는 요청자가 아니라 몫의 주인이다 — 토너먼트는 참여자가 넣어도 오너 몫에서 깎인다(ItemQuotaGuard 참고). + fun accept( + rawUrl: String, + quotaOwner: UUID, + quotaErrorCode: ErrorCode, + rejectIfDuplicate: (ProductLink) -> Unit, + ): ProductLink { + val link = ProductLink.parse(rawUrl) + accessPolicy.verifyRegistrable(link) + rejectIfDuplicate(link) + itemQuotaGuard.consume(quotaOwner, 1, quotaErrorCode) + return link + } +} 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 9699a7d3..41d91d3f 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -8,8 +8,7 @@ import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.routing.DomainAccessPolicy +import com.depromeet.piki.item.service.ItemRegistrar import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository @@ -20,7 +19,6 @@ import java.util.UUID @Service class TournamentItemService( private val tournamentItemPersistenceService: TournamentItemPersistenceService, - private val accessPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val tournamentRepository: TournamentRepository, @@ -28,6 +26,7 @@ class TournamentItemService( private val tournamentUserRepository: TournamentUserRepository, private val itemSnapshotRepository: ItemSnapshotRepository, private val itemQuotaGuard: ItemQuotaGuard, + private val itemRegistrar: ItemRegistrar, ) { // 아이템 등록 비용은 요청자가 아니라 **토너먼트 오너**의 몫에서 깎는다(#339). 참여자에는 게스트가 섞이는데 // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 @@ -51,19 +50,14 @@ class TournamentItemService( tournamentId: Long, url: String, ): Long { - val link = ProductLink.parse(url) - // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다(400). - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. - accessPolicy.verifyRegistrable(link) - // 권한·상태를 차감 전에 확인한다(이미지 경로와 같은 이유) — 참여자도 아닌 요청이 오너의 몫을 깎으면 안 된다. // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) - // 이미 담긴 링크면 차감 전에 거른다(#973) — 추가되지 않을 요청이 오너의 몫을 깎으면 안 된다. - // 특히 응답이 유실된 뒤의 재시도가 이 경로로 들어오는데, 그때마다 몫을 잃으면 담지도 못한 채 한도만 소모된다. - tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, link) - itemQuotaGuard.consume(ownerIdOf(tournamentId), 1, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) - // URL 경로는 PENDING snapshot 을 커밋만 하고(작업 큐 적재) 즉시 반환한다. 파싱은 디스패처(@Scheduled)가 - // PENDING 을 집어 워커에 넘긴다 — @Async 유실과 무관하게 최소 1회는 claim 된다(at-least-once). + val link = + itemRegistrar.accept( + url, + quotaOwner = ownerIdOf(tournamentId), + quotaErrorCode = TournamentErrorCode.ITEM_QUOTA_EXCEEDED, + ) { tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, it) } // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). val persisted = tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, link) return persisted.tournamentItemId 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 44e09b0b..b5585806 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -11,8 +11,7 @@ import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemDisplayService -import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.routing.DomainAccessPolicy +import com.depromeet.piki.item.service.ItemRegistrar import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.service.UserService import com.depromeet.piki.wishlist.domain.WishCursor @@ -32,7 +31,7 @@ import java.util.UUID @Service class WishlistService( private val wishPersistenceService: WishPersistenceService, - private val accessPolicy: DomainAccessPolicy, + private val itemRegistrar: ItemRegistrar, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val wishRepository: WishRepository, @@ -52,26 +51,16 @@ class WishlistService( if (user.identityType != IdentityType.MEMBER) throw WishException.guestCannotUseWishlist() } - // registerFromUrl 는 외부 LLM 호출(read-timeout 60s)을 동기로 기다리지 않는다. - // link 만 가진 item 과 PENDING snapshot 을 즉시 커밋해 응답을 돌려주고(클라이언트는 "담는 중" 표시), - // 실제 파싱은 디스패처(@Scheduled)가 PENDING 을 집어 워커에 넘겨 READY/FAILED 로 전이시킨다. - // DB 의 PENDING 행이 작업의 진실 원천이라 @Async 큐 유실(인스턴스 재시작 등)과 무관하게 최소 1회는 claim 된다(at-least-once). - // URL 형식·미지원 플랫폼 같은 계약 위반은 등록 시점에 동기로 거른다(400). 파싱 결과 실패만 FAILED 로 간다. + // 파싱을 기다리지 않는다. PENDING snapshot 을 커밋해 즉시 응답하고, 디스패처가 집어 READY/FAILED 로 전이시킨다. fun registerFromUrl( rawUrl: String, userId: UUID, ): WishWithItem { requireMember(userId) - val link = ProductLink.parse(rawUrl) - // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다. - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. - accessPolicy.verifyRegistrable(link) - // 이미 담은 상품이면 차감 전에 거른다(#973) — 등록되지 않을 요청이 몫을 깎으면 안 된다. 특히 응답이 - // 유실된 뒤의 재시도가 이 경로로 들어오는데, 그때마다 몫을 잃으면 사용자는 담지도 못한 채 한도만 소모한다. - wishPersistenceService.rejectIfAlreadyRegistered(userId, link) - // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. - // 파서로 풀려 LLM 을 안 타도 fetch·추출 모듈 시간·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. - itemQuotaGuard.consume(userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) + val link = + itemRegistrar.accept(rawUrl, quotaOwner = userId, quotaErrorCode = WishErrorCode.ITEM_QUOTA_EXCEEDED) { + wishPersistenceService.rejectIfAlreadyRegistered(userId, it) + } return wishPersistenceService.persist(userId, Item(link)) } From 4975051d862fd099b16695b8bdeaee29577cd4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:11:45 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?=20=ED=95=9C=EB=8F=84=EC=9D=98=20=EC=A3=BC=EC=9D=B8=EA=B3=BC=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=EB=A5=BC=20ItemQuota=20?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=B6=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemRegistrar.accept 이 quotaOwner 와 quotaErrorCode 를 따로 받고 있었는데, 관문은 둘을 쓰지 않고 ItemQuotaGuard 에 그대로 흘려보내기만 했다. 순수한 통과 인자 둘이 시그니처에 새어 있던 셈이다 - 둘은 함께 정해져야 하는 한 덩어리다. 문구가 주인을 전제하기 때문이다 - TOURNAMENT-037 은 차감 주체가 오너인데 응답은 게스트 참여자도 받으므로 남의 사용량을 감추는 문구를 쓴다. 따로 넘기면 위시 주인에 토너먼트 코드를 실어도 컴파일된다 - 두 code 의 문구가 실제로 다른 것을 확인하고 파라미터화를 유지했다. 주인이 요청자인지 남인지 하나로 갈라 문구를 공통화하는 안도 있으나 와이어 계약 변경이라 남겨 둔다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../com/depromeet/piki/common/ratelimit/ItemQuota.kt | 12 ++++++++++++ .../com/depromeet/piki/item/service/ItemRegistrar.kt | 9 +++------ .../piki/tournament/service/TournamentItemService.kt | 4 ++-- .../piki/wishlist/service/WishlistService.kt | 3 ++- 4 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt new file mode 100644 index 00000000..ac2e1d0e --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt @@ -0,0 +1,12 @@ +package com.depromeet.piki.common.ratelimit + +import com.depromeet.piki.common.exception.ErrorCode +import java.util.UUID + +// 누구 몫에서 깎고, 그 몫이 바닥났을 때 뭐라고 답할 것인가. 둘은 함께 정해져야 한다 — +// 문구가 주인을 전제하기 때문이다(토너먼트 코드는 오너의 사용량을 감추는 문구를 쓴다). +// 따로 넘기면 위시 주인에 토너먼트 코드를 실어도 컴파일된다. +data class ItemQuota( + val owner: UUID, + val errorCode: ErrorCode, +) diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt index 180c118e..5191a5d3 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt @@ -1,11 +1,10 @@ package com.depromeet.piki.item.service -import com.depromeet.piki.common.exception.ErrorCode +import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.routing.DomainAccessPolicy import org.springframework.stereotype.Component -import java.util.UUID // 링크로 아이템을 받아들이는 관문. 위시·토너먼트가 각자 베껴 쓰던 등록 서두를 한 자리로 모은다. // @@ -19,17 +18,15 @@ class ItemRegistrar( private val accessPolicy: DomainAccessPolicy, private val itemQuotaGuard: ItemQuotaGuard, ) { - // quotaOwner 는 요청자가 아니라 몫의 주인이다 — 토너먼트는 참여자가 넣어도 오너 몫에서 깎인다(ItemQuotaGuard 참고). fun accept( rawUrl: String, - quotaOwner: UUID, - quotaErrorCode: ErrorCode, + quota: ItemQuota, rejectIfDuplicate: (ProductLink) -> Unit, ): ProductLink { val link = ProductLink.parse(rawUrl) accessPolicy.verifyRegistrable(link) rejectIfDuplicate(link) - itemQuotaGuard.consume(quotaOwner, 1, quotaErrorCode) + itemQuotaGuard.consume(quota.owner, 1, quota.errorCode) return link } } 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 41d91d3f..c6d27af6 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,6 @@ package com.depromeet.piki.tournament.service +import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload @@ -55,8 +56,7 @@ class TournamentItemService( val link = itemRegistrar.accept( url, - quotaOwner = ownerIdOf(tournamentId), - quotaErrorCode = TournamentErrorCode.ITEM_QUOTA_EXCEEDED, + ItemQuota(ownerIdOf(tournamentId), TournamentErrorCode.ITEM_QUOTA_EXCEEDED), ) { tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, it) } // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). val persisted = tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, link) 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 b5585806..2546fab5 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,6 @@ package com.depromeet.piki.wishlist.service +import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload @@ -58,7 +59,7 @@ class WishlistService( ): WishWithItem { requireMember(userId) val link = - itemRegistrar.accept(rawUrl, quotaOwner = userId, quotaErrorCode = WishErrorCode.ITEM_QUOTA_EXCEEDED) { + itemRegistrar.accept(rawUrl, ItemQuota(userId, WishErrorCode.ITEM_QUOTA_EXCEEDED)) { wishPersistenceService.rejectIfAlreadyRegistered(userId, it) } return wishPersistenceService.persist(userId, Item(link)) From 1a9ea9fd1eaff82f2cb2c86ef1d68392a2a4c837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:26:33 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?=20=ED=95=9C=EB=8F=84=EB=A5=BC=20=EB=93=B1=EB=A1=9D=20=EA=B4=80?= =?UTF-8?q?=EB=AC=B8=20=EC=95=88=EC=9C=BC=EB=A1=9C=20=EB=84=A3=EA=B3=A0=20?= =?UTF-8?q?code=20=EB=A5=BC=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=95=A9?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관문이 콜백을 받던 모양을 걷어내고 판정 전용으로 좁혔다. 호출부가 parse - 중복 - accept - persist 로 위에서 아래로 읽힌다 - 차감을 호출 도메인에 두는 안도 검토했으나, 한도는 아이템 등록의 관심사라 위시·토너먼트로 다시 올리는 것은 방향이 반대다. 관문 안에 두고 몫의 주인만 인자로 받는다 - WISH-010 과 TOURNAMENT-037 을 ITEM-006 하나로 합쳤다. 카운터가 하나인데 담는 자리마다 code 를 나눌 이유가 없고, 이 인자가 있어야 했던 유일한 이유가 code 가 둘이라는 것이었다 - 합친 문구는 몫의 주인을 드러내지 않는 쪽으로 골랐다. 토너먼트는 오너 몫에서 깎지만 응답은 참여 게스트도 받으므로, 옛 위시 문구("더 담을 수 없어요")를 그대로 쓰면 남의 사용량이 새는 자리가 된다 - client repo 에서 두 code 참조가 0건인 것을 확인하고 진행했다. 응답 code 값이 바뀌는 와이어 변경이다 - 중복 판정이 관문 밖으로 나오면서 차감 앞이라는 순서는 호출자가 진다. 두 호출자 모두 그 순서를 지키고 있다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../piki/common/ratelimit/ItemQuota.kt | 12 --------- .../piki/item/domain/ItemErrorCode.kt | 6 +++++ .../piki/item/service/ItemRegistrar.kt | 26 ++++++++----------- .../controller/TournamentItemApiExamples.kt | 3 ++- .../tournament/service/TournamentErrorCode.kt | 7 ----- .../service/TournamentItemService.kt | 13 +++++----- .../controller/WishlistApiExamples.kt | 3 ++- .../piki/wishlist/domain/WishErrorCode.kt | 3 --- .../piki/wishlist/service/WishlistService.kt | 22 +++++++++------- .../ratelimit/ItemQuotaExceptionTest.kt | 17 ++++++------ .../ratelimit/ItemQuotaIntegrationTest.kt | 15 ++++++----- 11 files changed, 57 insertions(+), 70 deletions(-) delete mode 100644 src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt deleted file mode 100644 index ac2e1d0e..00000000 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuota.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.depromeet.piki.common.ratelimit - -import com.depromeet.piki.common.exception.ErrorCode -import java.util.UUID - -// 누구 몫에서 깎고, 그 몫이 바닥났을 때 뭐라고 답할 것인가. 둘은 함께 정해져야 한다 — -// 문구가 주인을 전제하기 때문이다(토너먼트 코드는 오너의 사용량을 감추는 문구를 쓴다). -// 따로 넘기면 위시 주인에 토너먼트 코드를 실어도 컴파일된다. -data class ItemQuota( - val owner: UUID, - val errorCode: ErrorCode, -) diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt index eab00e24..bfdc9b7a 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt @@ -24,4 +24,10 @@ enum class ItemErrorCode( NAME_REQUIRED_FOR_READY("ITEM-003", ErrorCategory.INVALID_INPUT, "상품 이름을 입력해 주세요."), PRICE_REQUIRED_FOR_READY("ITEM-004", ErrorCategory.INVALID_INPUT, "상품 가격을 입력해 주세요."), IMAGE_REQUIRED_FOR_READY("ITEM-005", ErrorCategory.INVALID_INPUT, "상품 이미지를 등록해 주세요."), + + // 006 은 한도 code 통합(WISH-010·TOURNAMENT-037 대체)에서 추가됐다. 한도는 아이템 등록의 사실이라 + // 담는 자리(위시·토너먼트)마다 code 를 나눌 이유가 없다 — 카운터도 하나다. + // 문구는 몫의 주인을 드러내지 않는 쪽으로 고정한다: 토너먼트는 오너 몫에서 깎지만 이 응답은 참여 게스트도 + // 받으므로, 남의 사용량이 문구로 새면 안 된다. 남은 시간은 문구가 아니라 Retry-After 헤더가 전한다. + QUOTA_EXCEEDED("ITEM-006", ErrorCategory.TOO_MANY_REQUESTS, "지금은 추가할 수 없어요. 잠시 후 다시 시도해 주세요."), } diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt index 5191a5d3..57ab1590 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt @@ -1,32 +1,28 @@ package com.depromeet.piki.item.service -import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.routing.DomainAccessPolicy import org.springframework.stereotype.Component +import java.util.UUID -// 링크로 아이템을 받아들이는 관문. 위시·토너먼트가 각자 베껴 쓰던 등록 서두를 한 자리로 모은다. +// 이 링크를 아이템으로 받아들여도 되는지 판정하고, 통과하면 한 개 몫을 확보한다. +// 위시·토너먼트가 각자 베껴 쓰던 두 줄을 한 자리로 모은 것이다. // -// 순서가 이 클래스의 존재 이유다. 형식·정책 위반과 중복은 차감 앞에서 걸러야 한다 — 뒤로 가면 -// 등록되지도 않을 요청이 사용자 몫을 깎는다(#973). 두 호출자가 이 순서를 각자 외우던 동안 -// 자격 검사 위치가 이미 서로 어긋나 있었다. -// -// 중복 판정만 콜백으로 받는다. 기준이 도메인마다 달라서다 — 위시는 내가 담은 것, 토너먼트는 이 토너먼트에 담긴 것. +// 정책 위반은 차감 앞에서 걸러야 한다 - 뒤로 가면 등록되지도 않을 요청이 사용자 몫을 깎는다(#973). +// 중복 판정도 같은 이유로 차감 앞이지만 기준이 도메인마다 달라(내 위시 대 이 토너먼트) 호출자가 먼저 끝낸다. @Component class ItemRegistrar( private val accessPolicy: DomainAccessPolicy, private val itemQuotaGuard: ItemQuotaGuard, ) { + // quotaOwner 는 요청자가 아니라 몫의 주인이다 - 토너먼트는 참여자가 넣어도 오너 몫에서 깎인다(ItemQuotaGuard 참고). fun accept( - rawUrl: String, - quota: ItemQuota, - rejectIfDuplicate: (ProductLink) -> Unit, - ): ProductLink { - val link = ProductLink.parse(rawUrl) + link: ProductLink, + quotaOwner: UUID, + ) { accessPolicy.verifyRegistrable(link) - rejectIfDuplicate(link) - itemQuotaGuard.consume(quota.owner, 1, quota.errorCode) - return link + itemQuotaGuard.consume(quotaOwner, 1, ItemErrorCode.QUOTA_EXCEEDED) } } 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 5213d6df..9311a039 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.tournament.controller +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.common.exception.AlreadyRegisteredException import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper @@ -291,7 +292,7 @@ class TournamentItemApiExamples( // 아이템 등록 한도 초과(#339). retryAfterSeconds 는 Retry-After 헤더로만 나가고 body 에는 실리지 않으므로 // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. - private val itemQuotaExceeded = ItemQuotaException.exceeded(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, 900) + private val itemQuotaExceeded = ItemQuotaException.exceeded(ItemErrorCode.QUOTA_EXCEEDED, 900) // 전역 가용량 소진(#927). 오너의 몫과 무관하게 서비스 전체가 찬 상태라 503 이고, code 도 도메인이 아닌 공통이다. private val capacityExceeded = ItemQuotaException.capacityExceeded(900) 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 0b10449b..f9304f4e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -65,13 +65,6 @@ enum class TournamentErrorCode( // 게이트를 되살리는 자리는 TournamentService.rejectIfDeleted 주석에 적혀 있다. GUEST_CANNOT_CREATE_TOURNAMENT("TOURNAMENT-036", ErrorCategory.FORBIDDEN, "토너먼트 만들기는 회원만 이용할 수 있어요."), - // 037 도 #339. 차감 주체는 토너먼트 오너지만 이 응답은 참여자(게스트 포함) 누구나 받을 수 있으므로, - // 문구에 "오너의 사용량" 을 드러내지 않는다 — 남의 사용량은 요청자에게 알릴 정보가 아니다. - ITEM_QUOTA_EXCEEDED( - "TOURNAMENT-037", - ErrorCategory.TOO_MANY_REQUESTS, - "이 토너먼트에는 지금 아이템을 추가할 수 없어요. 잠시 후 다시 시도해 주세요.", - ), // 038 은 플레이 링크 클론의 아이템 단건 조회 정합(#977)에서 추가됐다. 클론은 원본 아이템을 이어받아 조회는 되지만, // 수정·삭제하면 원본을 건드리게 되므로 아이템 추가 금지(032)와 같은 결로 막는다 — 옛 "직접 소속" 스코프 체크의 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 c6d27af6..2b6fade2 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -1,12 +1,13 @@ package com.depromeet.piki.tournament.service -import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemRegistrar @@ -53,11 +54,9 @@ class TournamentItemService( ): Long { // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) - val link = - itemRegistrar.accept( - url, - ItemQuota(ownerIdOf(tournamentId), TournamentErrorCode.ITEM_QUOTA_EXCEEDED), - ) { tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, it) } + val link = ProductLink.parse(url) + tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, link) + itemRegistrar.accept(link, ownerIdOf(tournamentId)) // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). val persisted = tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, link) return persisted.tournamentItemId @@ -79,7 +78,7 @@ class TournamentItemService( contentTypes.forEach { ProductImage.extensionForMimeType(it) } // 위시 v2 와 같은 이유로 발급 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 한도를 우회한다. confirm 은 차감하지 않는다(이중 차감 방지). - itemQuotaGuard.consume(ownerIdOf(tournamentId), contentTypes.size, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(ownerIdOf(tournamentId), contentTypes.size, ItemErrorCode.QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.tournament(key, userId, tournamentId, expiresAt) } 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 c21518f3..a3c53224 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.wishlist.controller +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.common.exception.AlreadyRegisteredException import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper @@ -263,7 +264,7 @@ class WishlistApiExamples( // 아이템 등록 한도 초과(#339). retryAfterSeconds 는 Retry-After 헤더로만 나가고 body 에는 실리지 않으므로 // example payload 에 영향을 주지 않는다 — 문서상 대표값으로 15분을 넣는다. - private val itemQuotaExceeded = ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, 900) + private val itemQuotaExceeded = ItemQuotaException.exceeded(ItemErrorCode.QUOTA_EXCEEDED, 900) // 전역 가용량 소진(#927). 요청자의 몫과 무관하게 서비스 전체가 찬 상태라 503 이고, code 도 도메인이 아닌 공통이다. private val capacityExceeded = ItemQuotaException.capacityExceeded(900) 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 baae83f8..b2798b0a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt @@ -26,7 +26,4 @@ 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 2546fab5..45741c98 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -1,6 +1,5 @@ package com.depromeet.piki.wishlist.service -import com.depromeet.piki.common.ratelimit.ItemQuota import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload @@ -8,11 +7,13 @@ import com.depromeet.piki.image.domain.ProductImage import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemDisplayService import com.depromeet.piki.item.service.ItemRegistrar +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.service.UserService import com.depromeet.piki.wishlist.domain.WishCursor @@ -58,10 +59,9 @@ class WishlistService( userId: UUID, ): WishWithItem { requireMember(userId) - val link = - itemRegistrar.accept(rawUrl, ItemQuota(userId, WishErrorCode.ITEM_QUOTA_EXCEEDED)) { - wishPersistenceService.rejectIfAlreadyRegistered(userId, it) - } + val link = ProductLink.parse(rawUrl) + wishPersistenceService.rejectIfAlreadyRegistered(userId, link) + itemRegistrar.accept(link, userId) return wishPersistenceService.persist(userId, Item(link)) } @@ -81,7 +81,7 @@ class WishlistService( // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). // 발급만 받고 업로드를 안 하면 그만큼 몫을 손해 보지만, 그건 클라이언트가 자기 요청을 버린 경우다. - itemQuotaGuard.consume(userId, contentTypes.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, contentTypes.size, ItemErrorCode.QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> PendingUpload.wish(key, userId, expiresAt) } @@ -119,10 +119,14 @@ class WishlistService( // 포인터 버전을 끌어온 뒤 표시값은 파생한다(#857) — 카드는 항상 그 상품의 마지막 기계 READY 를 향하고, // 수기 존중·진행 중 유지 등 규칙은 ItemDisplayService 가 진다. 포인터는 정체성 도달·수기 존중 판정의 표식이다. val snapshotsById = - itemSnapshotRepository.findByIds(pageWishes.map { it.snapshotId }).associateBy { it.getId() } + itemSnapshotRepository + .findByIds(pageWishes.map { it.snapshotId }) + .associateBy { it.getId() } val displayById = itemDisplayService.resolveDisplay(snapshotsById.values) // item 정체성은 snapshot.itemId 단일 출처다. snapshot 에서 itemId 를 모아 item 을 한 번에 끌어온다. - val itemsById = itemRepository.findByIds(snapshotsById.values.map { it.itemId }).associateBy { it.getId() } + val itemsById = itemRepository + .findByIds(snapshotsById.values.map { it.itemId }) + .associateBy { it.getId() } val entries = pageWishes.map { wish -> // snapshot·item 은 wish 와 함께 영속화되며 별도 삭제 경로가 없다. 없으면 영속화 경로가 깨진 코드 버그다. @@ -241,7 +245,7 @@ class WishlistService( // 재추출도 파싱을 한 번 더 돌리므로 신규 등록과 같은 비용이다 — 1 로 차감한다. // refresh 계약 검증(링크 없음·FAILED 항목 등)은 persistence 안쪽이라 여기선 앞서 깎이는데, 그 두 사유는 // 클라가 refresh 버튼을 띄우지 않는 상태라 정상 흐름에서 반복 호출되지 않는다. - itemQuotaGuard.consume(userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) + itemQuotaGuard.consume(userId, 1, ItemErrorCode.QUOTA_EXCEEDED) return wishPersistenceService.refresh(userId = userId, wishId = wishId) } 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 346615f7..c5243789 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaExceptionTest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.common.ratelimit +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.tournament.service.TournamentErrorCode @@ -12,22 +13,22 @@ import kotlin.test.assertFailsWith class ItemQuotaExceptionTest { @Test fun `도메인 code 에서 status 와 message 를 파생한다`() { - val exception = ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 900) + val exception = ItemQuotaException.exceeded(ItemErrorCode.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(ItemErrorCode.QUOTA_EXCEEDED, exception.errorCode) + assertEquals(ItemErrorCode.QUOTA_EXCEEDED.message, exception.message) assertEquals(900, exception.retryAfterSeconds) } @Test fun `토너먼트 축은 자기 code 와 문구를 쓴다`() { // 두 축이 같은 예외 클래스를 공유하되 사용자 대면 문구·code 는 도메인이 소유한다. - val exception = ItemQuotaException.exceeded(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 60) + val exception = ItemQuotaException.exceeded(ItemErrorCode.QUOTA_EXCEEDED, retryAfterSeconds = 60) - assertEquals(TournamentErrorCode.ITEM_QUOTA_EXCEEDED, exception.errorCode) - assertEquals(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message, exception.message) + assertEquals(ItemErrorCode.QUOTA_EXCEEDED, exception.errorCode) + assertEquals(ItemErrorCode.QUOTA_EXCEEDED.message, exception.message) } @Test @@ -35,10 +36,10 @@ class ItemQuotaExceptionTest { // 0 이면 클라가 즉시 재시도해 또 거부되고, 음수는 Retry-After 로 나갈 수 없는 값이다. // 지금 유일한 호출자는 최소 1초를 보장하지만, 그건 그쪽 사정이라 팩토리가 자기 불변식으로 못박는다. assertFailsWith { - ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = 0) + ItemQuotaException.exceeded(ItemErrorCode.QUOTA_EXCEEDED, retryAfterSeconds = 0) } assertFailsWith { - ItemQuotaException.exceeded(WishErrorCode.ITEM_QUOTA_EXCEEDED, retryAfterSeconds = -1) + ItemQuotaException.exceeded(ItemErrorCode.QUOTA_EXCEEDED, retryAfterSeconds = -1) } } 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 ddfcc33a..98ad493d 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.common.ratelimit +import com.depromeet.piki.item.domain.ItemErrorCode import ch.qos.logback.classic.Level import ch.qos.logback.classic.Logger import ch.qos.logback.classic.spi.ILoggingEvent @@ -107,8 +108,8 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .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("$.code").value(ItemErrorCode.QUOTA_EXCEEDED.code)) + .andExpect(jsonPath("$.detail").value(ItemErrorCode.QUOTA_EXCEEDED.message)) .andExpect(jsonPath("$.data").doesNotExist()) // 남은 시간은 창 길이에 따라 달라지므로 값이 아니라 "양수가 실렸다" 를 계약으로 고정한다. .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) @@ -285,7 +286,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .contentType(MediaType.APPLICATION_JSON) .content("""{"url":"https://www.musinsa.com/products/9"}"""), ).andExpect(status().isTooManyRequests) - .andExpect(jsonPath("$.code").value(WishErrorCode.ITEM_QUOTA_EXCEEDED.code)) + .andExpect(jsonPath("$.code").value(ItemErrorCode.QUOTA_EXCEEDED.code)) } @Test @@ -337,7 +338,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .content("""{"url":"https://www.musinsa.com/products/3"}"""), ).andExpect(status().isTooManyRequests) // 카운터는 하나지만 응답 code 는 경로가 소유한다 — 토너먼트에서 막혔으면 토너먼트 code 다. - .andExpect(jsonPath("$.code").value(TournamentErrorCode.ITEM_QUOTA_EXCEEDED.code)) + .andExpect(jsonPath("$.code").value(ItemErrorCode.QUOTA_EXCEEDED.code)) } finally { stubItemParsingWorker.enabled = true } @@ -539,14 +540,14 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { .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(jsonPath("$.code").value(ItemErrorCode.QUOTA_EXCEEDED.code)) + .andExpect(jsonPath("$.detail").value(ItemErrorCode.QUOTA_EXCEEDED.message)) .andExpect(header().exists(HttpHeaders.RETRY_AFTER)) // 이 응답은 참여 게스트도 받는다. 남의(오너의) 사용량은 요청자에게 알릴 정보가 아니므로 문구가 // 그것을 드러내지 않는지 금지 단어 부재로 고정한다 — "토너먼트가 들어있다" 같은 단언은 이 규칙과 // 무관해서, 문구를 "오너의 남은 사용량이 0이에요" 로 바꿔도 통과해버린다. - val message = TournamentErrorCode.ITEM_QUOTA_EXCEEDED.message + val message = ItemErrorCode.QUOTA_EXCEEDED.message listOf("오너", "소유자", "사용량", "남은").forEach { assertFalse(message.contains(it), "429 문구가 오너의 사용량을 드러낸다: $message") } From e924c0cf63e5fe2db15113d07dc0e60e00139c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:05:47 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EC=9C=84=EC=8B=9C=20?= =?UTF-8?q?=EC=98=81=EC=86=8D=ED=99=94=EC=9D=98=20=EB=91=90=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=EB=A5=BC=20=EB=93=9C=EB=9F=AC=EB=82=B4=EA=B3=A0=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EA=B2=BD=ED=95=A9=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persist 가 이름은 저장을 약속하면서 안에서 다섯 가지를 했다. 붙는 길(attachToShared)과 새로 세우는 길(createFresh)로 갈라 본체를 세 줄로 줄였다 - manualEdit·updateMemo·refresh 가 snapshot·item 을 각자 조회하며 같은 error 문구를 세 벌 쓰고 있어 activeVersionOf 로 묶었다. updateMemo 는 본체가 네 줄이 됐다 - 주석 65줄을 32줄로 줄였다. 남긴 것은 코드가 답할 수 없는 것들이다 - 사전 확인이 락 밖이라 근사치라는 것(중복 검사가 두 번 도는 이유), 중복 판정 기준이 shared 가 아니라 attachment.item 이라는 것(병합 경합의 승자·패자), updateMemo 가 포인터를 안 바꾸는데도 락이 필요한 이유(전 컬럼 UPDATE 라 lost update), saveAll 반환 순서가 계약이 아니라는 것 - persist 의 rejectIfWithdrawnForUpdate 를 지웠다. 이 가드가 실제로 보장하는 것은 "cascade 가 wishes 를 지운 뒤 새 wish 행이 끼어들지 않는다" 하나뿐인데, 파싱 실행·한도 차감·고아 snapshot 은 전부 락 밖이라 못 막는다(item/service 에 유저 검사 0건). #776 본문도 이 선택지를 "경합 자체를 없애진 못하고 창을 좁힌다" 로 적어 뒀다 - 한 구멍만 막고 "탈퇴 경합을 막는다" 로 읽히면 보장 범위를 실제보다 넓게 믿게 된다. 탈퇴 후 남는 wish 행은 배치 정리로 푼다 - 그에 맞춰 UserWithdrawalRaceConcurrencyIntegrationTest 의 URL 등록 경합 케이스를 걷어냈다. 프로필·지연 이미지·FCM 세 경합은 가드가 그대로라 유지된다 - 지연 이미지 경로의 isActiveForUpdate 는 남긴다. 스케줄러 공용이라 예외를 던지면 롤백이 claim 을 되살려 무한 재시도가 되는, 성격이 다른 가드다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../service/WishPersistenceService.kt | 168 ++++++++---------- ...ithdrawalRaceConcurrencyIntegrationTest.kt | 34 +--- 2 files changed, 76 insertions(+), 126 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt index ae70c4e3..54804fc2 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt @@ -21,13 +21,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID -// WishlistService 의 registerFromUrl 가 외부 LLM 호출을 트랜잭션 바깥에 두도록 -// 영속화만 별도 빈으로 분리. 같은 빈에서 호출하면 Spring AOP proxy 를 -// 거치지 않아 @Transactional 가 무력화되기 때문이다. -// -// item 은 정체성(link)만 들고 추출값·상태는 ItemSnapshot 이 보유한다. URL 등록 경로는 link 만 가진 item 과 -// PENDING snapshot(작업 큐 적재)을 같은 트랜잭션에서 함께 저장하고, wish 가 그 snapshot 을 활성 포인터로 가리킨다. -// 파싱은 디스패처(@Scheduled)가 PENDING 을 집어 시작하므로, 여기선 워커를 트리거하지 않는다. +// WishlistService 에서 분리된 빈이다. 같은 빈 안에서 부르면 AOP proxy 를 안 거쳐 @Transactional 이 무력화된다. @Service class WishPersistenceService( private val wishRepository: WishRepository, @@ -40,10 +34,10 @@ class WishPersistenceService( ) { private val log = LoggerFactory.getLogger(javaClass) - // 등록 전 사전 확인 — 이미 담은 상품이면 한도를 깎기 전에 409 로 끊는다(#973). + // 한도를 깎기 전에 거르는 사전 확인(#973). // // 락 밖 조회라 근사치다: 병합 경합 창에서는 여기서 본 item 과 persist 가 실제로 붙는 item(승자)이 다를 수 있다. - // 정확한 판정은 persist 가 행 락 안에서 다시 하므로, 여기서 놓친 중복도 거기서 걸린다. 그 창에서만 차감이 낭비된다. + // 정확한 판정은 persist 가 행 락 안에서 다시 하므로 여기서 놓친 중복도 거기서 걸린다. 그 창에서만 차감이 낭비된다. @Transactional(readOnly = true) fun rejectIfAlreadyRegistered( userId: UUID, @@ -55,75 +49,80 @@ class WishPersistenceService( } } - // 유저 안에서 itemId → 그 위시의 id. itemId 는 등록당 1건이라 유저 내에서 사실상 1:1 이고, // 삭제된 위시는 조회에서 빠지므로 지웠다 다시 담는 것은 막히지 않는다. private fun existingWishId( itemId: Long, userId: UUID, ): Long? = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).firstOrNull()?.getId() - // item(정체성) → snapshot(PENDING 버전) → wish 순서로 같은 트랜잭션에서 저장한다. - // item 생성은 호출부가 트랜잭션 바깥에서 끝내고, 여기선 영속화만 한다. - // snapshot 을 PENDING 으로 커밋하는 것이 곧 작업 큐 적재다 — 디스패처가 이 행을 집어 PROCESSING 으로 claim 한다. + // 아는 링크 모양이면 그 정체성에 붙고, 처음 보는 모양이면 새로 만든다. @Transactional fun persist( userId: UUID, item: Item, ): WishWithItem { - // 활성 유저 확인·쓰기 경합 차단(#776) — user 행을 잠가 tombstone 이면 409. requireMember(비잠금)의 - // 확인과 이 트랜잭션의 wish INSERT 사이에 탈퇴 cascade 가 끼어들어 죽은 유저 wish 가 남는 것을 막는다. - // absent(users 행 없음)는 여기서 막지 않는다 — 정상 경로는 앞단(WishlistService.requireMember)이 이미 거르고, - // 이 방어는 "확인 후 탈퇴가 끼어든" tombstone race 전용이다(FCM 과 같은 결). 행이 있으면 잠가 직렬화한다. - userService.rejectIfWithdrawnForUpdate(userId) - // 공유 정체성(#825 활성화) — 이미 아는 링크 모양이면 새 item 을 만들지 않고 기존 item 에 붙는다. - // 락 순서 규약(user → 자식)에 따라 user 락 뒤에 item 락(resolveAttachment)이 온다. - item.link?.let { link -> - itemSharingService.resolveExistingItem(link)?.let { shared -> - val attachment = itemSharingService.resolveAttachment(shared.getId(), link) - // 앞문 중복(결정 3c): 같은 사용자가 이미 담은 상품이면 새 카드 대신 409. 판정·응답의 정체성 기준은 - // 별칭으로 찾은 shared 가 아니라 실제로 붙은 attachment.item 이다 — 병합 재시도 경합에선 둘이 - // 다르고(shared=loser, attachment.item=winner), 행 락 뒤라 검사도 직렬화된다. 409 면 트랜잭션 - // 롤백으로 attach 가 만든 PENDING 도 함께 사라진다. - existingWishId(attachment.item.getId(), userId)?.let { - throw AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, it) - } - val wish = wishRepository.save(Wish(userId = userId, snapshotId = attachment.snapshot.getId())) - return WishWithItem( - wish = wish, - item = attachment.item, - snapshot = attachment.snapshot, - reused = attachment.reused, - refreshNeeded = attachment.refreshNeeded, - ) - } + val attached = item.link?.let { attachToShared(userId, it) } + return attached ?: createFresh(userId, item) + } + + // 이미 아는 링크 모양에 붙는 길(#825). 모르는 모양이면 null 을 돌려 새로 만드는 길로 넘긴다. + private fun attachToShared( + userId: UUID, + link: ProductLink, + ): WishWithItem? { + val shared = itemSharingService.resolveExistingItem(link) ?: return null + val attachment = itemSharingService.resolveAttachment(shared.getId(), link) + // 중복 판정의 기준은 별칭으로 찾은 shared 가 아니라 실제로 붙은 attachment.item 이다 - 병합 재시도 + // 경합에선 둘이 다르다(shared=loser, attachment.item=winner). 행 락 뒤라 이 검사도 직렬화된다. + // 409 면 트랜잭션 롤백으로 attach 가 만든 PENDING 도 함께 사라진다. + existingWishId(attachment.item.getId(), userId)?.let { + throw AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, it) } + val wish = wishRepository.save(Wish(userId = userId, snapshotId = attachment.snapshot.getId())) + return WishWithItem( + wish = wish, + item = attachment.item, + snapshot = attachment.snapshot, + reused = attachment.reused, + refreshNeeded = attachment.refreshNeeded, + ) + } + + // 처음 보는 링크를 새 정체성으로 세우는 길. snapshot 을 PENDING 으로 커밋하는 것이 곧 작업 큐 적재다. + private fun createFresh( + userId: UUID, + item: Item, + ): WishWithItem { val saved = itemRepository.save(item) - // 처음 보는 링크 모양 — 원본 입력을 별칭(item_links)으로 기록한다. 같은 트랜잭션이라 등록과 원자적이고, - // INSERT IGNORE 라 동시 등록 경합이 등록을 죽이지 않는다. itemIdentityRecorder.recordRegistrationAlias(saved) - // 저장한 snapshot 의 id 를 wish 의 활성 포인터(snapshotId)로 박는다. 5단계 갱신에서 새 버전으로 스왑된다. val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(saved.getId())) val wish = wishRepository.save(Wish(userId = userId, snapshotId = snapshot.getId())) return WishWithItem(wish = wish, item = saved, snapshot = snapshot) } - // 이미지 등록 — confirm 또는 폴링 백스톱이 "업로드 확인된" key 들을 등록한다. pending_uploads 를 FOR UPDATE 로 - // 잠가 삭제(claim)하고, claim 에 성공한(=이 트랜잭션이 가져간) WISH 매핑만 적재한다 — confirm·폴링이 같은 key 를 - // 다퉈도 삭제는 한쪽만 성공하므로 중복 등록되지 않는다(멱등). 다른 user·토너먼트 맥락 매핑은 걸러낸다. + // wish 의 활성 포인터가 가리키는 버전과 그 정체성. 영속화 경로상 반드시 존재하므로 부재는 코드 버그다. + private fun activeVersionOf(wish: Wish): WishWithItem { + val snapshot = + itemSnapshotRepository.findById(wish.snapshotId) + ?: error("wish ${wish.getId()} 의 snapshot ${wish.snapshotId} 가 없다") + val item = + itemRepository.findById(snapshot.itemId) + ?: error("wish ${wish.getId()} 의 item ${snapshot.itemId} 가 없다") + return WishWithItem(wish = wish, item = item, snapshot = snapshot) + } + + // confirm 과 폴링 백스톱이 공유하는 진입점. pending_uploads 를 FOR UPDATE 로 잠가 삭제(claim)하므로 + // 둘이 같은 key 를 다퉈도 한쪽만 이긴다(멱등). @Transactional fun registerClaimedImages( imageKeys: List, userId: UUID, ): List { - // 활성 유저 확인·쓰기 경합 차단(#776). claim(pending_uploads 락)보다 **먼저** user 행을 잠가, 이 프로젝트의 - // 락 순서 규약 "user → 자식" 을 지킨다(WithdrawalPersistenceService.withdraw 와 동일). 지금은 user 를 먼저 - // 잠근 뒤 pending_uploads 를 건드리는 경로가 없어 역순 교차가 성립하지 않지만, 탈퇴 cascade 가 이 유저의 - // pending_uploads 를 함께 정리하는 순간 users→pending_uploads 가 생겨 이 경로와 교차 데드락이 된다. - // 부수 효과로 확인~claim 구간이 user 락 안에 들어와, 그 사이 탈퇴가 끼어들 창 자체가 사라진다. + // claim 보다 **먼저** user 행을 잠가 락 순서 규약 "user 다음 자식" 을 지킨다(#776). 지금은 역순 교차가 + // 성립하지 않지만, 탈퇴 cascade 가 이 유저의 pending_uploads 를 함께 정리하는 순간 교차 데드락이 된다. // - // 이 경로는 스케줄러(지연 처리)·confirm 공용이라, tombstone 이라고 예외를 던지면 트랜잭션 롤백으로 - // claim(pending_uploads 삭제)이 되살아나 스케줄러가 무한 재시도한다. 그래서 예외 대신 boolean 으로 받아 - // claim 은 소비하되 wish 생성만 건너뛴다 — 탈퇴 후 남은 pending upload 가 죽은 유저 wish 로 되살아나지 않는다. + // 예외가 아니라 boolean 으로 받는다 - 이 경로는 스케줄러 공용이라, tombstone 에 예외를 던지면 + // 롤백이 claim 을 되살려 무한 재시도가 된다. claim 은 소비하고 wish 생성만 건너뛴다. val active = userService.isActiveForUpdate(userId) val claimedKeys = pendingUploadClaimer.claim(imageKeys, PendingUploadContext.WISH, userId, tournamentId = null) if (claimedKeys.isEmpty()) return emptyList() @@ -134,14 +133,13 @@ class WishPersistenceService( return persistImagesInternal(userId, claimedKeys) } - // 이미지 key 들을 item(정체성) → PENDING snapshot(작업 큐 적재) → wish 순서로 배치 적재하는 공통 코어. - // 트랜잭션은 호출부(registerClaimedImages)가 연다 — self-invocation 으로 트랜잭션이 무력화되지 않게 private. + // 트랜잭션은 호출부가 연다 - self-invocation 으로 무력화되지 않게 private. private fun persistImagesInternal( userId: UUID, imageKeys: List, ): List { val items = itemRepository.saveAll(imageKeys.map { Item(sourceImageKey = it) }) - // snapshot 을 itemId 로 매핑해 saveAll 반환 순서에 의존하지 않는다(순서 보존은 공식 계약이 아니다). + // itemId 로 매핑한다 - saveAll 반환 순서는 공식 계약이 아니다. val snapshotsByItemId = itemSnapshotRepository.saveAll(items.map { ItemSnapshot.pending(it.getId()) }).associateBy { it.itemId } return items.map { item -> @@ -151,11 +149,8 @@ class WishPersistenceService( } } - // 수기 수정 영속화(#825 결정 4) — 기존 행을 고치지 않고 MANUAL 새 버전을 쌓아 활성 포인터를 스왑한다. - // S3 업로드(외부 호출)는 호출부가 트랜잭션 바깥에서 끝낸다. 상태 제한이 없다: 기계 버전은 불변이라 어떤 상태든 - // 덮어써질 위험 자체가 없고, 진행 중이던 파싱은 자기 행에서 계속돼 완료 시 이력으로 남는다. - // wish 행 락으로 refresh 와 직렬화한다 — 둘 다 활성 포인터를 스왑하는 경로라, 락 없이는 서로의 스왑을 덮는다 - // (옛 FAILED-상태 분리 방어를 대체하는 장치). base 는 락 안에서 읽은 현재 활성 버전이다. + // 기존 행을 고치지 않고 MANUAL 새 버전을 쌓아 활성 포인터를 스왑한다(#825 결정 4). + // wish 행 락으로 refresh 와 직렬화한다 - 둘 다 포인터를 스왑하는 경로라 락 없이는 서로의 스왑을 덮는다. @Transactional fun manualEdit( userId: UUID, @@ -168,14 +163,11 @@ class WishPersistenceService( ): WishWithItem { val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() wish.verifyOwnedBy(userId) - val base = - itemSnapshotRepository.findById(wish.snapshotId) - ?: error("wish ${wish.getId()} 의 snapshot ${wish.snapshotId} 가 없다") - val item = itemRepository.findById(base.itemId) ?: error("item ${base.itemId} 가 없다") + val current = activeVersionOf(wish) val manual = itemSnapshotRepository.save( ItemSnapshot.manual( - base = base, + base = current.snapshot, name = name, price = price, imageUrl = imageUrl, @@ -185,12 +177,11 @@ class WishPersistenceService( ) wish.swapSnapshot(manual.getId()) memo?.let { wish.updateMemo(it) } - return WishWithItem(wish = wish, item = item, snapshot = manual) + return WishWithItem(wish = wish, item = current.item, snapshot = manual) } - // memo 만 온 수정 — 버전(snapshot)을 쌓지 않고 wish 행만 갱신한다. 포인터를 안 바꿔도 행 락은 필요하다: - // UPDATE 가 전 컬럼을 쓰므로(dynamic update 아님), 락 없이 읽은 뒤 flush 하면 그 사이 스왑 경로(manualEdit·refresh)가 - // 커밋한 snapshotId 를 읽던 옛 값으로 되덮는다(lost update). 같은 행 락으로 스왑 경로와 직렬화한다. + // 포인터를 안 바꿔도 행 락이 필요하다: UPDATE 가 전 컬럼을 쓰므로(dynamic update 아님), 락 없이 읽은 뒤 + // flush 하면 그 사이 스왑 경로(manualEdit·refresh)가 커밋한 snapshotId 를 옛 값으로 되덮는다(lost update). @Transactional fun updateMemo( userId: UUID, @@ -200,17 +191,11 @@ class WishPersistenceService( val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() wish.verifyOwnedBy(userId) wish.updateMemo(memo) - val snapshot = - itemSnapshotRepository.findById(wish.snapshotId) - ?: error("wish ${wish.getId()} 의 snapshot ${wish.snapshotId} 가 없다") - val item = itemRepository.findById(snapshot.itemId) ?: error("item ${snapshot.itemId} 가 없다") - return WishWithItem(wish = wish, item = item, snapshot = snapshot) + return activeVersionOf(wish) } - // 위시 item 을 원본 링크로 재추출해 최신화한다(수동 새로고침). 새 PENDING snapshot 을 작업 큐에 적재하고 - // wish 활성 포인터를 즉시 그 버전으로 스왑한다 — 디스패처가 PENDING 을 집어 추출해 READY/FAILED 로 전이한다(등록과 동일 흐름). - // 옛 snapshot 행은 유지돼 토너먼트 출전 격리를 지킨다. 외부 호출(추출)은 디스패처가 트랜잭션 밖에서 하므로 여기선 적재만 한다. - // 동시 새로고침은 wish 행 락(findByIdForUpdate)으로 직렬화하고, 이미 진행 중이면 멱등(no-op)으로 새 추출을 만들지 않는다. + // 원본 링크로 재추출해 최신화한다(수동 새로고침). 새 PENDING 을 작업 큐에 적재하고 포인터를 스왑하면 + // 등록과 같은 흐름을 탄다. 옛 snapshot 행은 남아 토너먼트 출전 격리를 지킨다. @Transactional fun refresh( userId: UUID, @@ -218,28 +203,19 @@ class WishPersistenceService( ): WishWithItem { val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() wish.verifyOwnedBy(userId) - // item 정체성은 snapshot.itemId 단일 출처. snapshot·item 은 영속화 경로상 반드시 존재한다(없으면 코드 버그). - val activeSnapshot = - itemSnapshotRepository.findById(wish.snapshotId) - ?: error("wish ${wish.getId()} 의 snapshot ${wish.snapshotId} 가 없다") - val item = - itemRepository.findById(activeSnapshot.itemId) - ?: error("wish ${wish.getId()} 의 item ${activeSnapshot.itemId} 가 없다") - // link 없는 item(이미지 등록분)은 재추출 입력이 없어 새로고침 대상이 아니다(400). + val current = activeVersionOf(wish) + val item = current.item item.link ?: throw WishException.notRefreshable() - // 이미 진행 중(PENDING·PROCESSING)이면 새 추출을 만들지 않고 현재 진행 상태를 그대로 반환(멱등). - if (activeSnapshot.isInProgress()) return WishWithItem(wish = wish, item = item, snapshot = activeSnapshot) - // 공유(#825) — 같은 item 의 다른 참조(다른 위시·출전)가 이미 파싱을 돌리고 있으면 새 작업 대신 그 진행에 - // 합류한다(#826). 활성 포인터를 그 버전으로 스왑해 완료 시 함께 갱신된다. + // 이미 진행 중이면 새 추출을 만들지 않는다(멱등). + if (current.snapshot.isInProgress()) return current + // 같은 item 의 다른 참조가 이미 파싱 중이면 새 작업 대신 그 진행에 합류한다(#826). itemSnapshotRepository.findLatestInProgressByItemId(item.getId())?.let { inProgress -> wish.swapSnapshot(inProgress.getId()) return WishWithItem(wish = wish, item = item, snapshot = inProgress) } - // 추출 실패(FAILED) 항목은 새로고침 대상이 아니다 — 보정(recover)으로 복구한다(409). 새로고침은 성공(READY) - // 항목의 재추출 전용이라, 보정(FAILED 대상)과 상태로 갈려 recover-vs-refresh 동시 요청이 서로의 활성 포인터를 - // 침범하지 않는다(보정 진행 중엔 FAILED 라 새로고침이 여기서 막혀, 보정이 끝나기 전 활성이 스왑되지 않는다). - if (activeSnapshot.isFailed()) throw WishException.failedNotRefreshable() - // 새 PENDING 버전을 작업 큐에 적재하고 활성 포인터를 즉시 스왑한다. + // FAILED 는 보정(recover)이 맡는다. 새로고침을 상태로 갈라 둬야 두 경로가 서로의 활성 포인터를 + // 침범하지 않는다 - 보정 진행 중엔 FAILED 라 새로고침이 여기서 막힌다. + if (current.snapshot.isFailed()) throw WishException.failedNotRefreshable() val newSnapshot = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId())) wish.swapSnapshot(newSnapshot.getId()) return WishWithItem(wish = wish, item = item, snapshot = newSnapshot) diff --git a/src/test/kotlin/com/depromeet/piki/user/service/UserWithdrawalRaceConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/user/service/UserWithdrawalRaceConcurrencyIntegrationTest.kt index 18dc4add..fcba5095 100644 --- a/src/test/kotlin/com/depromeet/piki/user/service/UserWithdrawalRaceConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/user/service/UserWithdrawalRaceConcurrencyIntegrationTest.kt @@ -2,10 +2,8 @@ package com.depromeet.piki.user.service import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.repository.PendingUploadRepository -import com.depromeet.piki.item.domain.Item import com.depromeet.piki.notification.fcm.repository.UserDeviceRepository import com.depromeet.piki.notification.fcm.service.UserDeviceService -import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.user.domain.User @@ -28,7 +26,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue // 활성 유저 확인과 쓰기 사이의 check-then-use 경합 차단(#776) 검증. -// 탈퇴(WithdrawalService.withdraw) cascade 와 유저 쓰기 경로(프로필 수정·wish 등록·FCM 등록)가 +// 탈퇴(WithdrawalService.withdraw) cascade 와 유저 쓰기 경로(프로필 수정·지연 이미지 등록·FCM 등록)가 // user 행 비관락(findActiveByIdForUpdate)으로 직렬화돼, 어느 인터리빙이든 종단 상태가 tombstone 이고 // 죽은 유저를 가리키는 자식 행이 남지 않음(계정 부활·PII 복원·orphan 자식 금지)을 확인한다. // @@ -128,33 +126,9 @@ class UserWithdrawalRaceConcurrencyIntegrationTest : IntegrationTestSupport() { } } - @Test - fun `탈퇴와 wish 등록이 동시에 일어나도 종단적으로 tombstone 유저의 wish 가 남지 않는다`() { - val pool = Executors.newFixedThreadPool(2) - try { - repeat(ITERATIONS) { i -> - val userId = newMember() - val created = AtomicReference() - try { - raceWithWithdrawal(pool, userId) { - created.set(wishPersistenceService.persist(userId, Item(link = ProductLink.parse("https://example.com/p$i")))) - } - val row = userRow(userId) - assertNotNull(row.deletedAt, "탈퇴가 관여했으면 종단은 tombstone (iter=$i)") - assertEquals(0, wishCount(userId), "tombstone 유저의 wish 행이 남으면 안 된다 (iter=$i)") - } finally { - created.get()?.let { - jdbcTemplate.update("DELETE FROM wishes WHERE snapshot_id = ?", it.snapshot.getId()) - jdbcTemplate.update("DELETE FROM item_snapshots WHERE id = ?", it.snapshot.getId()) - jdbcTemplate.update("DELETE FROM items WHERE id = ?", it.item.getId()) - } - cleanupUser(userId) - } - } - } finally { - pool.shutdownNow() - } - } + // URL 등록(persist) 경합은 더는 덮지 않는다. 그 경로의 user 행 락을 걷어냈기 때문이다 - 창을 좁힐 뿐 + // 파싱 실행·한도 차감·고아 snapshot 은 어차피 못 막아, 보장 범위가 실제보다 넓게 읽히는 값이 컸다. + // 탈퇴 후 남는 wish 행은 배치 정리로 푼다. // 지연 이미지 등록(스케줄러·confirm 공용)만 예외 대신 boolean(isActiveForUpdate)으로 조용히 skip 하는 유일한 분기라 // 별도로 덮는다 — 회귀해도 예외가 안 터져 발견이 늦는 지점이다. 두 계약을 동시에 본다: From fe2a866631c18c7dcf64f62e77335fa9ec538892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:31:13 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20=EC=9C=84=EC=8B=9C=20persist?= =?UTF-8?q?=20=EA=B0=80=20Item=20=EB=8C=80=EC=8B=A0=20ProductLink=20?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persist 는 Item 을 받으면서 link 가 null 인 경우를 분기하고 있었으나, 호출자 16곳이 전부 Item(link) 를 만들어 넘긴다. 이미지 경로는 persistImagesInternal 로 따로 가므로 link 없는 Item 은 들어올 수 없다 - 받을 수 없는 값을 받는다고 선언해 둔 탓에 널 분기가 생겼고, null 이 두 가지 이유(링크가 없다·붙을 데가 없다)로 뭉개져 읽기 어려웠다. 링크를 직접 받게 하니 본체가 한 줄이 된다 - Item 생성이 createFresh 안으로 들어갔다. 위시는 서비스에서 Item 을 만들고 토너먼트는 영속화 안에서 만들던 비대칭이 함께 정리된다 - WishlistService 는 Item 을 더 이상 알지 않는다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../service/WishPersistenceService.kt | 17 +++++--------- .../piki/wishlist/service/WishlistService.kt | 3 +-- .../service/ItemSharingIntegrationTest.kt | 22 +++++++++---------- .../controller/WishlistCrudIntegrationTest.kt | 6 ++--- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt index 54804fc2..b650bf04 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt @@ -34,10 +34,6 @@ class WishPersistenceService( ) { private val log = LoggerFactory.getLogger(javaClass) - // 한도를 깎기 전에 거르는 사전 확인(#973). - // - // 락 밖 조회라 근사치다: 병합 경합 창에서는 여기서 본 item 과 persist 가 실제로 붙는 item(승자)이 다를 수 있다. - // 정확한 판정은 persist 가 행 락 안에서 다시 하므로 여기서 놓친 중복도 거기서 걸린다. 그 창에서만 차감이 낭비된다. @Transactional(readOnly = true) fun rejectIfAlreadyRegistered( userId: UUID, @@ -55,15 +51,12 @@ class WishPersistenceService( userId: UUID, ): Long? = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).firstOrNull()?.getId() - // 아는 링크 모양이면 그 정체성에 붙고, 처음 보는 모양이면 새로 만든다. + // 아는 링크 모양이면 그 정체성에 붙고, 처음 보는 모양이면 새로 세운다. @Transactional fun persist( userId: UUID, - item: Item, - ): WishWithItem { - val attached = item.link?.let { attachToShared(userId, it) } - return attached ?: createFresh(userId, item) - } + link: ProductLink, + ): WishWithItem = attachToShared(userId, link) ?: createFresh(userId, link) // 이미 아는 링크 모양에 붙는 길(#825). 모르는 모양이면 null 을 돌려 새로 만드는 길로 넘긴다. private fun attachToShared( @@ -91,9 +84,9 @@ class WishPersistenceService( // 처음 보는 링크를 새 정체성으로 세우는 길. snapshot 을 PENDING 으로 커밋하는 것이 곧 작업 큐 적재다. private fun createFresh( userId: UUID, - item: Item, + link: ProductLink, ): WishWithItem { - val saved = itemRepository.save(item) + val saved = itemRepository.save(Item(link)) itemIdentityRecorder.recordRegistrationAlias(saved) val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(saved.getId())) val wish = wishRepository.save(Wish(userId = userId, snapshotId = snapshot.getId())) 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 45741c98..e1c65b4a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -6,7 +6,6 @@ import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload -import com.depromeet.piki.item.domain.Item import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemRepository @@ -62,7 +61,7 @@ class WishlistService( val link = ProductLink.parse(rawUrl) wishPersistenceService.rejectIfAlreadyRegistered(userId, link) itemRegistrar.accept(link, userId) - return wishPersistenceService.persist(userId, Item(link)) + return wishPersistenceService.persist(userId, link) } // 이미지 등록 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다. 클라→S3 직접 업로드라 diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt index 78b4a545..7b1e7ddb 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt @@ -72,10 +72,10 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val userA = newMember() val userB = newMember() val url = "https://www.musinsa.com/products/8100001" - val first = wishPersistenceService.persist(userA, Item(link = ProductLink.parse(url))) + val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) try { // 첫 등록이 PENDING(진행 중) — 두 번째 등록은 새 item·새 작업 없이 같은 snapshot 을 함께 기다린다(#826). - val second = wishPersistenceService.persist(userB, Item(link = ProductLink.parse(url))) + val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) assertEquals(first.item.getId(), second.item.getId()) assertEquals(first.snapshot.getId(), second.snapshot.getId()) } finally { @@ -90,11 +90,11 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val userA = newMember() val userB = newMember() val url = "https://www.musinsa.com/products/8100002" - val first = wishPersistenceService.persist(userA, Item(link = ProductLink.parse(url))) + val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) try { seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 1) - val second = wishPersistenceService.persist(userB, Item(link = ProductLink.parse(url))) + val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) assertEquals(first.item.getId(), second.item.getId()) assertEquals(first.snapshot.getId(), second.snapshot.getId()) assertEquals(ItemStatus.READY, second.snapshot.status) @@ -114,12 +114,12 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val userA = newMember() val userB = newMember() val url = "https://www.musinsa.com/products/8100003" - val first = wishPersistenceService.persist(userA, Item(link = ProductLink.parse(url))) + val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) try { // 갱신 권고 임계(24h) 밖의 기계 READY — 그래도 그 값에 붙고, 재추출 여부는 사용자 선택(#853). seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 25) - val second = wishPersistenceService.persist(userB, Item(link = ProductLink.parse(url))) + val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) assertEquals(first.item.getId(), second.item.getId()) assertEquals(first.snapshot.getId(), second.snapshot.getId()) assertTrue(second.reused) @@ -138,7 +138,7 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val userA = newMember() val userB = newMember() val url = "https://www.musinsa.com/products/8100006" - val first = wishPersistenceService.persist(userA, Item(link = ProductLink.parse(url))) + val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) try { seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 25) @@ -168,7 +168,7 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { fun `같은 사용자가 같은 상품을 다시 담으면 409 - 링크 모양이 달라도 정체성 기준`() { stubItemParsingWorker.enabled = false val user = newMember() - val first = wishPersistenceService.persist(user, Item(link = ProductLink.parse("https://www.musinsa.com/products/8100004"))) + val first = wishPersistenceService.persist(user, ProductLink.parse("https://www.musinsa.com/products/8100004")) try { val mockMvc = MockMvcBuilders @@ -200,8 +200,8 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val userB = newMember() val userC = newMember() // 서로 다른 단축 모양이라 별칭 미스 — 각자 item 이 생기고, 파싱 완료 시 같은 귀결점으로 충돌한다(뒷문). - val first = wishPersistenceService.persist(userA, Item(link = ProductLink.parse("https://musinsa.onelink.me/PvkC/share0001"))) - val second = wishPersistenceService.persist(userB, Item(link = ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002"))) + val first = wishPersistenceService.persist(userA, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0001")) + val second = wishPersistenceService.persist(userB, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) val winnerId = first.item.getId() val loserId = second.item.getId() try { @@ -226,7 +226,7 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { val resolved = itemSharingService.resolveExistingItem(ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) assertEquals(winnerId, resolved?.getId()) // 실제 등록 흐름(persist)까지 승자에 붙는지 확인 — 조회 공간만이 아니라 attach 도 병합 결과를 따른다. - val third = wishPersistenceService.persist(userC, Item(link = ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002"))) + val third = wishPersistenceService.persist(userC, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) assertEquals(winnerId, third.item.getId()) } finally { stubItemParsingWorker.enabled = true diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt index 47544320..87b49142 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt @@ -104,7 +104,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { currency: String? = "KRW", imageUrl: String? = "https://img.example.com/a.png", ): Long { - val result = wishPersistenceService.persist(userId, Item(ProductLink.parse(url))) + val result = wishPersistenceService.persist(userId, ProductLink.parse(url)) itemParsingService.claimDuePending(100) // 이 시딩은 워커를 태우지 않고 전이만 재현한다 — 실행이 없었으므로 attempt 는 집기 직후 값(0) 그대로이고, // 전이의 fencing 토큰도 그 값이다. (실행까지 재현하는 흐름은 WishlistRegisterAsyncIntegrationTest 가 덮는다.) @@ -128,7 +128,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { userId: UUID, url: String, ): Long { - val result = wishPersistenceService.persist(userId, Item(ProductLink.parse(url))) + val result = wishPersistenceService.persist(userId, ProductLink.parse(url)) itemParsingService.claimDuePending(100) // 이 시딩은 워커를 태우지 않고 전이만 재현한다 — 실행이 없었으므로 attempt 는 집기 직후 값(0) 그대로이고, // 전이의 fencing 토큰도 그 값이다. (실행까지 재현하는 흐름은 WishlistRegisterAsyncIntegrationTest 가 덮는다.) @@ -142,7 +142,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { userId: UUID, url: String, ): Long { - val result = wishPersistenceService.persist(userId, Item(ProductLink.parse(url))) + val result = wishPersistenceService.persist(userId, ProductLink.parse(url)) itemParsingService.claimDuePending(100) return result.wish.getId() } From 2407d32678c032ee603df1c6894902874cea8d7e 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: Fri, 4 Sep 2026 00:15:08 +0900 Subject: [PATCH 07/10] =?UTF-8?q?test:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EC=9D=98=20=EC=82=AC=EC=9C=A0=EB=B3=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=EC=9D=84=20HTTP=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=EB=A1=9C=20=EB=AA=BB=EB=B0=95=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LINK-001(형식)·LINK-002(스킴)·빈 값·길이 네 갈래가 각각 다른 code·detail 로 나가는데, HTTP 레벨 검증이 없어 파싱 위치를 옮기면 조용히 뭉개진다. 실제로 ProductLink 파싱을 역직렬화로 옮겨 보니 LINK-001 이 COMMON-INVALID-INPUT 으로 바뀌는 것을 이 테스트가 잡았다 - 스킴 없는 상대 URI("example.com/...")는 URI.create 가 통과시켜 형식이 아니라 스킴 오류로 떨어진다. 형식 오류를 재려면 host 에 공백이 든 입력이어야 한다는 것도 함께 고정한다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../WishlistRegisterAsyncIntegrationTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt index b4f2c298..7ae16b72 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -657,6 +657,39 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } } + // 링크 형식·스킴·빈 값·길이의 응답 계약을 HTTP 레벨에서 못박는다. 파싱 위치가 서비스에서 역직렬화로 + // 옮겨가도 클라이언트가 보는 code·detail 이 그대로여야 한다. + @Test + fun `잘못된 링크는 사유별 code 로 400 을 받는다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + try { + val cases = + listOf( + // 공백이 든 host 는 URI.create 가 던진다(형식). 스킴 없는 상대 URI 는 통과해 스킴 검증에서 걸린다. + Triple("https://exa mple.com/1", "LINK-001", "올바른 링크 형식이 아니에요. 다시 확인해 주세요."), + Triple("example.com/products/1", "LINK-002", "https 링크만 등록할 수 있어요."), + Triple("http://example.com/products/1", "LINK-002", "https 링크만 등록할 수 있어요."), + Triple("", "COMMON-INVALID-INPUT", "링크를 입력해 주세요."), + Triple("https://a.com/" + "x".repeat(2048), "COMMON-INVALID-INPUT", "링크가 너무 길어요."), + ) + cases.forEach { (url, code, detail) -> + mockMvc + .perform( + post("/api/v1/wishlists") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + .content(objectMapper.writeValueAsString(mapOf("url" to url))), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.detail").value(detail)) + } + } finally { + cleanup(userId) + } + } + private fun registerAndGetItemId( mockMvc: MockMvc, userId: UUID, From 0f61cb621a83b25160c65698b75481f0e2a8e794 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: Fri, 4 Sep 2026 14:42:05 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=97=86=EC=95=A0=EA=B3=A0=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 진입점이 content-type 을 검증해 결과를 버리고, presignRawUploads 가 같은 검증을 다시 했다. "이 중복은 무해하다" 는 주석이 그 구조를 변호하고 있었다 - UploadFormat 을 두어 검증 결과(확장자)를 실어 나른다. 검증이 차감 앞이라는 순서는 그대로고, 결과를 쓰니 중복이 사라져 변호할 주석도 없어졌다 - ImagePresignService 26줄 -> 6줄. 남긴 것은 raw 회수를 여기서 안 한다는 것(부재라 코드가 못 말한다), presign 이 로컬 계산이라 트랜잭션에 묶어도 되는 근거, 두 발급 메서드의 차이, key 정규식이 ProductImage 에서 파생된다는 것 - PendingUploadPollingScheduler 는 @Async 로 바꾸면 안 되는 이유만 남겼다. 재진입 가드가 async body 로 들어가 무력해지고 fixedDelay 가 fixedRate 가 되는데, 코드만 봐서는 executor.execute 를 @Async 로 "정리" 하고 싶어진다 - 지운 것은 메서드 이름이 이미 말하던 것들이다. 만료 경로의 네 갈래 열거는 아래 if/else 와 로그가 같은 말을 하고 있었다 - PendingUploadClaimer 는 삭제가 곧 claim 이라는 것과 자기 트랜잭션을 열면 안 되는 이유만 남겼다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../piki/image/domain/UploadFormat.kt | 11 +++++ .../piki/image/service/ImagePresignService.kt | 45 +++++------------- .../image/service/PendingUploadClaimer.kt | 6 +-- .../service/PendingUploadPollingScheduler.kt | 47 +++++-------------- .../service/TournamentItemService.kt | 11 ++--- .../piki/wishlist/service/WishlistService.kt | 16 +++---- 6 files changed, 46 insertions(+), 90 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt new file mode 100644 index 00000000..8407e9d9 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.image.domain + +// 지원 형식임이 확인된 업로드 형식. of() 를 통과한 인스턴스만 존재하므로 뒤에서 다시 검증하지 않는다. +data class UploadFormat private constructor( + val contentType: String, + val extension: String, +) { + companion object { + fun of(contentType: String): UploadFormat = UploadFormat(contentType, ProductImage.extensionForMimeType(contentType)) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt index 52042924..21dc7f14 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt @@ -5,6 +5,7 @@ import com.depromeet.piki.common.storage.S3Properties import com.depromeet.piki.image.domain.ImageUploadException import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.image.repository.PendingUploadRepository import com.depromeet.piki.image.service.dto.PresignedRawUpload import org.slf4j.LoggerFactory @@ -14,14 +15,7 @@ import java.time.Duration import java.time.LocalDateTime import java.util.UUID -// 이미지 등록의 공통 presigned 업로드 프리미티브 — 위시·토너먼트가 권한 검증 후 위임한다. -// 발급: content-type 을 검증해 raw key(items/raw/{UUID}.{ext})를 만들고, 클라가 서버를 거치지 않고 S3 에 직접 PUT 할 -// presigned URL 을 준다. 발급된 key 는 pending_uploads 에 맥락과 함께 커밋해, confirm 이 안 와도 폴링 백스톱이 -// S3 존재를 확인해 등록할 수 있게 한다(클라 신호에 의존하지 않는 at-least-once). -// 확정 검증: 클라가 되돌려준 key 가 우리 발급 형식인지 + 실제로 S3 에 올라왔는지(HEAD) 확인한다. -// raw 회수는 두지 않는다 — raw 를 올린 주체가 클라이고, 등록에 매이지 못한 raw 는 폴링이 pending 매핑을 정리한 뒤 -// items/raw/ S3 lifecycle 이 만료시킨다. -// 개수 검증(1~5)은 도메인 계약이라 호출부(위시=member, 토너먼트=참여자·상태)가 각자 담당한다 — 여기선 형식·존재만 본다. +// 등록에 매이지 못한 raw 는 여기서 지우지 않는다 - items/raw/ S3 lifecycle 이 만료시킨다. @Service class ImagePresignService( private val imageStorage: ImageStorage, @@ -30,35 +24,26 @@ class ImagePresignService( ) { private val log = LoggerFactory.getLogger(javaClass) - // presign 서명은 로컬 계산(네트워크 없음)이라 pending 커밋과 한 트랜잭션으로 묶어도 커넥션을 오래 잡지 않는다. - // exists(HEAD, 외부 호출)는 여기 없다 — confirm/폴링이 트랜잭션 밖에서 먼저 확인한 뒤 등록(claim)을 부른다. - // 발급된 key 를 어느 맥락(위시/토너먼트)의 pending 으로 적을지는 호출부가 pendingOf 로 정한다 — PendingUpload 의 팩토리가 - // 맥락 정합(WISH↔tournamentId 없음, TOURNAMENT↔필수)을 강제하므로, 맥락 인코딩이 PendingUpload 한 곳에만 산다. + // presign 서명은 네트워크를 타지 않는 로컬 계산이라 pending 커밋과 한 트랜잭션으로 묶어도 커넥션을 오래 잡지 않는다. @Transactional fun presignRawUploads( - contentTypes: List, + formats: List, pendingOf: (imageKey: String, expiresAt: LocalDateTime) -> PendingUpload, ): List { - // 만료는 presigned 유효기간 + 여유 — 그 안에 업로드+등록이 끝나지 않으면 폴링이 이 매핑을 정리한다. + // presigned 가 만료된 뒤에도 폴링이 한 번 더 등록을 시도할 여유를 준다. val expiresAt = LocalDateTime.now().plus(s3Properties.presignedUploadExpiry).plus(PENDING_GRACE) val uploads = - contentTypes.map { contentType -> - // 미지정·미지원 content-type 은 발급 시점에 400 으로 거른다(ProductImage 가 of() 와 같은 검증을 공유). - val extension = ProductImage.extensionForMimeType(contentType) - val key = "$RAW_PREFIX${UUID.randomUUID()}.$extension" - val url = imageStorage.presignUpload(key, contentType, s3Properties.presignedUploadExpiry) - PresignedRawUpload(imageKey = key, uploadUrl = url, contentType = contentType) + formats.map { format -> + val key = "$RAW_PREFIX${UUID.randomUUID()}.${format.extension}" + val url = imageStorage.presignUpload(key, format.contentType, s3Properties.presignedUploadExpiry) + PresignedRawUpload(imageKey = key, uploadUrl = url, contentType = format.contentType) } pendingUploadRepository.saveAll(uploads.map { pendingOf(it.imageKey, expiresAt) }) return uploads } - // pending 매핑 없이 발급만 한다 — 확정 신호가 안 와도 되는 경로(프로필 이미지)가 쓴다. - // 위시·토너먼트 등록은 확정이 유실돼도 폴링이 등록을 마쳐야 해서 pending 을 남기지만(at-least-once), - // 프로필은 사용자가 다시 시도하면 그만이라 남길 상태가 없다. 미확정 raw 는 items/raw/ lifecycle(1일)이 만료한다. - // - // 허용 형식 정책은 호출부가 갖는다 — 프로필(ProfileImageFile)과 상품 이미지(ProductImage)의 허용 목록이 - // 독립이라, 검증을 끝낸 확장자만 받아 key 형식과 발급만 여기서 책임진다. + // pending 을 남기지 않는 발급. 프로필처럼 확정이 유실돼도 사용자가 다시 시도하면 그만인 경로가 쓴다. + // 허용 형식이 상품 이미지와 달라 검증을 끝낸 확장자를 받는다. fun presignRawUpload( extension: String, contentType: String, @@ -68,14 +53,11 @@ class ImagePresignService( return PresignedRawUpload(imageKey = key, uploadUrl = url, contentType = contentType) } - // 우리가 발급한 raw key 의 확장자. verifyUploaded 를 통과한 key 만 넘어오므로 형식이 보장된다. fun extensionOf(imageKey: String): String = imageKey.substringAfterLast('.') fun verifyUploaded(imageKeys: List) { imageKeys.forEach { key -> - // 우리가 발급하는 raw key 형식이 아니면 클라가 임의 경로를 준 것 — 400. if (!RAW_KEY_REGEX.matches(key)) throw ImageUploadException.invalidKey() - // presigned 로 실제 올리지 않고 confirm 을 부른 것 — 400 (스토리지 장애면 exists 가 502 로 던진다). if (!imageStorage.exists(key)) throw ImageUploadException.notUploaded() } } @@ -83,12 +65,9 @@ class ImagePresignService( companion object { const val RAW_PREFIX = "items/raw/" - // pending 매핑 만료 여유 — presigned 유효기간이 지나 업로드가 불가능해진 뒤에도 마지막 폴링이 한 번 더 - // 등록을 시도할 짧은 유예. 이 시간까지 안 올라오면 폴링이 매핑을 정리한다. private val PENDING_GRACE: Duration = Duration.ofMinutes(2) - // items/raw/{UUID}.{ext} — presignRawUploads 가 만드는 key 와 정확히 일치해야 한다. UUID.toString() 은 소문자 hex 라 - // [0-9a-f] 로 충분하고, 확장자 집합은 ProductImage.EXTENSIONS 에서 파생해 지원 포맷 추가 시 자동 추종한다(수동 동기화 제거). + // presignRawUploads 가 만드는 key 와 정확히 일치해야 한다. 확장자 집합을 ProductImage 에서 파생해 수동 동기화를 없앤다. private val RAW_KEY_REGEX = Regex( "^${RAW_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + diff --git a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadClaimer.kt b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadClaimer.kt index a58574c0..d4670f7b 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadClaimer.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadClaimer.kt @@ -5,10 +5,8 @@ import com.depromeet.piki.image.repository.PendingUploadRepository import org.springframework.stereotype.Component import java.util.UUID -// confirm 과 폴링이 공유하는 claim 프리미티브 — 주어진 key 중 (context, user, tournament) 맥락이 일치하는 pending 을 -// FOR UPDATE 로 잠가 삭제(claim)하고, claim 한 key 를 돌려준다. 호출부(위시·토너먼트 persistence 의 registerClaimedImages)의 -// @Transactional 안에서 실행된다(REQUIRED 전파) — confirm·폴링이 같은 key 를 다퉈도 삭제에 성공한 한쪽만 claim 한다(멱등). -// 다른 user·토너먼트·context 의 매핑은 걸러내, 남의 key 나 잘못된 맥락으로 등록되지 않게 한다. +// 삭제가 곧 claim 이다 - confirm 과 폴링이 같은 key 를 다퉈도 삭제에 성공한 한쪽만 가져간다. +// 트랜잭션은 호출부가 연다(REQUIRED). 자기 트랜잭션을 열면 claim 이 등록과 따로 커밋돼 멱등이 깨진다. @Component class PendingUploadClaimer( private val pendingUploadRepository: PendingUploadRepository, diff --git a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt index 09a17c0d..ed4ae0ef 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt @@ -19,20 +19,11 @@ import java.util.UUID import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicBoolean -// 이미지 등록 v2 폴링 백스톱 — 클라 confirm 에 의존하지 않고 "업로드된 pending 을 서버가 스스로 확인해 등록"한다. -// SQS 같은 이벤트 인프라 없이, 기존 작업 큐 폴링(ItemParsingScheduler)과 같은 방식으로 동작한다: -// 1. 아직 안 만료됐고 grace 가 지난 pending 을 집어 S3 HEAD(exists)로 업로드 여부 확인 → 올라온 것을 confirm 과 같은 배치로 등록. -// 2. 유효기간이 지난 pending 은 정리하되, 업로드는 됐는데 등록이 밀린 것은 유실 대신 마지막으로 배치 등록을 시도한다. -// 등록은 confirm 과 같은 registerClaimedImages(claim = FOR UPDATE 삭제)를 거치므로 멱등이다. +// 클라 confirm 이 오지 않아도 서버가 S3 를 확인해 등록을 마치는 백스톱. // -// 스케줄러 스레드는 재진입 가드만 확인하고 실제 폴링(HEAD·등록)을 전용 executor 에 넘긴 뒤 즉시 반환한다 — 외부 호출이 -// 공유 스케줄러 스레드를 막아 파싱 dispatch·SSE heartbeat 를 굶기는 것을 방지한다(ItemParsingScheduler 가 파싱을 @Async 로 -// 빼는 것과 같은 결). @Async 대신 executor.execute 를 직접 쓰는 이유: @Async + @Scheduled 를 같은 메서드에 걸면 메서드가 -// 즉시 반환돼 AtomicBoolean 재진입 가드가 async body 안으로 들어가 무력해지고, fixedDelay 가 fixedRate 처럼 동작한다. -// 스케줄러 스레드에서 가드를 확인해야 이전 폴링이 아직 도는 동안 새 폴링을 확실히 건너뛴다. -// -// enabled=false 로 두면 @Scheduled 자동 실행만 끈다(통합 테스트가 stub exists 로 발급 매핑을 조용히 등록해 오염되는 것을 막고, -// 폴링 테스트는 pollOnce() 를 직접 호출해 결정적으로 검증한다). +// **@Async 로 바꾸지 말 것.** @Async 와 @Scheduled 를 같은 메서드에 걸면 메서드가 즉시 반환돼 +// 재진입 가드가 async body 안으로 들어가 무력해지고, fixedDelay 가 fixedRate 처럼 동작한다. +// 가드는 스케줄러 스레드에서 확인해야 실효가 있다. @Component class PendingUploadPollingScheduler( private val pendingUploadRepository: PendingUploadRepository, @@ -44,7 +35,6 @@ class PendingUploadPollingScheduler( ) { private val log = LoggerFactory.getLogger(javaClass) - // 이전 폴링이 아직 도는 중이면(느린 S3 등) 겹쳐 돌지 않게 한다. 스케줄러 스레드에서 확인하므로 실효가 있다. private val running = AtomicBoolean(false) @Scheduled(fixedDelayString = "\${image.upload-poll-interval-ms:1000}") @@ -60,24 +50,18 @@ class PendingUploadPollingScheduler( } } - // 폴링 1회 — 자동 실행(poll)과 테스트 수동 호출이 공유하는 실제 로직. fun pollOnce() { val now = LocalDateTime.now() registerUploaded(now) expireStale(now) } - // 대기 중 pending 을 confirm 과 같은 배치 단위(같은 user·context·tournament)로 묶어 등록한다. - // 그룹 내 존재 확인 중 HEAD 가 일시 실패(S3 장애)하면 "안 올라옴(false)"으로 확정하지 않고 그룹 전체를 이번 주기에서 보류한다 - // — 일시 오류로 배치가 쪼개져 정원 판정이 부분적으로 갈리는 것을 막는다. private fun registerUploaded(now: LocalDateTime) { pendingUploadRepository .findLiveForPolling(now, now.minus(POLL_GRACE), BATCH_SIZE) .groupBy { RegisterGroup(it.userId, it.context, it.tournamentId) } .forEach { (group, uploads) -> - // 그룹 내 각 pending 의 존재를 확인한다. HEAD 일시 실패(existsOrNull 가 null 을 돌려 판단 못 한 것)가 - // 하나라도 있으면 그룹 전체를 이번 주기에 보류한다(다음 폴링 재시도) — 일시 오류로 배치가 쪼개져 - // 정원 판정이 부분적으로 갈리는 것을 막는다. checked.size < uploads.size 면 보류 대상이 있다는 뜻이다. + // 하나라도 판단이 안 되면 그룹 전체를 보류한다 - 일시 오류로 배치가 쪼개지면 정원 판정이 부분적으로 갈린다. val checked = uploads.mapNotNull { up -> existsOrNull(up.imageKey)?.let { up.imageKey to it } } if (checked.size < uploads.size) return@forEach val uploadedKeys = checked.filter { it.second }.map { it.first } @@ -87,15 +71,8 @@ class PendingUploadPollingScheduler( } } - // 만료된 pending 을 정리하되, 업로드는 됐는데 등록이 밀린 것은 유실 대신 배치로 마지막 등록을 시도한다: - // - 안 올라온 채 만료 → 삭제. - // - 등록 성공 → claim 으로 삭제됨. - // - 등록 실패가 영구 사유(정원 초과 등 계약 예외=HttpMappable) → 폐기하고 경고(다시 해도 같음. raw 는 lifecycle 이 정리). - // - 등록 실패가 일시 오류(DB deadlock·lock timeout 등 non-HttpMappable) → 삭제하지 않고 남겨 다음 폴링이 재시도(at-least-once 보존). - // - 존재 확인 자체가 실패(S3 장애) → 이번 정리 보류. - // registerUploaded 와 같은 배치 단위(RegisterGroup)로 등록해 만료 경로에서도 정원 all-or-nothing 을 지킨다(단건 partial-fill 방지). + // 만료됐어도 업로드는 끝난 것은 버리지 않고 마지막 등록을 시도한다. private fun expireStale(now: LocalDateTime) { - // 존재 확인 실패(S3 장애)는 이번 정리에서 제외(보류)한다 — uploaded / notUploaded 로만 가른다. val checked = pendingUploadRepository.findExpired(now, BATCH_SIZE).mapNotNull { upload -> val exists = existsOrNull(upload.imageKey) ?: return@mapNotNull null @@ -112,18 +89,18 @@ class PendingUploadPollingScheduler( runCatching { registerGroup(group, uploads.map { it.imageKey }) } .onFailure { e -> if (e is HttpMappable) { - // 영구 사유(정원 초과 등) — 다시 해도 같으니 폐기하고 경고한다(운영자 인지, raw 는 lifecycle 이 정리). + // 다시 해도 같은 결과라 폐기한다. raw 는 lifecycle 이 정리한다. log.warn("업로드됐으나 등록 못 한 채 만료된 pending 폐기(영구 사유): {}", e.message) pendingUploadRepository.deleteAll(uploads) } else { - // 일시 오류(DB deadlock·lock timeout 등) — 유실 방지 위해 삭제하지 않고 남겨 다음 폴링이 재시도한다. + // 삭제하지 않고 남겨 다음 폴링이 재시도한다. log.warn("만료 pending 등록 일시 실패, 다음 폴링 재시도: {}", e.message) } } } } - // HEAD 는 외부 호출 — 실패(일시 장애)면 null 로 돌려, 호출부가 "안 올라옴(false)"과 구분해 판단을 보류하게 한다. + // null = 판단 못 함. 호출부가 "안 올라옴(false)" 과 구분해 보류한다. private fun existsOrNull(imageKey: String): Boolean? = runCatching { imageStorage.exists(imageKey) } .getOrElse { e -> @@ -142,13 +119,12 @@ class PendingUploadPollingScheduler( tournamentItemPersistenceService.registerClaimedImages( imageKeys, group.userId, - // TOURNAMENT 매핑은 팩토리가 tournamentId 를 강제하므로 정상 흐름엔 항상 있다(없으면 코드 버그). group.tournamentId ?: error("TOURNAMENT pending 그룹에 tournamentId 가 없다"), ) } } - // confirm 이 (user, context, tournament) 하나로 배치 등록하는 것과 같은 grouping key — 폴링도 이 단위로 묶어 원자성을 맞춘다. + // confirm 의 배치 단위와 같아야 정원 판정이 두 경로에서 갈리지 않는다. private data class RegisterGroup( val userId: UUID, val context: PendingUploadContext, @@ -158,8 +134,7 @@ class PendingUploadPollingScheduler( companion object { private const val BATCH_SIZE = 100 - // 폴링은 confirm(빠른 경로)이 처리할 시간을 준 뒤에만 개입한다 — 발급 후 이 시간이 지난 pending 만 백스톱 대상으로 삼아, - // confirm 과 같은 key 를 다투는 레이스(부분 응답·재시도 오탐·불필요한 HEAD)를 시간으로 분리해 줄인다. + // confirm 이 먼저 처리할 시간을 준다. 같은 key 를 다투는 레이스를 시간으로 갈라 줄인다. private val POLL_GRACE: Duration = Duration.ofSeconds(15) } } 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 2b6fade2..54114236 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload @@ -73,13 +74,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(ownerIdOf(tournamentId), contentTypes.size, ItemErrorCode.QUOTA_EXCEEDED) - return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> + val formats = contentTypes.map { UploadFormat.of(it) } + itemQuotaGuard.consume(ownerIdOf(tournamentId), formats.size, ItemErrorCode.QUOTA_EXCEEDED) + return imagePresignService.presignRawUploads(formats) { key, expiresAt -> PendingUpload.tournament(key, userId, tournamentId, expiresAt) } } 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 e1c65b4a..023253b4 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.PendingUpload import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload import com.depromeet.piki.item.domain.ItemErrorCode @@ -73,15 +74,11 @@ class WishlistService( ): List { requireMember(userId) if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() - // content-type 검증을 차감 앞으로 당긴다 — presignRawUploads 안에서 걸러도 결과는 같지만, 그러면 지원하지 - // 않는 MIME 을 보낸 요청이 몫을 깎고 400 을 받는다. 형식 위반은 몫을 건드리기 전에 거른다는 순서를 지킨다. - // 같은 검증이 발급 시점에 한 번 더 도는 것은 부작용 없는 순수 함수라 무해하다. - contentTypes.forEach { ProductImage.extensionForMimeType(it) } - // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, - // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). - // 발급만 받고 업로드를 안 하면 그만큼 몫을 손해 보지만, 그건 클라이언트가 자기 요청을 버린 경우다. - itemQuotaGuard.consume(userId, contentTypes.size, ItemErrorCode.QUOTA_EXCEEDED) - return imagePresignService.presignRawUploads(contentTypes) { key, expiresAt -> + val formats = contentTypes.map { UploadFormat.of(it) } + // confirm 이 아니라 발급 시점에 차감한다. confirm 이 안 와도 폴링 백스톱이 등록을 마치므로, + // confirm 에서만 세면 그 경로가 한도를 통째로 우회한다. + itemQuotaGuard.consume(userId, formats.size, ItemErrorCode.QUOTA_EXCEEDED) + return imagePresignService.presignRawUploads(formats) { key, expiresAt -> PendingUpload.wish(key, userId, expiresAt) } } @@ -96,7 +93,6 @@ 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) } From 7091f28de1daff8e0a9bd1b430105a30a85a3940 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: Fri, 4 Sep 2026 14:51:18 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=9D=98=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=A7=80=EC=8B=9C=C2=B7=EB=B6=80?= =?UTF-8?q?=EC=9E=AC=C2=B7=EC=B0=A9=EC=8B=9C=EB=A7=8C=20=EB=82=A8=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앞 커밋이 공유 프리미티브만 정리하고 진입점 두 곳을 빠뜨려 기준이 반쪽으로 적용돼 있었다 - 지운 것은 세 부류다. 메서드 이름과 본문이 이미 말하는 것(발급 설명·확정 설명·만료 처리 요약), 바로 아래 로그가 같은 말을 하는 것(영구 사유·재시도), 다른 주석과 같은 사실을 두 번 말하는 것(정원 판정이 갈린다) - existsOrNull 을 uploadedOrUnknown 으로 바꿔 "null 은 판단 못 함" 주석을 이름으로 옮겼다 - UploadFormat 은 private 생성자와 of 팩토리가 그 말을 하고 있어 주석을 없앴다 - 남은 것은 넷뿐이다. @Async 로 바꾸지 말 것(하지 말라는 지시라 코드에 없다), 등록에 못 매인 raw 를 여기서 안 지운다는 것(부재), presign 이 로컬 계산이라 트랜잭션에 묶어도 된다는 것(외부 호출처럼 보이는 착시), 삭제가 곧 claim 이고 자기 트랜잭션을 열면 안 된다는 것(전파 속성이 코드에 안 보인다) - 조회·수기수정·삭제 경로의 주석은 별개 플로우라 이번 범위에서 제외했다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../com/depromeet/piki/image/domain/UploadFormat.kt | 1 - .../piki/image/service/ImagePresignService.kt | 5 +---- .../image/service/PendingUploadPollingScheduler.kt | 11 +++-------- .../piki/tournament/service/TournamentItemService.kt | 10 +--------- .../piki/wishlist/service/WishlistService.kt | 8 -------- 5 files changed, 5 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt index 8407e9d9..7e6798a2 100644 --- a/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt +++ b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt @@ -1,6 +1,5 @@ package com.depromeet.piki.image.domain -// 지원 형식임이 확인된 업로드 형식. of() 를 통과한 인스턴스만 존재하므로 뒤에서 다시 검증하지 않는다. data class UploadFormat private constructor( val contentType: String, val extension: String, diff --git a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt index 21dc7f14..875ea711 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt @@ -30,7 +30,6 @@ class ImagePresignService( formats: List, pendingOf: (imageKey: String, expiresAt: LocalDateTime) -> PendingUpload, ): List { - // presigned 가 만료된 뒤에도 폴링이 한 번 더 등록을 시도할 여유를 준다. val expiresAt = LocalDateTime.now().plus(s3Properties.presignedUploadExpiry).plus(PENDING_GRACE) val uploads = formats.map { format -> @@ -42,8 +41,7 @@ class ImagePresignService( return uploads } - // pending 을 남기지 않는 발급. 프로필처럼 확정이 유실돼도 사용자가 다시 시도하면 그만인 경로가 쓴다. - // 허용 형식이 상품 이미지와 달라 검증을 끝낸 확장자를 받는다. + // pending 을 남기지 않는 발급. 확정이 유실돼도 사용자가 다시 시도하면 그만인 경로가 쓴다. fun presignRawUpload( extension: String, contentType: String, @@ -67,7 +65,6 @@ class ImagePresignService( private val PENDING_GRACE: Duration = Duration.ofMinutes(2) - // presignRawUploads 가 만드는 key 와 정확히 일치해야 한다. 확장자 집합을 ProductImage 에서 파생해 수동 동기화를 없앤다. private val RAW_KEY_REGEX = Regex( "^${RAW_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + diff --git a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt index ed4ae0ef..b238a50d 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt @@ -62,7 +62,7 @@ class PendingUploadPollingScheduler( .groupBy { RegisterGroup(it.userId, it.context, it.tournamentId) } .forEach { (group, uploads) -> // 하나라도 판단이 안 되면 그룹 전체를 보류한다 - 일시 오류로 배치가 쪼개지면 정원 판정이 부분적으로 갈린다. - val checked = uploads.mapNotNull { up -> existsOrNull(up.imageKey)?.let { up.imageKey to it } } + val checked = uploads.mapNotNull { up -> uploadedOrUnknown(up.imageKey)?.let { up.imageKey to it } } if (checked.size < uploads.size) return@forEach val uploadedKeys = checked.filter { it.second }.map { it.first } if (uploadedKeys.isEmpty()) return@forEach @@ -71,11 +71,10 @@ class PendingUploadPollingScheduler( } } - // 만료됐어도 업로드는 끝난 것은 버리지 않고 마지막 등록을 시도한다. private fun expireStale(now: LocalDateTime) { val checked = pendingUploadRepository.findExpired(now, BATCH_SIZE).mapNotNull { upload -> - val exists = existsOrNull(upload.imageKey) ?: return@mapNotNull null + val exists = uploadedOrUnknown(upload.imageKey) ?: return@mapNotNull null upload to exists } val notUploaded = checked.filter { !it.second }.map { it.first } @@ -89,19 +88,16 @@ class PendingUploadPollingScheduler( runCatching { registerGroup(group, uploads.map { it.imageKey }) } .onFailure { e -> if (e is HttpMappable) { - // 다시 해도 같은 결과라 폐기한다. raw 는 lifecycle 이 정리한다. log.warn("업로드됐으나 등록 못 한 채 만료된 pending 폐기(영구 사유): {}", e.message) pendingUploadRepository.deleteAll(uploads) } else { - // 삭제하지 않고 남겨 다음 폴링이 재시도한다. log.warn("만료 pending 등록 일시 실패, 다음 폴링 재시도: {}", e.message) } } } } - // null = 판단 못 함. 호출부가 "안 올라옴(false)" 과 구분해 보류한다. - private fun existsOrNull(imageKey: String): Boolean? = + private fun uploadedOrUnknown(imageKey: String): Boolean? = runCatching { imageStorage.exists(imageKey) } .getOrElse { e -> log.warn("pending {} 존재 확인 실패, 이번 주기 보류: {}", imageKey, e.message) @@ -124,7 +120,6 @@ class PendingUploadPollingScheduler( } } - // confirm 의 배치 단위와 같아야 정원 판정이 두 경로에서 갈리지 않는다. private data class RegisterGroup( val userId: UUID, val context: PendingUploadContext, 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 54114236..6fe068df 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -63,10 +63,7 @@ class TournamentItemService( return persisted.tournamentItemId } - // 이미지 등록 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다(위시 presignImageUploads 와 동일 패턴). - // 원본 바이트가 서버 메모리·대역을 경유하지 않는다. - // 개수·권한(참여자·PENDING·비복제)을 사전 검증하고, content-type 검증·raw key 생성·presign 은 ImagePresignService 에 위임한다. - // 발급은 pending_uploads 매핑만 남기고 tournament_item 을 만들지 않으므로 정원 최종 판정(persist 의 FOR UPDATE)은 confirm 으로 미룬다 — 여기선 사전 권한만 본다. + // 발급 단계에선 tournament_item 을 만들지 않아 정원 최종 판정은 confirm 으로 미룬다. fun presignImageUploads( userId: UUID, tournamentId: Long, @@ -81,10 +78,6 @@ class TournamentItemService( } } - // 이미지 등록 v2 확정(빠른 경로) — presigned 로 업로드를 마친 key 들을 받아 PENDING 아이템으로 적재한다. - // 권한 사전검증 → key 형식·존재(HEAD) 검증 → pending_uploads claim(FOR UPDATE 삭제) + persist(정원 FOR UPDATE 최종 판정). - // 폴링 백스톱과 같은 진입점이라 confirm 이 안 와도 폴링이 회수하고, 둘이 같은 key 를 다퉈도 claim 이 한쪽만 이긴다(멱등). - // persist 실패 시 트랜잭션이 claim 을 롤백해 pending 이 남으므로 회수는 폴링에 맡긴다(raw 는 클라가 올린 것 + lifecycle 백업). fun confirmImageRegistration( userId: UUID, tournamentId: Long, @@ -92,7 +85,6 @@ 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/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index 023253b4..7ea6f22e 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -53,7 +53,6 @@ class WishlistService( if (user.identityType != IdentityType.MEMBER) throw WishException.guestCannotUseWishlist() } - // 파싱을 기다리지 않는다. PENDING snapshot 을 커밋해 즉시 응답하고, 디스패처가 집어 READY/FAILED 로 전이시킨다. fun registerFromUrl( rawUrl: String, userId: UUID, @@ -65,9 +64,6 @@ class WishlistService( return wishPersistenceService.persist(userId, link) } - // 이미지 등록 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다. 클라→S3 직접 업로드라 - // 원본 바이트가 서버 메모리·대역을 경유하지 않는다. - // 회원·개수(계약) 검증만 여기서 하고, content-type 검증·raw key 생성·presign 발급은 ImagePresignService 에 위임한다. fun presignImageUploads( contentTypes: List, userId: UUID, @@ -83,10 +79,6 @@ class WishlistService( } } - // 이미지 등록 v2 확정(빠른 경로) — 클라가 presigned 로 업로드를 마친 key 들을 받아 PENDING 위시로 적재한다. - // key 형식·존재(HEAD) 검증 후 pending_uploads 를 claim(FOR UPDATE 삭제)하며 등록한다 — 폴링 백스톱과 같은 진입점이라 - // confirm 이 안 와도(또는 실패해도) 폴링이 회수하고, 둘이 같은 key 를 다퉈도 claim 이 한쪽만 이긴다(멱등). - // persist 실패 시 트랜잭션이 claim 을 롤백해 pending 이 남으므로, 회수는 폴링에 맡긴다(raw 는 클라가 올린 것 + lifecycle 백업). fun confirmImageRegistration( imageKeys: List, userId: UUID, From 2aefd693b074c54cc89d813c25dd1ab1143fddbb 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: Fri, 4 Sep 2026 16:15:41 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=C2=B7=EB=B0=9C=EA=B8=89=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20UploadFormat?= =?UTF-8?q?=20=EC=9D=98=20copy=20=EC=9A=B0=ED=9A=8C=EB=A5=BC=20=EB=A7=89?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data class 는 생성자가 private 이어도 copy() 가 공개라 검증을 우회한다. @ConsistentCopyVisibility 로 막는다 - requireMember 는 모든 진입 메서드가 첫 줄에서 부르는 손으로 짠 애스펙트라 TODO 로 표시했다 - 발급 시점 차감 근거는 커밋 이력과 이슈에 남으므로 코드에서 뺀다 Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9 --- .../com/depromeet/piki/image/domain/UploadFormat.kt | 1 + .../piki/wishlist/controller/WishlistController.kt | 1 - .../depromeet/piki/wishlist/service/WishlistService.kt | 9 ++------- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt index 7e6798a2..fb312165 100644 --- a/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt +++ b/src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.image.domain +@ConsistentCopyVisibility data class UploadFormat private constructor( val contentType: String, val extension: String, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt index 90554a49..18d7138d 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt @@ -53,7 +53,6 @@ class WishlistController( ) } - // 발급 기록만 남기고 Wish·Item 은 아직 만들지 않아 201 이 아니다. @PostMapping("/images/presigned") override fun presignImageUploads( @AuthenticationPrincipal userId: UUID, 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 7ea6f22e..5a452fac 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -43,12 +43,9 @@ class WishlistService( private val itemQuotaGuard: ItemQuotaGuard, private val userService: UserService, ) { - // 위시리스트는 회원 전용. 게스트(인증은 됐으나 회원 아님)는 Security 가 아니라 여기서 도메인 계약으로 막아 - // "회원만 이용 가능" 이라는 구체 사유를 내려준다(SecurityConfig 의 wishlists authenticated() 주석 참고). - // 인증 principal 은 userId 뿐이라 identityType 은 조회로 확인한다 — 모든 진입 메서드가 처리 전에 가장 먼저 호출한다. + + // TODO AOP private fun requireMember(userId: UUID) { - // 활성 조회라 탈퇴(tombstone) 회원은 identityType 이 MEMBER 여도 여기서 409 로 끊긴다 — - // 탈퇴 시 토큰 무효화가 부분 실패한 창에서 죽은 계정이 위시리스트를 쓰는 것을 막는다 (#691). val user = userService.findActiveById(userId) if (user.identityType != IdentityType.MEMBER) throw WishException.guestCannotUseWishlist() } @@ -71,8 +68,6 @@ class WishlistService( requireMember(userId) if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() val formats = contentTypes.map { UploadFormat.of(it) } - // confirm 이 아니라 발급 시점에 차감한다. confirm 이 안 와도 폴링 백스톱이 등록을 마치므로, - // confirm 에서만 세면 그 경로가 한도를 통째로 우회한다. itemQuotaGuard.consume(userId, formats.size, ItemErrorCode.QUOTA_EXCEEDED) return imagePresignService.presignRawUploads(formats) { key, expiresAt -> PendingUpload.wish(key, userId, expiresAt)