diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 538b8e80..38dd2757 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -680,11 +680,30 @@ private String resolveStoredImageKey(String rawReference, BattleStatus targetSta return null; } if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) { - return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService); + // 실제 S3 업로드/로컬 draft 삭제는 커밋 이후로 미룬다. + // 여기서 바로 승격시키면, 이후 같은 트랜잭션에서 DB 저장이 실패해 롤백돼도 + // 이미 올라간 S3 파일과 지워진 draft는 되돌릴 수 없어 orphan이 된다. + String s3Key = localDraftFileStorageService.resolveS3Key(normalized, fallbackCategory); + promoteDraftAfterCommit(normalized, s3Key); + return s3Key; } return normalized; } + private void promoteDraftAfterCommit(String localReference, String s3Key) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + }); + return; + } + + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) { if (rawReference == null || rawReference.isBlank()) { return null; diff --git a/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java b/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java index c7b037b1..4813ba61 100644 --- a/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java +++ b/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java @@ -90,7 +90,13 @@ public String toPublicUrl(String localKey) { return safeBaseUrl + LOCAL_RESOURCE_PREFIX + extractFileName(normalizeLocalDraftKey(localKey)); } - public String promoteLocalDraftToS3(String rawReference, FileCategory category, S3UploadService s3UploadService) { + /** + * draft가 승격될 목적지 S3 key를 계산만 한다(업로드/삭제 같은 부수효과 없음). + * DB 트랜잭션 중간에 엔티티에 최종 key를 미리 채워 넣어야 할 때 쓰고, + * 실제 업로드는 트랜잭션 커밋 후 {@link #promoteToS3}로 수행해서 + * "S3엔 올라갔는데 DB 저장은 롤백됨" 같은 불일치를 막는다. + */ + public String resolveS3Key(String rawReference, FileCategory category) { String normalized = normalizeLocalDraftKey(rawReference); if (!isLocalDraftReference(normalized)) { return normalized; @@ -102,10 +108,28 @@ public String promoteLocalDraftToS3(String rawReference, FileCategory category, } String fileName = extractFileName(normalized); - String s3Key = category.getPath() + "/" + fileName; + return category.getPath() + "/" + fileName; + } + + /** + * {@link #resolveS3Key}로 미리 계산해둔 key로 실제 업로드하고 로컬 draft를 지운다. + * DB 트랜잭션 커밋 이후에만 호출해야 한다 — 롤백 시에는 호출되지 않아야 + * draft 파일이 남아있어서 다시 시도할 수 있다. + */ + public void promoteToS3(String rawReference, String s3Key, S3UploadService s3UploadService) { + String normalized = normalizeLocalDraftKey(rawReference); + if (!isLocalDraftReference(normalized)) { + return; + } + + Path localPath = resolvePath(normalized); + if (!Files.exists(localPath)) { + // 이미 승격되었거나 삭제됨 - 할 일 없음 + return; + } + s3UploadService.uploadFile(s3Key, localPath.toFile()); deleteIfLocalReference(normalized); - return s3Key; } public void deleteIfLocalReference(String rawReference) { diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java index a51cf36c..0998a24e 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java @@ -29,9 +29,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; -import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.time.LocalDate; import java.util.LinkedHashMap; @@ -40,9 +38,6 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -349,12 +344,16 @@ void pending_local_images_are_promoted_to_s3_on_publish() throws Exception { .content(objectMapper.writeValueAsString(publishPayload))) .andExpect(status().isOk()); + // 실제 S3 업로드/로컬 draft 삭제는 DB 트랜잭션 커밋 이후로 미뤄진다 + // (트랜잭션 중간에 실패해도 이미 올라간 파일이 orphan되는 걸 막기 위함 — [[s3_railway_bucket_migration]] 참고). + // 이 테스트는 클래스 레벨 @Transactional로 실제 커밋 없이 끝에 롤백되므로, + // "커밋 이후에만 실제로 승격됨"을 여기서 직접 증명하지는 않는다. + // 그 배선(afterCommit 등록 여부)은 LocalDraftFileStorageServiceTest/단위테스트에서 검증한다. + // 여기서는 요청 처리 중 동기적으로 결정되는 최종 저장 키(목적지 경로)만 확인한다. Battle publishedBattle = battleRepository.findById(battleId).orElseThrow(); assertThat(publishedBattle.getThumbnailUrl()).startsWith("images/battles/"); List publishedOptions = battleOptionRepository.findByBattle(publishedBattle); assertThat(publishedOptions).allMatch(option -> option.getImageUrl().startsWith("images/philosophers/")); - - verify(s3Client, atLeastOnce()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } private String uploadLocalDraftKey(String adminToken, String fileName, String content) throws Exception { diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java index e1cdb0f7..61b61f68 100644 --- a/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java @@ -2,8 +2,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; @@ -27,10 +30,12 @@ import com.swyp.picke.domain.user.service.UserBattleService; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.service.S3UploadService; import com.swyp.picke.domain.user.repository.UserRepository; import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,6 +43,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; @ExtendWith(MockitoExtension.class) class BattleServiceImplTest { @@ -122,4 +128,49 @@ private BattleOption option(Long id, Battle battle) { private AdminBattleOptionRequest optionRequest2() { return new AdminBattleOptionRequest("두번째 옵션", null, null, null, 2, List.of()); } + + @AfterEach + void tearDownSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void 발행_상태로_바뀌며_local_draft_썸네일이면_실제_S3_승격은_커밋_이후로_미룬다() { + Battle battle = battle(1L); + String localDraftKey = "local/drafts/uuid_thumb.png"; + String s3Key = "images/battles/uuid_thumb.png"; + + when(battleRepository.findById(1L)).thenReturn(Optional.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + lenient().when(localDraftFileStorageService.normalizeLocalDraftKey(localDraftKey)).thenReturn(localDraftKey); + when(localDraftFileStorageService.isLocalDraftReference(localDraftKey)).thenReturn(true); + when(localDraftFileStorageService.resolveS3Key(localDraftKey, FileCategory.BATTLE)).thenReturn(s3Key); + lenient().when(battleOptionRepository.save(any(BattleOption.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of()); + + AdminBattleUpdateRequest request = new AdminBattleUpdateRequest( + "제목", null, null, localDraftKey, null, null, null, BattleStatus.PUBLISHED, + null, List.of( + new AdminBattleOptionRequest("옵션1", null, null, null, 1, null), + new AdminBattleOptionRequest("옵션2", null, null, null, 2, null))); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.updateBattle(1L, request); + + // 요청 처리 도중(커밋 전)에는 실제 업로드/삭제가 절대 일어나면 안 된다. + verify(localDraftFileStorageService, never()).promoteToS3(anyString(), anyString(), any()); + org.assertj.core.api.Assertions.assertThat(battle.getThumbnailUrl()).isEqualTo(s3Key); + + // 커밋 이후에야 실제 승격이 일어난다. + TransactionSynchronizationManager.getSynchronizations() + .forEach(org.springframework.transaction.support.TransactionSynchronization::afterCommit); + verify(localDraftFileStorageService).promoteToS3(localDraftKey, s3Key, s3UploadService); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } } diff --git a/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java new file mode 100644 index 00000000..6827c011 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java @@ -0,0 +1,83 @@ +package com.swyp.picke.global.infra.local.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +class LocalDraftFileStorageServiceTest { + + private LocalDraftFileStorageService service; + private S3UploadService s3UploadService; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + service = new LocalDraftFileStorageService(); + ReflectionTestUtils.setField(service, "localStorageRoot", tempDir.toString()); + ReflectionTestUtils.setField(service, "baseUrl", "https://dev.picke.store"); + s3UploadService = mock(S3UploadService.class); + } + + private String saveDraft() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "rousseau.png", "image/png", new byte[]{1, 2, 3}); + return service.saveDraftFile(file); + } + + @Test + void resolveS3Key는_실제_업로드_없이_목적지_key만_계산한다() throws IOException { + String localKey = saveDraft(); + + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + + assertThat(s3Key).startsWith("images/philosophers/").endsWith("rousseau.png"); + // 부수효과 없음: 로컬 파일 그대로 남아있고 S3 호출 없음 + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } + + @Test + void resolveS3Key는_draft_파일이_없으면_예외를_던진다() { + assertThatThrownBy(() -> service.resolveS3Key("local/drafts/missing_file.png", FileCategory.PHILOSOPHER)) + .isInstanceOf(CustomException.class); + } + + @Test + void promoteToS3는_업로드하고_로컬_draft를_삭제한다() throws IOException { + String localKey = saveDraft(); + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + when(s3UploadService.uploadFile(eq(s3Key), any())) + .thenReturn(s3Key); + + service.promoteToS3(localKey, s3Key, s3UploadService); + + verify(s3UploadService).uploadFile(eq(s3Key), any()); + assertThat(service.isLocalDraftReference(localKey)).isTrue(); // 여전히 local draft 형식의 키지만 + // 실제 파일은 삭제됐어야 한다 -> 다시 promote 시도해도 재업로드 없이 조용히 스킵 + service.promoteToS3(localKey, s3Key, s3UploadService); + verify(s3UploadService, times(1)) + .uploadFile(eq(s3Key), any()); + } + + @Test + void promoteToS3는_local_draft가_아니면_아무일도_하지_않는다() { + service.promoteToS3("images/philosophers/already-s3.png", "images/philosophers/already-s3.png", s3UploadService); + + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } +}