From dab679eadcb25a5a00b47702b55999245797f941 Mon Sep 17 00:00:00 2001 From: hamtorygoals Date: Fri, 17 Jul 2026 18:43:36 +0900 Subject: [PATCH] =?UTF-8?q?:bug:=20fix:=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=ED=81=90=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=ED=95=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=EA=B0=80=20500=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20thumbnailExecutor=20=ED=81=90?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EB=93=9D=20=EC=B0=A8=EB=A9=B4=20afterComm?= =?UTF-8?q?it()=20=EC=BD=9C=EB=B0=B1=EC=97=90=EC=84=9C=20=EB=8D=98?= =?UTF-8?q?=EC=A7=84=20RejectedExecutionException=EC=9D=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=A0=84=ED=8C=8C=EB=8F=BC,=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=EC=9D=80=20=EC=9D=B4=EB=AF=B8=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EB=90=90=EB=8A=94=EB=8D=B0=20=EC=9D=91=EB=8B=B5=EB=A7=8C=20500?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B9=A8=EC=A7=80=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98=EA=B0=80=20=EC=9E=88?= =?UTF-8?q?=EC=97=88=EB=8B=A4.=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EC=9E=A1?= =?UTF-8?q?=EC=95=84=20=EB=A1=9C=EA=B7=B8=EB=A7=8C=20=EB=82=A8=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20PENDING=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=91=90?= =?UTF-8?q?=EB=A9=B4=20PhotoSweepScheduler=EA=B0=80=20=EC=9E=AC=EC=88=98?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95.=20Ph?= =?UTF-8?q?otoSweepScheduler.resubmitStaleThumbnailJobs()=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=98=88=EC=99=B8=EC=97=90=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=EB=90=98=EC=96=B4=20=EC=9E=88=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=95=84,=20=ED=81=90=EA=B0=80=20=EC=A7=80=EC=86=8D=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=93=9D=20=EC=B0=AC=20=EC=83=81?= =?UTF-8?q?=ED=99=A9=EC=97=90=EC=84=9C=EB=8A=94=20=EC=8A=A4=EC=9C=95=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EC=9D=98=20=EC=B2=AB=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A3=A8=ED=94=84=EA=B0=80=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=EB=90=98=EA=B3=A0=20=EB=82=98=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=EB=93=A4=EC=9D=B4=20=EA=B7=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=EC=97=90=EC=84=9C=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=9E=88=20=EB=88=84=EB=9D=BD=EB=90=98=EB=8A=94=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=20=EA=B2=B0=ED=95=A8=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EC=97=88=EB=8B=A4.=20=EA=B0=99=EC=9D=80=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=A7=84=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?try/catch=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=B4=20=ED=95=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=EC=9D=98=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20?= =?UTF-8?q?=EB=82=98=EB=A8=B8=EC=A7=80=20=EC=9E=AC=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EB=A7=89=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95.=20Closes=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/backend-architecture.md | 3 +- .../photo/scheduler/PhotoSweepScheduler.java | 16 ++++- .../photo/service/PhotoUploadService.java | 17 +++++- ...dAlbumPhotoUploadLimitIntegrationTest.java | 46 ++++++++++++++ .../scheduler/PhotoSweepSchedulerTest.java | 61 +++++++++++++++++++ .../photo/service/PhotoUploadServiceTest.java | 39 ++++++++++++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepSchedulerTest.java diff --git a/docs/architecture/backend-architecture.md b/docs/architecture/backend-architecture.md index 580b5a8..7e3b7d8 100644 --- a/docs/architecture/backend-architecture.md +++ b/docs/architecture/backend-architecture.md @@ -99,8 +99,9 @@ - **동시성 캡:** `@Async` 무제한 금지, `ThreadPoolTaskExecutor`로 동시 디코딩 수를 인스턴스 자원에 맞춰 제한(6.7 참조). - **인프로세스 워커:** 별도 프로세스/인스턴스가 아니라 API와 같은 프로세스의 바운드 풀. 프로세스 분리가 없으므로 릴레이도 불필요. - **스윕:** 재시작으로 인메모리 큐가 날아가도 원본은 안전하므로, 주기적 스윕이 `PENDING`/`FAILED`를 재처리. +- **큐 초과 시 안전 처리:** 바운드 풀의 대기열이 가득 차면 제출 시점에 `RejectedExecutionException`이 난다. 업로드 완료 등록의 커밋 후 콜백(`afterCommit`)에서 이 예외를 못 잡으면, 사진은 이미 커밋됐는데 응답만 500으로 깨지는 상태 불일치가 생긴다(#86). 제출 실패는 로그만 남기고 삼키며, 사진은 기본값인 `PENDING`에 남아 위 스윕이 재수거한다 — 최초 제출 경로(`PhotoUploadService`)와 스윕 재제출 경로(`PhotoSweepScheduler`) 모두 동일하게 처리한다. -**근거.** 썸네일 잡은 유실돼도 원본으로 재생성 가능하다. 원본이 이미 영속화돼 있다는 사실을 지렛대 삼아, Redis Queue 없이 DB 상태만으로 "재시작 견딤"을 얻는다. +**근거.** 썸네일 잡은 유실돼도 원본으로 재생성 가능하다. 원본이 이미 영속화돼 있다는 사실을 지렛대 삼아, Redis Queue 없이 DB 상태만으로 "재시작 견딤"을 얻는다. 큐 용량 자체를 실측 기반으로 얼마로 잡을지는 별도 과제다(#107). ### 4.4 실시간 미도입 — 전면 폴링 + 푸시 diff --git a/src/main/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepScheduler.java b/src/main/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepScheduler.java index f2646b4..baea2ef 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepScheduler.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepScheduler.java @@ -4,6 +4,7 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.concurrent.RejectedExecutionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -64,7 +65,20 @@ public void resubmitStaleThumbnailJobs() { photoRepository.findByDeletedAtIsNullAndThumbnailStatusInAndUpdatedAtLessThanEqual( List.of(PhotoThumbnailStatus.PENDING, PhotoThumbnailStatus.FAILED), staleBefore); - staleJobs.forEach(photo -> thumbnailProcessingService.process(photo.getId())); + + // thumbnailExecutor 큐가 가득 차면 여기서도 RejectedExecutionException이 날 수 있다. 한 사진에서 + // 터진다고 나머지를 포기하면 이번 스윕 사이클에서 재수거하려던 사진들이 통째로 다음 사이클로 밀리므로, + // 사진 단위로 잡고 넘어가 나머지는 계속 재제출한다(다음 사이클에도 여전히 stale하면 다시 시도된다). + for (Photo photo : staleJobs) { + try { + thumbnailProcessingService.process(photo.getId()); + } catch (RejectedExecutionException exception) { + log.warn( + "[PhotoSweep] 썸네일 작업 큐가 가득 차 재제출에 실패했습니다. photoId={}", + photo.getId(), + exception); + } + } } @Scheduled(fixedDelayString = "${photo.sweep.photo-purge-interval:PT1H}") diff --git a/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadService.java b/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadService.java index 53e8aba..219c322 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadService.java @@ -13,8 +13,10 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -43,6 +45,7 @@ import org.zipzip.zipzipserver.global.exception.BusinessException; import org.zipzip.zipzipserver.global.idempotency.IdempotencyService; +@Slf4j @Service @RequiredArgsConstructor public class PhotoUploadService { @@ -228,11 +231,23 @@ private void submitThumbnailJobsAfterCommit(List photoIds) { new TransactionSynchronization() { @Override public void afterCommit() { - photoIds.forEach(thumbnailProcessingService::process); + photoIds.forEach(PhotoUploadService.this::submitThumbnailJob); } }); } + // afterCommit() 콜백에서 던진 예외는 이미 커밋된 트랜잭션의 호출자(컨트롤러)에게까지 그대로 전파된다(Spring + // TransactionSynchronization 명세). thumbnailExecutor 큐가 가득 차면 여기서 RejectedExecutionException이 + // 나는데, 이미 사진은 정상 커밋된 상태라 응답만 500으로 깨지면 상태 불일치가 된다. 제출 실패 시 사진은 기본값인 + // PENDING에 머물러 있으므로 로그만 남기고 넘기면 PhotoSweepScheduler가 나중에 재수거한다. + private void submitThumbnailJob(UUID photoId) { + try { + thumbnailProcessingService.process(photoId); + } catch (RejectedExecutionException exception) { + log.warn("[PhotoUpload] 썸네일 작업 큐가 가득 차 제출에 실패했습니다. photoId={}", photoId, exception); + } + } + private List validateFileSpecs( List files) { if (files == null || files.isEmpty()) { diff --git a/src/test/java/org/zipzip/zipzipserver/domain/photo/controller/SharedAlbumPhotoUploadLimitIntegrationTest.java b/src/test/java/org/zipzip/zipzipserver/domain/photo/controller/SharedAlbumPhotoUploadLimitIntegrationTest.java index a53220c..faf419b 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/photo/controller/SharedAlbumPhotoUploadLimitIntegrationTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/photo/controller/SharedAlbumPhotoUploadLimitIntegrationTest.java @@ -3,6 +3,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -20,10 +22,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.task.TaskRejectedException; import org.springframework.http.MediaType; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import org.testcontainers.containers.PostgreSQLContainer; @@ -31,11 +35,14 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum; import org.zipzip.zipzipserver.domain.auth.jwt.JwtTokenProvider; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoUploadReservation; +import org.zipzip.zipzipserver.domain.photo.service.ThumbnailProcessingService; import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupMembership; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole; import org.zipzip.zipzipserver.domain.storage.ObjectStorageService; +import org.zipzip.zipzipserver.domain.storage.PresignedDownload; import org.zipzip.zipzipserver.domain.storage.PresignedUpload; import org.zipzip.zipzipserver.domain.user.entity.AppUser; @@ -63,6 +70,7 @@ static void configureDataSource(DynamicPropertyRegistry registry) { @Autowired private JwtTokenProvider jwtTokenProvider; @MockitoBean private ObjectStorageService objectStorageService; + @MockitoBean private ThumbnailProcessingService thumbnailProcessingService; private AppUser uploader; private SharedAlbum sharedAlbum; @@ -150,6 +158,44 @@ void setUp() { .andExpect(jsonPath("$.code").value("INVALID_UPLOAD_METADATA")); } + @Test + void 완료_등록_시_썸네일_큐가_가득_차도_사진_생성은_201로_성공한다() throws Exception { + String objectKey = "object-key-" + UUID.randomUUID(); + persist( + PhotoUploadReservation.create( + objectKey, sharedAlbum, uploader, Instant.now().plusSeconds(900))); + entityManager.flush(); + entityManager.clear(); + // afterCommit() 콜백은 실제 커밋이 일어나야 발화한다. 테스트 기본 트랜잭션(롤백 전제)에 얹힌 채로 + // MockMvc를 호출하면 완료등록 서비스의 트랜잭션이 테스트 메서드가 끝날 때까지 실제로 커밋되지 않아, 응답을 + // 검증하는 시점엔 afterCommit()이 아직 실행되기 전이라 버그를 재현하지 못한다. 지금까지의 픽스처(uploader, + // sharedAlbum, 예약)를 실제로 커밋하고 이후 요청은 별도의 진짜 트랜잭션에서 처리되도록 한다. + TestTransaction.flagForCommit(); + TestTransaction.end(); + when(objectStorageService.exists(objectKey)).thenReturn(true); + when(objectStorageService.issueDownloadUrl(eq(objectKey), any())) + .thenReturn( + new PresignedDownload( + "https://original-url", Instant.now().plusSeconds(600))); + doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다")) + .when(thumbnailProcessingService) + .process(any()); + String requestBody = + objectMapper.writeValueAsString( + Map.of("files", List.of(Map.of("objectKey", objectKey)))); + + mockMvc.perform( + post( + "/api/v1/shared-albums/{sharedAlbumId}/photos/complete", + sharedAlbum.getId()) + .header("Authorization", bearerToken(uploader)) + .header("Idempotency-Key", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.items.length()").value(1)); + } + private Map uploadUrlRequestBody(int fileCount, long sizeBytes) { List> files = new ArrayList<>(); for (int i = 0; i < fileCount; i++) { diff --git a/src/test/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepSchedulerTest.java b/src/test/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepSchedulerTest.java new file mode 100644 index 0000000..b62a7e9 --- /dev/null +++ b/src/test/java/org/zipzip/zipzipserver/domain/photo/scheduler/PhotoSweepSchedulerTest.java @@ -0,0 +1,61 @@ +package org.zipzip.zipzipserver.domain.photo.scheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.task.TaskRejectedException; +import org.zipzip.zipzipserver.domain.photo.entity.Photo; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; +import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; +import org.zipzip.zipzipserver.domain.photo.repository.PhotoUploadReservationRepository; +import org.zipzip.zipzipserver.domain.photo.service.PhotoPurgeService; +import org.zipzip.zipzipserver.domain.photo.service.ThumbnailProcessingService; +import org.zipzip.zipzipserver.domain.storage.ObjectStorageService; +import org.zipzip.zipzipserver.domain.user.entity.AppUser; + +@ExtendWith(MockitoExtension.class) +class PhotoSweepSchedulerTest { + + @Mock private PhotoRepository photoRepository; + @Mock private PhotoUploadReservationRepository photoUploadReservationRepository; + @Mock private ObjectStorageService objectStorageService; + @Mock private ThumbnailProcessingService thumbnailProcessingService; + @Mock private PhotoPurgeService photoPurgeService; + + @InjectMocks private PhotoSweepScheduler photoSweepScheduler; + + @Test + void 썸네일_재제출_한_사진의_큐가_가득_차도_나머지_사진은_계속_재제출한다() { + AppUser uploader = AppUser.create("apple-subject-" + UUID.randomUUID(), "업로더"); + Photo rejectedPhoto = staleThumbnailPhoto(uploader); + Photo recoveredPhoto = staleThumbnailPhoto(uploader); + when(photoRepository.findByDeletedAtIsNullAndThumbnailStatusInAndUpdatedAtLessThanEqual( + eq(List.of(PhotoThumbnailStatus.PENDING, PhotoThumbnailStatus.FAILED)), + any(Instant.class))) + .thenReturn(List.of(rejectedPhoto, recoveredPhoto)); + doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다")) + .when(thumbnailProcessingService) + .process(rejectedPhoto.getId()); + + photoSweepScheduler.resubmitStaleThumbnailJobs(); + + verify(thumbnailProcessingService).process(rejectedPhoto.getId()); + verify(thumbnailProcessingService).process(recoveredPhoto.getId()); + } + + private Photo staleThumbnailPhoto(AppUser uploader) { + return Photo.create( + uploader, "iPhone 15", "photos/" + UUID.randomUUID() + ".jpg", null, null, null); + } +} diff --git a/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadServiceTest.java index e981bf1..03d0dcc 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoUploadServiceTest.java @@ -1,11 +1,13 @@ package org.zipzip.zipzipserver.domain.photo.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -28,6 +30,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum; import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; @@ -400,6 +404,41 @@ albumId, userId, new PhotoUploadUrlRequest(files))) .complete(any(ApiIdempotencyRecord.class), any(), eq(result.response())); } + @Test + void 완료등록_썸네일_큐가_가득_차도_커밋_후_콜백에서_예외가_전파되지_않는다() { + UUID albumId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + String idempotencyKeyHeader = UUID.randomUUID().toString(); + PhotoUploadCompleteRequest request = completeRequestFor("object-key"); + + givenFreshIdempotencyStart(albumId, userId, idempotencyKeyHeader, request); + when(appUserRepository.getReferenceById(userId)).thenReturn(uploader); + PhotoUploadReservation usableReservation = + PhotoUploadReservation.create( + "object-key", sharedAlbum, uploader, Instant.now().plusSeconds(900)); + when(photoUploadReservationRepository.findById("object-key")) + .thenReturn(Optional.of(usableReservation)); + when(objectStorageService.exists("object-key")).thenReturn(true); + when(photoRepository.save(any(Photo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(objectStorageService.issueDownloadUrl(eq("object-key"), any(Duration.class))) + .thenReturn( + new PresignedDownload( + "https://original-url", Instant.now().plusSeconds(600))); + doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다")) + .when(thumbnailProcessingService) + .process(any(UUID.class)); + + photoUploadService.completeUpload(albumId, userId, idempotencyKeyHeader, request); + + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + assertThat(synchronizations).isNotEmpty(); + assertThatCode(() -> synchronizations.forEach(TransactionSynchronization::afterCommit)) + .doesNotThrowAnyException(); + verify(thumbnailProcessingService).process(any(UUID.class)); + } + @Test void 완료등록_파일_목록이_비어있으면_INVALID_UPLOAD_METADATA() { UUID albumId = UUID.randomUUID();