Skip to content

Commit f390c1a

Browse files
committed
feat: 멤버 즉시 삭제 기능 구현 (#109)
* feat: 멤버 즉시 삭제 요청 API 구현 * feat: 멤버와 관련된 모든 데이터를 즉시 삭제하는 기능 구현 (Trip, Stamp, Mission, DailyGoal, DailyMission, Pomodoro, StudyLog, StudyLogDailyMission, TripReport, TripReportStudyLog, Image) * feat: 이벤트 기반 이미지 삭제 로직 추가 * chore: S3 스토리지 설정 추가 (retry, time-out) * test: 멤버 즉시 삭제 통합 테스트 추가 * test: 멤버와 관련된 모든 데이터를 즉시 삭제하는 단위 테스트 추가
1 parent 3fef731 commit f390c1a

60 files changed

Lines changed: 1195 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/java/com/ject/studytrip/global/config/S3Config.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.ject.studytrip.global.config;
22

33
import com.ject.studytrip.global.config.properties.S3Properties;
4+
import java.time.Duration;
45
import lombok.RequiredArgsConstructor;
56
import org.springframework.boot.context.properties.EnableConfigurationProperties;
67
import org.springframework.context.annotation.Bean;
78
import org.springframework.context.annotation.Configuration;
89
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
10+
import software.amazon.awssdk.core.retry.RetryMode;
11+
import software.amazon.awssdk.core.retry.RetryPolicy;
912
import software.amazon.awssdk.regions.Region;
1013
import software.amazon.awssdk.services.s3.S3Client;
1114
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
@@ -18,7 +21,21 @@ public class S3Config {
1821

1922
@Bean
2023
public S3Client s3Client() {
24+
RetryPolicy retry =
25+
RetryPolicy.builder(RetryMode.STANDARD)
26+
.numRetries(props.retry().maxAttempts())
27+
.build();
28+
2129
return S3Client.builder()
30+
.overrideConfiguration(
31+
config ->
32+
config.retryPolicy(retry)
33+
.apiCallTimeout(
34+
Duration.ofSeconds(
35+
props.timeout().apiCallInSeconds()))
36+
.apiCallAttemptTimeout(
37+
Duration.ofSeconds(
38+
props.timeout().apiCallAttemptInSeconds())))
2239
.region(Region.of(props.region()))
2340
.credentialsProvider(DefaultCredentialsProvider.create())
2441
.build();

src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,13 @@
33
import org.springframework.boot.context.properties.ConfigurationProperties;
44

55
@ConfigurationProperties(prefix = "aws.s3")
6-
public record S3Properties(String bucket, String region, long presignExpiresInMinutes) {}
6+
public record S3Properties(
7+
String bucket,
8+
String region,
9+
long presignExpiresInMinutes,
10+
S3Retry retry,
11+
S3Timeout timeout) {
12+
public record S3Retry(int maxAttempts) {}
13+
14+
public record S3Timeout(int apiCallInSeconds, int apiCallAttemptInSeconds) {}
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.ject.studytrip.image.application.dto;
2+
3+
import java.util.List;
4+
5+
public record CleanupImagesResult(int success, List<String> failedKeys) {
6+
public static CleanupImagesResult of(int success, List<String> failedKeys) {
7+
return new CleanupImagesResult(success, failedKeys);
8+
}
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.ject.studytrip.image.application.event;
2+
3+
import java.util.List;
4+
5+
public record ImageCleanupBatchEvent(List<String> imageUrls) {
6+
public static ImageCleanupBatchEvent of(List<String> imageUrls) {
7+
return new ImageCleanupBatchEvent(imageUrls);
8+
}
9+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.ject.studytrip.image.application.event;
2+
3+
import com.ject.studytrip.image.application.service.ImageService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.transaction.event.TransactionPhase;
7+
import org.springframework.transaction.event.TransactionalEventListener;
8+
9+
@Component
10+
@RequiredArgsConstructor
11+
public class ImageEventListener {
12+
13+
private final ImageService imageService;
14+
15+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
16+
public void handleCleanupBatch(ImageCleanupBatchEvent event) {
17+
imageService.cleanupBatch(event.imageUrls());
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.ject.studytrip.image.application.event;
2+
3+
import java.util.List;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.context.ApplicationEventPublisher;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class ImageEventPublisher {
11+
12+
private final ApplicationEventPublisher publisher;
13+
14+
public void publishCleanupBatch(List<String> imageUrls) {
15+
if (imageUrls == null || imageUrls.isEmpty()) return;
16+
17+
ImageCleanupBatchEvent event = ImageCleanupBatchEvent.of(imageUrls);
18+
publisher.publishEvent(event);
19+
}
20+
}

src/main/java/com/ject/studytrip/image/application/service/ImageService.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,36 @@
33
import com.ject.studytrip.global.config.properties.CdnProperties;
44
import com.ject.studytrip.global.exception.CustomException;
55
import com.ject.studytrip.global.util.FilenameUtil;
6+
import com.ject.studytrip.image.application.dto.CleanupImagesResult;
67
import com.ject.studytrip.image.application.dto.PresignedImageInfo;
8+
import com.ject.studytrip.image.application.event.ImageEventPublisher;
79
import com.ject.studytrip.image.domain.constants.ImageConstants;
810
import com.ject.studytrip.image.domain.factory.ImageKeyFactory;
911
import com.ject.studytrip.image.domain.policy.ImagePolicy;
1012
import com.ject.studytrip.image.domain.util.ImageUrlUtil;
1113
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
1214
import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider;
1315
import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider;
16+
import java.util.ArrayList;
1417
import java.util.List;
18+
import java.util.Objects;
19+
import java.util.Optional;
1520
import lombok.RequiredArgsConstructor;
21+
import lombok.extern.slf4j.Slf4j;
1622
import org.springframework.stereotype.Service;
1723

1824
@Service
1925
@RequiredArgsConstructor
26+
@Slf4j
2027
public class ImageService {
2128

29+
private static final int MAX_BATCH = 1000;
30+
2231
private final S3ImageStorageProvider s3Provider;
2332
private final TikaImageProbeProvider tikaProvider;
2433

34+
private final ImageEventPublisher publisher;
35+
2536
private final CdnProperties cdnProps;
2637

2738
// Presigned URL 발급
@@ -75,6 +86,47 @@ public void cleanup(String imageUrl) {
7586
ImageUrlUtil.extractKey(cdnProps.domain(), imageUrl).ifPresent(s3Provider::deleteByKey);
7687
}
7788

89+
// 이미지 배치 삭제
90+
public void cleanupBatch(List<String> imageUrls) {
91+
List<String> keys = extractKeysFromUrls(imageUrls);
92+
if (keys.isEmpty()) return;
93+
94+
int attempted = 0;
95+
int succeeded = 0;
96+
List<String> failed = new ArrayList<>();
97+
98+
// 배치 삭제 (S3 DeleteObjects 최대 개수: 1000개)
99+
for (int i = 0; i < keys.size(); i += MAX_BATCH) {
100+
List<String> batch =
101+
new ArrayList<>(keys.subList(i, Math.min(keys.size(), i + MAX_BATCH)));
102+
attempted += batch.size();
103+
104+
CleanupImagesResult result = s3Provider.deleteByKeys(batch);
105+
succeeded += result.success();
106+
107+
if (!result.failedKeys().isEmpty()) failed.addAll(result.failedKeys());
108+
}
109+
110+
log.info(
111+
"Image Cleanup Batch attempted={}, succeeded={}, failed={}",
112+
attempted,
113+
succeeded,
114+
failed.size());
115+
116+
if (!failed.isEmpty()) {
117+
// 우선 로깅 처리
118+
// 추후 Outbox 패턴으로 확장 가능
119+
log.debug(
120+
"Image Cleanup Batch Failed. failedCount={}, failedKeys={}",
121+
failed.size(),
122+
failed);
123+
}
124+
}
125+
126+
public void publishCleanupBatchEvent(List<String> imageUrls) {
127+
publisher.publishCleanupBatch(imageUrls);
128+
}
129+
78130
// 이미지 사이즈 검증, 실패 시 삭제
79131
private void validateSizeWithCleanup(String tmpKey, long contentLength) {
80132
try {
@@ -111,4 +163,17 @@ private void cleanupAndThrow(String tmpKey, CustomException exception) {
111163
s3Provider.deleteByKey(tmpKey);
112164
throw exception;
113165
}
166+
167+
// 중복, 빈 값 제거 후 키 목록 추출
168+
private List<String> extractKeysFromUrls(List<String> urls) {
169+
if (urls == null || urls.isEmpty()) return List.of();
170+
return urls.stream()
171+
.filter(Objects::nonNull)
172+
.map(url -> ImageUrlUtil.extractKey(cdnProps.domain(), url))
173+
.flatMap(Optional::stream)
174+
.map(String::trim)
175+
.filter(s -> !s.isEmpty())
176+
.distinct()
177+
.toList();
178+
}
114179
}

src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,11 @@ public void deleteObject(String key) {
5656
() -> client.deleteObject(builder -> builder.bucket(props.bucket()).key(key)));
5757
}
5858

59-
public void deleteObjects(List<ObjectIdentifier> objects) {
60-
S3ExceptionTranslator.executeWithExceptionTranslation(
61-
() ->
62-
client.deleteObjects(
63-
builder ->
64-
builder.bucket(props.bucket())
65-
.delete(d -> d.quiet(true).objects(objects))));
59+
public DeleteObjectsResponse deleteObjects(List<ObjectIdentifier> objects) {
60+
return client.deleteObjects(
61+
builder ->
62+
builder.bucket(props.bucket())
63+
.delete(d -> d.quiet(false).objects(objects)));
6664
}
6765

6866
public void copyObject(String tmpKey, String finalKey) {

src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package com.ject.studytrip.image.infra.s3.provider;
22

3+
import com.ject.studytrip.image.application.dto.CleanupImagesResult;
34
import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient;
45
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
56
import java.util.List;
7+
import java.util.Objects;
68
import lombok.RequiredArgsConstructor;
79
import lombok.extern.slf4j.Slf4j;
810
import org.springframework.stereotype.Component;
9-
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
10-
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
11+
import software.amazon.awssdk.core.exception.SdkClientException;
12+
import software.amazon.awssdk.services.s3.model.*;
1113
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
1214

1315
@Component
@@ -35,11 +37,32 @@ public void deleteByKey(String key) {
3537
s3Client.deleteObject(key);
3638
}
3739

38-
public void deleteByKeys(List<String> keys) {
40+
public CleanupImagesResult deleteByKeys(List<String> keys) {
3941
List<ObjectIdentifier> objects =
4042
keys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList();
43+
int attempts = objects.size();
4144

42-
s3Client.deleteObjects(objects);
45+
try {
46+
DeleteObjectsResponse response = s3Client.deleteObjects(objects);
47+
List<String> failedKeys =
48+
response.errors() == null
49+
? List.of()
50+
: response.errors().stream()
51+
.map(S3Error::key)
52+
.filter(Objects::nonNull)
53+
.filter(key -> !key.isBlank())
54+
.distinct()
55+
.toList();
56+
int success = attempts - failedKeys.size();
57+
58+
return CleanupImagesResult.of(success, failedKeys);
59+
} catch (S3Exception | SdkClientException e) {
60+
// S3 삭제는 멱등이기 때문에 키가 존재하지 않거나, 중복이여도 에러가 발생하지 않음
61+
// 삭제 시 발생하는 에러는 보통 S3 내부 서버 문제(IO/네트워크) 혹은 인증/자격, 권한, 정책, 상태 등으로 발생
62+
// 따라서 요청 레벨 실패로 간주하고 배치를 전체 실패로 처리
63+
log.warn("S3 deleteObjects request failure: {}", e.getMessage(), e);
64+
return CleanupImagesResult.of(0, keys);
65+
}
4366
}
4467

4568
public void copyByKey(String tmpKey, String finalKey) {

src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@
1313
import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest;
1414
import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest;
1515
import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest;
16+
import com.ject.studytrip.mission.application.service.DailyMissionCommandService;
17+
import com.ject.studytrip.mission.application.service.MissionCommandService;
18+
import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService;
19+
import com.ject.studytrip.stamp.application.service.StampCommandService;
20+
import com.ject.studytrip.studylog.application.service.StudyLogCommandService;
21+
import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService;
1622
import com.ject.studytrip.studylog.application.service.StudyLogQueryService;
1723
import com.ject.studytrip.trip.application.dto.TripCount;
18-
import com.ject.studytrip.trip.application.service.TripQueryService;
24+
import com.ject.studytrip.trip.application.service.*;
25+
import java.util.ArrayList;
26+
import java.util.List;
1927
import lombok.RequiredArgsConstructor;
2028
import org.springframework.cache.annotation.CacheEvict;
2129
import org.springframework.cache.annotation.Cacheable;
@@ -30,8 +38,19 @@ public class MemberFacade {
3038
private final MemberQueryService memberQueryService;
3139
private final TripQueryService tripQueryService;
3240
private final StudyLogQueryService studyLogQueryService;
41+
private final TripReportQueryService tripReportQueryService;
3342

3443
private final MemberCommandService memberCommandService;
44+
private final TripCommandService tripCommandService;
45+
private final StampCommandService stampCommandService;
46+
private final MissionCommandService missionCommandService;
47+
private final DailyGoalCommandService dailyGoalCommandService;
48+
private final PomodoroCommandService pomodoroCommandService;
49+
private final DailyMissionCommandService dailyMissionCommandService;
50+
private final StudyLogCommandService studyLogCommandService;
51+
private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService;
52+
private final TripReportCommandService tripReportCommandService;
53+
private final TripReportStudyLogCommandService tripReportStudyLogCommandService;
3554

3655
private final ImageService imageService;
3756

@@ -94,4 +113,51 @@ public void confirmImage(Long memberId, ConfirmProfileImageRequest request) {
94113
// 새로운 이미지 업데이트
95114
memberCommandService.updateProfileImage(member, finalKey);
96115
}
116+
117+
@Transactional
118+
public void hardDeleteMemberCascade(Long memberId) {
119+
Member member = memberQueryService.getValidMember(memberId);
120+
121+
// 삭제할 이미지 목록
122+
List<String> imageUrls = collectImageUrlsForMember(member);
123+
124+
// 멤버의 모든 데이터 즉시 삭제
125+
cascadeHardDeleteByMemberId(member.getId());
126+
127+
// 이미지 삭제 이벤트 발행
128+
// 트랜잭션 커밋 이후 이미지 삭제 처리
129+
imageService.publishCleanupBatchEvent(imageUrls);
130+
}
131+
132+
private List<String> collectImageUrlsForMember(Member member) {
133+
List<String> imageUrls = new ArrayList<>();
134+
135+
// TripReport 이미지 목록 조회
136+
imageUrls.addAll(tripReportQueryService.getTripReportImageUrlsByMemberId(member.getId()));
137+
138+
// StudyLog 이미지 목록 조회
139+
imageUrls.addAll(studyLogQueryService.getStudyLogImageUrlsByMemberId(member.getId()));
140+
141+
if (member.getProfileImage() != null && !member.getProfileImage().isBlank()) {
142+
imageUrls.add(member.getProfileImage());
143+
}
144+
return imageUrls;
145+
}
146+
147+
private void cascadeHardDeleteByMemberId(Long memberId) {
148+
// 자식 -> 부모 순으로 삭제 진행
149+
tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsByMember(memberId);
150+
tripReportCommandService.hardDeleteTripReportsByMember(memberId);
151+
152+
studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsByMember(memberId);
153+
pomodoroCommandService.hardDeletePomodorosByMember(memberId);
154+
studyLogCommandService.hardDeleteStudyLogsByMember(memberId);
155+
dailyMissionCommandService.hardDeleteDailyMissionsByMember(memberId);
156+
dailyGoalCommandService.hardDeleteDailyGoalsByMember(memberId);
157+
158+
missionCommandService.hardDeleteMissionsByMember(memberId);
159+
stampCommandService.hardDeleteStampsByMember(memberId);
160+
tripCommandService.hardDeleteTripsByMember(memberId);
161+
memberCommandService.hardDeleteMemberById(memberId);
162+
}
97163
}

0 commit comments

Comments
 (0)