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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<S3Folder> MEMBER_OWNED_FOLDERS = List.of(S3Folder.PROFILE, S3Folder.JOURNAL);

private final JournalRepository journalRepository;
private final PlaceImageRepository placeImageRepository;
Expand Down Expand Up @@ -121,6 +126,52 @@ public void deleteImage(String imageUrl, Long memberId) {
log.info("S3 이미지 삭제 완료: key={}", key);
}

/**
* 파기 대상 회원이 올린 이미지(프로필/일지)를 prefix 단위로 모두 삭제한다.
* <p>
* 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<ObjectIdentifier> 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;
}

/**
* 장소 사진은 회원 소유 경로가 아니므로 소유권 대신 관리자 권한으로 판정한다.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,9 +25,10 @@
import java.util.stream.Collectors;

/**
* 유예 기간이 끝난 탈퇴 회원의 소셜 연동(Apple/카카오)을 해제하고 파기시킨다. 삭제는 WithdrawnMemberPurger가 한다.
* 유예 기간이 끝난 탈퇴 회원의 소셜 연동(Apple/카카오)을 해제하고 S3 업로드 이미지를 지운 뒤 파기시킨다.
* DB 삭제는 WithdrawnMemberPurger가 한다.
* <p>
* 트랜잭션을 열지 않는다. Apple/카카오 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다.
* 트랜잭션을 열지 않는다. Apple/카카오/S3 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다.
* <p>
* revoke/unlink를 탈퇴 요청 즉시가 아니라 이 배치(유예 종료) 시점에 하는 이유: 유예 기간 중에는 재로그인으로
* 계정이 복구될 수 있는데, 탈퇴 즉시 끊어버리면 복구된 계정의 소셜 연동 상태가 DB와 어긋난다.
Expand All @@ -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시)와 겹치지 않게 띄운다.
Expand All @@ -64,15 +67,27 @@ public void purgeExpiredWithdrawals() {
Set<Long> appleFailedMemberIds = revokeAppleTokens(targetIds);
Set<Long> kakaoFailedMemberIds = unlinkKakaoAccounts(targetIds);

List<Long> purgeTargets = targetIds.stream()
List<Long> 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<Long> s3FailedMemberIds = deleteUploadedImages(socialReleasedIds);

List<Long> purgeTargets = socialReleasedIds.stream()
.filter(id -> !s3FailedMemberIds.contains(id))
.toList();

if (purgeTargets.isEmpty()) {
log.warn("S3 이미지 삭제에 모두 실패해 이번 파기를 건너뛴다: memberIds={}", socialReleasedIds);
return;
}

withdrawnMemberPurger.purge(purgeTargets);
}

Expand Down Expand Up @@ -103,7 +118,7 @@ private Set<Long> revokeAppleTokens(List<Long> targetIds) {
}
}

logFailures("Apple", failedMemberIds, attemptedMemberIds.size());
logFailures("Apple 연동 해제", failedMemberIds, attemptedMemberIds.size());
return failedMemberIds;
}

Expand Down Expand Up @@ -137,21 +152,29 @@ private Set<Long> unlinkKakaoAccounts(List<Long> targetIds) {
}
}

logFailures("카카오", failedMemberIds, attemptedMemberIds.size());
logFailures("카카오 연동 해제", failedMemberIds, attemptedMemberIds.size());
return failedMemberIds;
}

// 실패한 회원은 WITHDRAWN 상태로 남으므로 다음 배치가 같은 prefix로 다시 시도한다
private Set<Long> deleteUploadedImages(List<Long> memberIds) {
Set<Long> failedMemberIds = memberIds.stream()
.filter(memberId -> !imageCommandService.deleteAllOfMember(memberId))
.collect(Collectors.toSet());

logFailures("S3 이미지 삭제", failedMemberIds, memberIds.size());
return failedMemberIds;
}

// 일부만 실패하면 그 회원들 refresh_token/연동 정보가 아직 남아있어 다음 배치가 알아서 재시도한다(WARN으로 충분).
// 시도한 회원 전원이 실패하면 개별 계정 문제가 아니라 어드민 키 만료·인증서 문제 같은 설정/연동 자체의
// 장애일 가능성이 높고, 그 상태로는 파기가 계속 밀리므로 놓치지 않도록 ERROR로 올린다.
private void logFailures(String provider, Set<Long> failedMemberIds, int attemptedCount) {
// 일부 실패는 다음 배치가 재시도하므로 WARN, 전원 실패는 키·권한 같은 설정 장애일 가능성이 높아 ERROR로 남긴다.
private void logFailures(String task, Set<Long> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -329,4 +341,107 @@ void validatePlaceImageUrl() {
.isInstanceOf(CustomException.class);
}

private void givenS3Objects(Map<String, List<String>> 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<S3Object> 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<String> deletedKeys() {
ArgumentCaptor<DeleteObjectsRequest> 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<ListObjectsV2Request> 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();
}

}
Loading
Loading