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
93 changes: 88 additions & 5 deletions src/main/java/com/afternote/domain/image/service/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -84,6 +88,11 @@ public class S3Service {
private static final Set<String> ALLOWED_DIRECTORIES = Set.of(
"profiles", "timeletters", "afternotes", "mindrecords", "documents"
);
/** 같은 탈퇴 호출 안에서 객체별 일시 오류를 다시 지우는 횟수. */
private static final int MAX_TRANSIENT_DELETE_RETRIES = 2;
private static final Set<String> TRANSIENT_DELETE_ERROR_CODES = Set.of(
"InternalError", "ServiceUnavailable", "SlowDown", "RequestTimeout"
);
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"jpg", "jpeg", "png", "gif", "webp", "heic"
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -380,6 +386,83 @@ private void deleteOwnedPrefix(String prefix) {
}
}

/**
* {@code DeleteObjects} 는 요청이 성공해도 객체별 오류를 돌려준다.
* 성공 목록에 있는 키만 삭제된 것으로 센다. 일시 오류만 다시 지우고,
* 권한 오류와 재시도 뒤에 남은 키는 탈퇴 후에도 찾을 수 있게 error 로 남긴다.
*/
private void deleteObjectBatch(String prefix, List<ObjectIdentifier> requested) {
List<ObjectIdentifier> pending = requested;
int deletedCount = 0;
List<S3Error> 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<DeletedObject> deleted = response.deleted() == null ? List.of() : response.deleted();
List<S3Error> errors = response.errors() == null ? List.of() : response.errors();
Set<String> deletedKeys = new HashSet<>();
for (DeletedObject object : deleted) {
if (object.key() != null) {
deletedKeys.add(object.key());
}
}
deletedCount += deletedKeys.size();

Set<String> errorKeys = new HashSet<>();
List<ObjectIdentifier> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -146,6 +151,8 @@ void deleteAllOwnedByUser_DeletesOwnerPrefixesOnly() {
return ListObjectsV2Response.builder().isTruncated(false).build();
});

givenDeleteObjectsSucceeds();

s3Service.deleteAllOwnedByUser(10L);

org.mockito.ArgumentCaptor<ListObjectsV2Request> listCaptor =
Expand All @@ -167,6 +174,7 @@ void deleteAllOwnedByUser_DeletesOwnerPrefixesOnly() {
org.mockito.ArgumentCaptor<DeleteObjectsRequest> deleteCaptor =
org.mockito.ArgumentCaptor.forClass(DeleteObjectsRequest.class);
verify(s3Client).deleteObjects(deleteCaptor.capture());
assertThat(deleteCaptor.getValue().delete().quiet()).isFalse();
List<String> deleted = deleteCaptor.getValue().delete().objects().stream()
.map(id -> id.key())
.toList();
Expand All @@ -192,6 +200,7 @@ void deleteAllOwnedByUser_Paginates() {
}
return "next".equals(request.continuationToken()) ? second : first;
});
givenDeleteObjectsSucceeds();

s3Service.deleteAllOwnedByUser(7L);

Expand Down Expand Up @@ -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<DeleteObjectsRequest> 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<DeleteObjectsRequest> 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<S3Object> 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<DeletedObject> 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<String> 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)
Expand Down
Loading