From eda913943e4a5e9e8b7fd8283cdfb24637fb75ba Mon Sep 17 00:00:00 2001 From: Haewon Lee Date: Wed, 23 Sep 2026 21:43:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=ED=8C=8C=EA=B8=B0=20=EC=8B=9C=20S3=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/command/ImageCommandService.java | 51 +++++++++++++++++++ .../service/WithdrawnMemberCleaner.java | 33 ++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/cotato/nextstation/domain/image/service/command/ImageCommandService.java b/src/main/java/com/cotato/nextstation/domain/image/service/command/ImageCommandService.java index b7a5530f..82e52065 100644 --- a/src/main/java/com/cotato/nextstation/domain/image/service/command/ImageCommandService.java +++ b/src/main/java/com/cotato/nextstation/domain/image/service/command/ImageCommandService.java @@ -14,6 +14,10 @@ import org.springframework.util.StringUtils; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.S3Error; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; @@ -30,6 +34,7 @@ public class ImageCommandService { private static final Duration PRESIGNED_URL_EXPIRATION = Duration.ofMinutes(10); private static final String ALLOWED_DELETE_PREFIX = "images/uploads/"; private static final String STATIC_PLACE_DELETE_PREFIX = S3Folder.STATIC_PLACE.getPath() + "/"; + private static final List MEMBER_OWNED_FOLDERS = List.of(S3Folder.PROFILE, S3Folder.JOURNAL); private final JournalRepository journalRepository; private final PlaceImageRepository placeImageRepository; @@ -121,6 +126,52 @@ public void deleteImage(String imageUrl, Long memberId) { log.info("S3 이미지 삭제 완료: key={}", key); } + /** + * 파기 대상 회원이 올린 이미지(프로필/일지)를 prefix 단위로 모두 삭제한다. + *

+ * DB의 이미지 URL이 아닌 prefix를 기준으로 하므로 업로드 후 저장되지 않았거나 DB에서만 빠진 파일까지 함께 정리된다. + * + * @return 모두 삭제했으면 true, 목록 조회나 삭제가 하나라도 실패하면 false + */ + public boolean deleteAllOfMember(Long memberId) { + try { + int deleted = 0; + for (S3Folder folder : MEMBER_OWNED_FOLDERS) { + deleted += deleteAllUnder("%s/%d/".formatted(folder.getPath(), memberId)); + } + log.info("탈퇴 회원 S3 이미지 삭제 완료: memberId={}, deleted={}", memberId, deleted); + return true; + } catch (Exception e) { + log.warn("탈퇴 회원 S3 이미지 삭제 실패: memberId={}", memberId, e); + return false; + } + } + + // ListObjectsV2 한 페이지 최대 크기(1000)가 DeleteObjects 한 번의 최대 개수와 같아 페이지 단위로 그대로 삭제한다 + private int deleteAllUnder(String prefix) { + int deleted = 0; + for (ListObjectsV2Response page : s3Client.listObjectsV2Paginator(r -> r.bucket(bucketName).prefix(prefix))) { + List objects = page.contents().stream() + .map(object -> ObjectIdentifier.builder().key(object.key()).build()) + .toList(); + if (objects.isEmpty()) { + continue; + } + + DeleteObjectsResponse response = s3Client.deleteObjects(r -> r.bucket(bucketName) + .delete(d -> d.objects(objects).quiet(true))); + + // DeleteObjects는 일부 객체가 실패해도 200을 반환하므로 errors를 직접 확인한다 + if (response.hasErrors() && !response.errors().isEmpty()) { + S3Error first = response.errors().get(0); + throw new IllegalStateException("S3 일부 객체 삭제 실패: prefix=%s, failed=%d, firstKey=%s, code=%s" + .formatted(prefix, response.errors().size(), first.key(), first.code())); + } + deleted += objects.size(); + } + return deleted; + } + /** * 장소 사진은 회원 소유 경로가 아니므로 소유권 대신 관리자 권한으로 판정한다. *

diff --git a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java index 7da381aa..d34f14ab 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java +++ b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java @@ -2,6 +2,7 @@ import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.KakaoOAuthClient; +import com.cotato.nextstation.domain.image.service.command.ImageCommandService; import com.cotato.nextstation.domain.member.entity.AuthProvider; import com.cotato.nextstation.domain.member.entity.Member; import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; @@ -24,9 +25,10 @@ import java.util.stream.Collectors; /** - * 유예 기간이 끝난 탈퇴 회원의 소셜 연동(Apple/카카오)을 해제하고 파기시킨다. 삭제는 WithdrawnMemberPurger가 한다. + * 유예 기간이 끝난 탈퇴 회원의 소셜 연동(Apple/카카오)을 해제하고 S3 업로드 이미지를 지운 뒤 파기시킨다. + * DB 삭제는 WithdrawnMemberPurger가 한다. *

- * 트랜잭션을 열지 않는다. Apple/카카오 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다. + * 트랜잭션을 열지 않는다. Apple/카카오/S3 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다. *

* revoke/unlink를 탈퇴 요청 즉시가 아니라 이 배치(유예 종료) 시점에 하는 이유: 유예 기간 중에는 재로그인으로 * 계정이 복구될 수 있는데, 탈퇴 즉시 끊어버리면 복구된 계정의 소셜 연동 상태가 DB와 어긋난다. @@ -43,6 +45,7 @@ public class WithdrawnMemberCleaner { private final OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor; private final AppleTokenClient appleTokenClient; private final KakaoOAuthClient kakaoOAuthClient; + private final ImageCommandService imageCommandService; private final WithdrawnMemberPurger withdrawnMemberPurger; // 매일 새벽 4시 30분. 같은 시간대의 EmailVerificationCleaner(4시)와 겹치지 않게 띄운다. @@ -64,15 +67,27 @@ public void purgeExpiredWithdrawals() { Set appleFailedMemberIds = revokeAppleTokens(targetIds); Set kakaoFailedMemberIds = unlinkKakaoAccounts(targetIds); - List purgeTargets = targetIds.stream() + List socialReleasedIds = targetIds.stream() .filter(id -> !appleFailedMemberIds.contains(id) && !kakaoFailedMemberIds.contains(id)) .toList(); - if (purgeTargets.isEmpty()) { + if (socialReleasedIds.isEmpty()) { log.warn("소셜 연동 해제에 모두 실패해 이번 파기를 건너뛴다: memberIds={}", targetIds); return; } + // DB 파기보다 먼저 지운다, 순서가 반대면 실패했을 때 재시도할 회원 행이 사라져 이미지가 영구히 남는다. + Set s3FailedMemberIds = deleteUploadedImages(socialReleasedIds); + + List purgeTargets = socialReleasedIds.stream() + .filter(id -> !s3FailedMemberIds.contains(id)) + .toList(); + + if (purgeTargets.isEmpty()) { + log.warn("S3 이미지 삭제에 모두 실패해 이번 파기를 건너뛴다: memberIds={}", socialReleasedIds); + return; + } + withdrawnMemberPurger.purge(purgeTargets); } @@ -141,6 +156,16 @@ private Set unlinkKakaoAccounts(List targetIds) { return failedMemberIds; } + // 실패한 회원은 WITHDRAWN 상태로 남으므로 다음 배치가 같은 prefix로 다시 시도한다 + private Set deleteUploadedImages(List memberIds) { + Set failedMemberIds = memberIds.stream() + .filter(memberId -> !imageCommandService.deleteAllOfMember(memberId)) + .collect(Collectors.toSet()); + + logFailures("S3 이미지", failedMemberIds, memberIds.size()); + return failedMemberIds; + } + // 일부만 실패하면 그 회원들 refresh_token/연동 정보가 아직 남아있어 다음 배치가 알아서 재시도한다(WARN으로 충분). // 시도한 회원 전원이 실패하면 개별 계정 문제가 아니라 어드민 키 만료·인증서 문제 같은 설정/연동 자체의 // 장애일 가능성이 높고, 그 상태로는 파기가 계속 밀리므로 놓치지 않도록 ERROR로 올린다. From 57c4283cdd8914b2413291394ba0e539e5f30ca7 Mon Sep 17 00:00:00 2001 From: Haewon Lee Date: Wed, 23 Sep 2026 21:44:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=ED=83=88=ED=87=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ImageCommandServiceTest.java | 115 ++++++++++++++++++ .../service/WithdrawnMemberCleanerTest.java | 82 +++++++++++++ 2 files changed, 197 insertions(+) diff --git a/src/test/java/com/cotato/nextstation/domain/image/service/command/ImageCommandServiceTest.java b/src/test/java/com/cotato/nextstation/domain/image/service/command/ImageCommandServiceTest.java index 9c58c046..32e7c904 100644 --- a/src/test/java/com/cotato/nextstation/domain/image/service/command/ImageCommandServiceTest.java +++ b/src/test/java/com/cotato/nextstation/domain/image/service/command/ImageCommandServiceTest.java @@ -22,19 +22,31 @@ import org.springframework.test.util.ReflectionTestUtils; import software.amazon.awssdk.services.s3.S3Client; 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.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.S3Error; +import software.amazon.awssdk.services.s3.model.S3Object; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.net.URI; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; 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.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -329,4 +341,107 @@ void validatePlaceImageUrl() { .isInstanceOf(CustomException.class); } + private void givenS3Objects(Map> keysByPrefix) { + given(s3Client.listObjectsV2Paginator(any(Consumer.class))).willCallRealMethod(); + given(s3Client.listObjectsV2Paginator(any(ListObjectsV2Request.class))).willCallRealMethod(); + given(s3Client.listObjectsV2(any(ListObjectsV2Request.class))).willAnswer(invocation -> { + ListObjectsV2Request request = invocation.getArgument(0); + List contents = keysByPrefix.getOrDefault(request.prefix(), List.of()).stream() + .map(key -> S3Object.builder().key(key).build()) + .toList(); + return ListObjectsV2Response.builder().contents(contents).isTruncated(false).build(); + }); + } + + private void givenDeleteObjectsResponse(DeleteObjectsResponse response) { + given(s3Client.deleteObjects(any(Consumer.class))).willCallRealMethod(); + given(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).willReturn(response); + } + + private List deletedKeys() { + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client, atLeastOnce()).deleteObjects(captor.capture()); + return captor.getAllValues().stream() + .flatMap(request -> request.delete().objects().stream()) + .map(ObjectIdentifier::key) + .toList(); + } + + @Test + @DisplayName("회원 이미지 일괄 삭제는 프로필/일지 경로의 객체를 모두 지우고 true를 반환한다") + void deleteAllOfMember_deletesProfileAndJournal() { + // given + givenS3Objects(Map.of( + "images/uploads/profile/1/", List.of("images/uploads/profile/1/a.jpg"), + "images/uploads/journal/1/", List.of("images/uploads/journal/1/10/b.jpg", "images/uploads/journal/1/c.jpg"))); + givenDeleteObjectsResponse(DeleteObjectsResponse.builder().build()); + + // when + boolean result = imageCommandService.deleteAllOfMember(MEMBER_ID); + + // then + assertThat(result).isTrue(); + assertThat(deletedKeys()).containsExactlyInAnyOrder( + "images/uploads/profile/1/a.jpg", "images/uploads/journal/1/10/b.jpg", "images/uploads/journal/1/c.jpg"); + } + + @Test + @DisplayName("회원 이미지 일괄 삭제는 memberId 뒤에 /를 붙여 조회해 다른 회원(12 등) 경로를 건드리지 않는다") + void deleteAllOfMember_prefixEndsWithSlash() { + // given + givenS3Objects(Map.of()); + + // when + imageCommandService.deleteAllOfMember(MEMBER_ID); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(ListObjectsV2Request.class); + verify(s3Client, times(2)).listObjectsV2(captor.capture()); + assertThat(captor.getAllValues()).extracting(ListObjectsV2Request::prefix) + .containsExactly("images/uploads/profile/1/", "images/uploads/journal/1/"); + } + + @Test + @DisplayName("회원 이미지가 하나도 없으면 삭제 요청 없이 true를 반환한다") + void deleteAllOfMember_noObjects() { + // given + givenS3Objects(Map.of()); + + // when + boolean result = imageCommandService.deleteAllOfMember(MEMBER_ID); + + // then + assertThat(result).isTrue(); + verify(s3Client, never()).deleteObjects(any(DeleteObjectsRequest.class)); + } + + @Test + @DisplayName("DeleteObjects 응답에 errors가 있으면 일부만 실패한 것이므로 false를 반환한다") + void deleteAllOfMember_partialErrors_returnsFalse() { + // given + givenS3Objects(Map.of("images/uploads/profile/1/", List.of("images/uploads/profile/1/a.jpg"))); + givenDeleteObjectsResponse(DeleteObjectsResponse.builder() + .errors(S3Error.builder().key("images/uploads/profile/1/a.jpg").code("AccessDenied").build()) + .build()); + + // when + boolean result = imageCommandService.deleteAllOfMember(MEMBER_ID); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("목록 조회에서 예외가 나면 false를 반환한다") + void deleteAllOfMember_listFailure_returnsFalse() { + // given + given(s3Client.listObjectsV2Paginator(any(Consumer.class))).willThrow(new RuntimeException("AccessDenied")); + + // when + boolean result = imageCommandService.deleteAllOfMember(MEMBER_ID); + + // then + assertThat(result).isFalse(); + } + } diff --git a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java index 6c87aa38..a7356845 100644 --- a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java +++ b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java @@ -6,6 +6,7 @@ import ch.qos.logback.core.read.ListAppender; import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.KakaoOAuthClient; +import com.cotato.nextstation.domain.image.service.command.ImageCommandService; import com.cotato.nextstation.domain.member.entity.AuthProvider; import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; import com.cotato.nextstation.domain.member.entity.MemberStatus; @@ -19,6 +20,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -34,6 +36,8 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.never; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class WithdrawnMemberCleanerTest { @@ -59,6 +63,9 @@ class WithdrawnMemberCleanerTest { @Mock private KakaoOAuthClient kakaoOAuthClient; + @Mock + private ImageCommandService imageCommandService; + @Mock private WithdrawnMemberPurger withdrawnMemberPurger; @@ -73,6 +80,12 @@ void setUpLogCapture() { logger.addAppender(logCapture); } + // S3 삭제는 기본적으로 성공시키고, S3 실패 케이스에서만 덮어쓴다 + @BeforeEach + void setUpS3Success() { + lenient().when(imageCommandService.deleteAllOfMember(any())).thenReturn(true); + } + @AfterEach void tearDownLogCapture() { logger.detachAppender(logCapture); @@ -306,4 +319,73 @@ void purge_allFailed_skipsPurge() { // then - 빈 목록으로 파기를 호출하면 IN () 이 되어 SQL이 깨진다 then(withdrawnMemberPurger).should(never()).purge(any()); } + + @Test + @DisplayName("S3 이미지 삭제는 DB 파기보다 먼저 실행된다") + void purge_deletesS3BeforePurge() { + // given + givenTargets(List.of(1L)); + givenAppleAccounts(List.of()); + givenKakaoAccounts(List.of()); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + InOrder inOrder = inOrder(imageCommandService, withdrawnMemberPurger); + inOrder.verify(imageCommandService).deleteAllOfMember(1L); + inOrder.verify(withdrawnMemberPurger).purge(List.of(1L)); + } + + @Test + @DisplayName("S3 이미지 삭제에 실패한 회원만 파기 대상에서 빠진다 - 다음 배치가 다시 시도한다") + void purge_s3Failure_excludesFromPurgeTargets() { + // given + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of()); + givenKakaoAccounts(List.of()); + given(imageCommandService.deleteAllOfMember(1L)).willReturn(false); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + then(withdrawnMemberPurger).should().purge(List.of(2L)); + assertThat(logCapture.list).extracting(ILoggingEvent::getLevel).contains(Level.WARN).doesNotContain(Level.ERROR); + } + + @Test + @DisplayName("S3 이미지 삭제가 전원 실패하면 ERROR로 남기고 이번 파기를 건너뛴다") + void purge_s3AllFailure_skipsPurgeAndLogsError() { + // given + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of()); + givenKakaoAccounts(List.of()); + given(imageCommandService.deleteAllOfMember(any())).willReturn(false); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + then(withdrawnMemberPurger).should(never()).purge(any()); + assertThat(logCapture.list).extracting(ILoggingEvent::getLevel).contains(Level.ERROR); + } + + @Test + @DisplayName("소셜 연동 해제에 실패한 회원은 S3 이미지도 지우지 않는다 - 다음 배치에서 함께 처리한다") + void purge_socialFailure_skipsS3Delete() { + // given + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of()); + givenKakaoAccounts(List.of(kakaoAccount(1L, "kakao-1"))); + given(kakaoOAuthClient.unlink("kakao-1")).willReturn(false); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + then(imageCommandService).should(never()).deleteAllOfMember(1L); + then(imageCommandService).should().deleteAllOfMember(2L); + then(withdrawnMemberPurger).should().purge(List.of(2L)); + } } From 6999db10701d8b1480dad290401a06a86ad943dd Mon Sep 17 00:00:00 2001 From: Haewon Lee Date: Wed, 23 Sep 2026 22:32:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=8C=8C=EA=B8=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=EC=97=90=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=AA=85=20=EB=93=9C=EB=9F=AC=EB=82=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/WithdrawnMemberCleaner.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java index d34f14ab..fe54c08c 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java +++ b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java @@ -118,7 +118,7 @@ private Set revokeAppleTokens(List targetIds) { } } - logFailures("Apple", failedMemberIds, attemptedMemberIds.size()); + logFailures("Apple 연동 해제", failedMemberIds, attemptedMemberIds.size()); return failedMemberIds; } @@ -152,7 +152,7 @@ private Set unlinkKakaoAccounts(List targetIds) { } } - logFailures("카카오", failedMemberIds, attemptedMemberIds.size()); + logFailures("카카오 연동 해제", failedMemberIds, attemptedMemberIds.size()); return failedMemberIds; } @@ -162,21 +162,19 @@ private Set deleteUploadedImages(List memberIds) { .filter(memberId -> !imageCommandService.deleteAllOfMember(memberId)) .collect(Collectors.toSet()); - logFailures("S3 이미지", failedMemberIds, memberIds.size()); + logFailures("S3 이미지 삭제", failedMemberIds, memberIds.size()); return failedMemberIds; } - // 일부만 실패하면 그 회원들 refresh_token/연동 정보가 아직 남아있어 다음 배치가 알아서 재시도한다(WARN으로 충분). - // 시도한 회원 전원이 실패하면 개별 계정 문제가 아니라 어드민 키 만료·인증서 문제 같은 설정/연동 자체의 - // 장애일 가능성이 높고, 그 상태로는 파기가 계속 밀리므로 놓치지 않도록 ERROR로 올린다. - private void logFailures(String provider, Set failedMemberIds, int attemptedCount) { + // 일부 실패는 다음 배치가 재시도하므로 WARN, 전원 실패는 키·권한 같은 설정 장애일 가능성이 높아 ERROR로 남긴다. + private void logFailures(String task, Set failedMemberIds, int attemptedCount) { if (failedMemberIds.isEmpty()) { return; } if (failedMemberIds.size() == attemptedCount) { - log.error("{} 연동 해제가 전원 실패했다 - 설정/연동 자체의 문제일 수 있다: memberIds={}", provider, failedMemberIds); + log.error("{} 전원 실패 - 설정/연동 자체의 문제일 수 있다: memberIds={}", task, failedMemberIds); } else { - log.warn("{} 연동 해제 실패로 이번 파기에서 제외: memberIds={}", provider, failedMemberIds); + log.warn("{} 실패로 이번 파기에서 제외: memberIds={}", task, failedMemberIds); } } }