From 100bde964a3d30641cd063890cf800bce89ed90c Mon Sep 17 00:00:00 2001 From: hwanggyuun Date: Sun, 4 Oct 2026 00:21:20 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20S3=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=82=AD=EC=A0=9C=EC=97=90=EC=84=9C=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EB=B3=84=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=EC=9C=BC=EB=A1=9C=20=EC=84=B8=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeleteObjects 응답의 오류를 확인해 일시 오류만 다시 지우고, 남은 실패 키는 로그로 남긴다. Co-authored-by: Cursor --- .../domain/image/service/S3Service.java | 93 +++++++++++++++- .../service/S3ServiceManagedMediaTest.java | 105 ++++++++++++++++++ 2 files changed, 193 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/afternote/domain/image/service/S3Service.java b/src/main/java/com/afternote/domain/image/service/S3Service.java index 2d90ccc..eb48820 100644 --- a/src/main/java/com/afternote/domain/image/service/S3Service.java +++ b/src/main/java/com/afternote/domain/image/service/S3Service.java @@ -14,11 +14,14 @@ import software.amazon.awssdk.services.s3.model.Delete; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.DeletedObject; import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Error; import software.amazon.awssdk.services.s3.model.S3Exception; import software.amazon.awssdk.services.s3.model.S3Object; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -27,6 +30,7 @@ import java.time.Duration; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -84,6 +88,11 @@ public class S3Service { private static final Set ALLOWED_DIRECTORIES = Set.of( "profiles", "timeletters", "afternotes", "mindrecords", "documents" ); + /** 같은 탈퇴 호출 안에서 객체별 일시 오류를 다시 지우는 횟수. */ + private static final int MAX_TRANSIENT_DELETE_RETRIES = 2; + private static final Set TRANSIENT_DELETE_ERROR_CODES = Set.of( + "InternalError", "ServiceUnavailable", "SlowDown", "RequestTimeout" + ); private static final Set IMAGE_EXTENSIONS = Set.of( "jpg", "jpeg", "png", "gif", "webp", "heic" ); @@ -334,6 +343,7 @@ public void deleteManagedObject(String rawUrlOrKey, String directory) { * 탈퇴한 사용자가 올린 staging·permanent 객체를 디렉터리마다 지운다. * {@code receiver} 공용 prefix 와 다른 사용자 키는 건드리지 않는다. * S3 장애는 탈퇴 요청을 실패시키지 않는다. + * 객체별 삭제 실패는 성공으로 세지 않고, 키와 오류 코드를 error 로그로 남긴다. */ public void deleteAllOwnedByUser(Long userId) { if (userId == null) { @@ -365,11 +375,7 @@ private void deleteOwnedPrefix(String prefix) { } } if (!objects.isEmpty()) { - s3Client.deleteObjects(DeleteObjectsRequest.builder() - .bucket(bucket) - .delete(Delete.builder().objects(objects).quiet(true).build()) - .build()); - log.debug("Deleted {} S3 objects under {}", objects.size(), prefix); + deleteObjectBatch(prefix, objects); } continuationToken = Boolean.TRUE.equals(page.isTruncated()) ? page.nextContinuationToken() @@ -380,6 +386,83 @@ private void deleteOwnedPrefix(String prefix) { } } + /** + * {@code DeleteObjects} 는 요청이 성공해도 객체별 오류를 돌려준다. + * 성공 목록에 있는 키만 삭제된 것으로 센다. 일시 오류만 다시 지우고, + * 권한 오류와 재시도 뒤에 남은 키는 탈퇴 후에도 찾을 수 있게 error 로 남긴다. + */ + private void deleteObjectBatch(String prefix, List requested) { + List pending = requested; + int deletedCount = 0; + List unresolved = new ArrayList<>(); + + for (int attempt = 0; attempt <= MAX_TRANSIENT_DELETE_RETRIES && !pending.isEmpty(); attempt++) { + DeleteObjectsResponse response = s3Client.deleteObjects(DeleteObjectsRequest.builder() + .bucket(bucket) + .delete(Delete.builder().objects(pending).quiet(false).build()) + .build()); + if (response == null) { + for (ObjectIdentifier object : pending) { + unresolved.add(missingDeleteOutcome(object.key())); + } + break; + } + + List deleted = response.deleted() == null ? List.of() : response.deleted(); + List errors = response.errors() == null ? List.of() : response.errors(); + Set deletedKeys = new HashSet<>(); + for (DeletedObject object : deleted) { + if (object.key() != null) { + deletedKeys.add(object.key()); + } + } + deletedCount += deletedKeys.size(); + + Set errorKeys = new HashSet<>(); + List retryable = new ArrayList<>(); + boolean canRetry = attempt < MAX_TRANSIENT_DELETE_RETRIES; + for (S3Error error : errors) { + String key = error.key(); + if (key != null && (deletedKeys.contains(key) || !errorKeys.add(key))) { + continue; + } + if (canRetry && isTransientDeleteError(error.code()) && key != null) { + retryable.add(ObjectIdentifier.builder().key(key).build()); + } else { + unresolved.add(error); + } + } + for (ObjectIdentifier object : pending) { + String key = object.key(); + if (key == null || deletedKeys.contains(key) || errorKeys.contains(key)) { + continue; + } + unresolved.add(missingDeleteOutcome(key)); + } + pending = retryable; + } + + for (S3Error error : unresolved) { + log.error("S3 object delete failed key={} code={} message={}", + error.key(), error.code(), error.message()); + } + if (deletedCount > 0) { + log.debug("Deleted {} S3 objects under {}", deletedCount, prefix); + } + } + + private static boolean isTransientDeleteError(String code) { + return code != null && TRANSIENT_DELETE_ERROR_CODES.contains(code); + } + + private static S3Error missingDeleteOutcome(String key) { + return S3Error.builder() + .key(key) + .code("MissingDeleteOutcome") + .message("DeleteObjects response omitted this key") + .build(); + } + public String resolvePublicUrl(String rawUrlOrKey) { if (!StringUtils.hasText(rawUrlOrKey)) { return null; diff --git a/src/test/java/com/afternote/domain/image/service/S3ServiceManagedMediaTest.java b/src/test/java/com/afternote/domain/image/service/S3ServiceManagedMediaTest.java index de4e71a..bc90467 100644 --- a/src/test/java/com/afternote/domain/image/service/S3ServiceManagedMediaTest.java +++ b/src/test/java/com/afternote/domain/image/service/S3ServiceManagedMediaTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -14,12 +15,16 @@ import software.amazon.awssdk.services.s3.model.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.DeletedObject; import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Error; import software.amazon.awssdk.services.s3.model.S3Exception; import software.amazon.awssdk.services.s3.model.S3Object; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -146,6 +151,8 @@ void deleteAllOwnedByUser_DeletesOwnerPrefixesOnly() { return ListObjectsV2Response.builder().isTruncated(false).build(); }); + givenDeleteObjectsSucceeds(); + s3Service.deleteAllOwnedByUser(10L); org.mockito.ArgumentCaptor listCaptor = @@ -167,6 +174,7 @@ void deleteAllOwnedByUser_DeletesOwnerPrefixesOnly() { org.mockito.ArgumentCaptor deleteCaptor = org.mockito.ArgumentCaptor.forClass(DeleteObjectsRequest.class); verify(s3Client).deleteObjects(deleteCaptor.capture()); + assertThat(deleteCaptor.getValue().delete().quiet()).isFalse(); List deleted = deleteCaptor.getValue().delete().objects().stream() .map(id -> id.key()) .toList(); @@ -192,6 +200,7 @@ void deleteAllOwnedByUser_Paginates() { } return "next".equals(request.continuationToken()) ? second : first; }); + givenDeleteObjectsSucceeds(); s3Service.deleteAllOwnedByUser(7L); @@ -225,6 +234,102 @@ void deleteAllOwnedByUser_S3Failure_DoesNotThrow() { assertThatCode(() -> s3Service.deleteAllOwnedByUser(3L)).doesNotThrowAnyException(); } + @Test + @DisplayName("탈퇴 퍼지에서 객체별 오류가 없으면 재시도하지 않는다") + void deleteAllOwnedByUser_AllDeleted_DoesNotRetry() { + givenListedKeys("profiles/permanent/10/", "profiles/permanent/10/a.jpg"); + givenDeleteObjectsSucceeds(); + + s3Service.deleteAllOwnedByUser(10L); + + verify(s3Client).deleteObjects(any(DeleteObjectsRequest.class)); + } + + @Test + @DisplayName("탈퇴 퍼지의 AccessDenied 키는 성공으로 세지 않고 재시도하지 않는다") + void deleteAllOwnedByUser_PartialAccessDenied_DoesNotRetryDeniedKey() { + givenListedKeys( + "profiles/permanent/10/", + "profiles/permanent/10/a.jpg", + "profiles/permanent/10/b.jpg"); + given(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).willReturn(DeleteObjectsResponse.builder() + .deleted(DeletedObject.builder().key("profiles/permanent/10/a.jpg").build()) + .errors(S3Error.builder() + .key("profiles/permanent/10/b.jpg") + .code("AccessDenied") + .message("Simulated per-object failure") + .build()) + .build()); + + assertThatCode(() -> s3Service.deleteAllOwnedByUser(10L)).doesNotThrowAnyException(); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client).deleteObjects(deleteCaptor.capture()); + assertThat(keysOf(deleteCaptor.getValue())).containsExactly( + "profiles/permanent/10/a.jpg", + "profiles/permanent/10/b.jpg"); + } + + @Test + @DisplayName("탈퇴 퍼지의 InternalError 는 실패 키만 두 번 더 지운 뒤 멈춘다") + void deleteAllOwnedByUser_AllInternalError_RetriesTwiceThenStops() { + givenListedKeys("profiles/permanent/10/", "profiles/permanent/10/a.jpg"); + given(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).willReturn(deleteError( + "profiles/permanent/10/a.jpg", "InternalError")); + + assertThatCode(() -> s3Service.deleteAllOwnedByUser(10L)).doesNotThrowAnyException(); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client, org.mockito.Mockito.times(3)).deleteObjects(deleteCaptor.capture()); + assertThat(deleteCaptor.getAllValues()).allSatisfy(request -> + assertThat(keysOf(request)).containsExactly("profiles/permanent/10/a.jpg")); + } + + @Test + @DisplayName("탈퇴 퍼지의 AccessDenied 전부 실패는 다시 지우지 않는다") + void deleteAllOwnedByUser_AllAccessDenied_DoesNotRetry() { + givenListedKeys("profiles/permanent/10/", "profiles/permanent/10/a.jpg"); + given(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).willReturn(deleteError( + "profiles/permanent/10/a.jpg", "AccessDenied")); + + assertThatCode(() -> s3Service.deleteAllOwnedByUser(10L)).doesNotThrowAnyException(); + + verify(s3Client).deleteObjects(any(DeleteObjectsRequest.class)); + } + + private void givenListedKeys(String prefix, String... keys) { + given(s3Client.listObjectsV2(any(ListObjectsV2Request.class))).willAnswer(invocation -> { + ListObjectsV2Request request = invocation.getArgument(0); + if (!prefix.equals(request.prefix())) { + return ListObjectsV2Response.builder().isTruncated(false).build(); + } + List contents = Arrays.stream(keys) + .map(key -> S3Object.builder().key(key).build()) + .toList(); + return ListObjectsV2Response.builder().contents(contents).isTruncated(false).build(); + }); + } + + private void givenDeleteObjectsSucceeds() { + given(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).willAnswer(invocation -> { + DeleteObjectsRequest request = invocation.getArgument(0); + List deleted = request.delete().objects().stream() + .map(object -> DeletedObject.builder().key(object.key()).build()) + .toList(); + return DeleteObjectsResponse.builder().deleted(deleted).errors(List.of()).build(); + }); + } + + private static DeleteObjectsResponse deleteError(String key, String code) { + return DeleteObjectsResponse.builder() + .errors(S3Error.builder().key(key).code(code).message(code).build()) + .build(); + } + + private static List keysOf(DeleteObjectsRequest request) { + return request.delete().objects().stream().map(object -> object.key()).toList(); + } + private static S3Exception s3Exception(int statusCode, String errorCode) { return (S3Exception) S3Exception.builder() .statusCode(statusCode)