Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/main/kotlin/com/depromeet/piki/image/domain/UploadFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.depromeet.piki.image.domain

@ConsistentCopyVisibility
data class UploadFormat private constructor(
val contentType: String,
val extension: String,
) {
companion object {
fun of(contentType: String): UploadFormat = UploadFormat(contentType, ProductImage.extensionForMimeType(contentType))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -30,35 +24,24 @@ 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<String>,
formats: List<UploadFormat>,
pendingOf: (imageKey: String, expiresAt: LocalDateTime) -> PendingUpload,
): List<PresignedRawUpload> {
// 만료는 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,
Expand All @@ -68,27 +51,20 @@ 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<String>) {
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()
}
}

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 에서 파생해 지원 포맷 추가 시 자동 추종한다(수동 동기화 제거).
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}" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}")
Expand All @@ -60,25 +50,19 @@ 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 } }
// 하나라도 판단이 안 되면 그룹 전체를 보류한다 - 일시 오류로 배치가 쪼개지면 정원 판정이 부분적으로 갈린다.
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
Expand All @@ -87,18 +71,10 @@ 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
val exists = uploadedOrUnknown(upload.imageKey) ?: return@mapNotNull null
upload to exists
}
val notUploaded = checked.filter { !it.second }.map { it.first }
Expand All @@ -112,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 {
// 일시 오류(DB deadlock·lock timeout 등) — 유실 방지 위해 삭제하지 않고 남겨 다음 폴링이 재시도한다.
log.warn("만료 pending 등록 일시 실패, 다음 폴링 재시도: {}", e.message)
}
}
}
}

// HEAD 는 외부 호출 — 실패(일시 장애)면 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)
Expand All @@ -142,13 +115,11 @@ class PendingUploadPollingScheduler(
tournamentItemPersistenceService.registerClaimedImages(
imageKeys,
group.userId,
// TOURNAMENT 매핑은 팩토리가 tournamentId 를 강제하므로 정상 흐름엔 항상 있다(없으면 코드 버그).
group.tournamentId ?: error("TOURNAMENT pending 그룹에 tournamentId 가 없다"),
)
}
}

// confirm 이 (user, context, tournament) 하나로 배치 등록하는 것과 같은 grouping key — 폴링도 이 단위로 묶어 원자성을 맞춘다.
private data class RegisterGroup(
val userId: UUID,
val context: PendingUploadContext,
Expand All @@ -158,8 +129,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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, "지금은 추가할 수 없어요. 잠시 후 다시 시도해 주세요."),
}
28 changes: 28 additions & 0 deletions src/main/kotlin/com/depromeet/piki/item/service/ItemRegistrar.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.depromeet.piki.item.service

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).
// 중복 판정도 같은 이유로 차감 앞이지만 기준이 도메인마다 달라(내 위시 대 이 토너먼트) 호출자가 먼저 끝낸다.
@Component
class ItemRegistrar(
private val accessPolicy: DomainAccessPolicy,
private val itemQuotaGuard: ItemQuotaGuard,
) {
// quotaOwner 는 요청자가 아니라 몫의 주인이다 - 토너먼트는 참여자가 넣어도 오너 몫에서 깎인다(ItemQuotaGuard 참고).
fun accept(
link: ProductLink,
quotaOwner: UUID,
) {
accessPolicy.verifyRegistrable(link)
itemQuotaGuard.consume(quotaOwner, 1, ItemErrorCode.QUOTA_EXCEEDED)
}
}
Loading
Loading