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
17 changes: 17 additions & 0 deletions src/main/java/com/ject/studytrip/global/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.ject.studytrip.global.config;

import com.ject.studytrip.global.config.properties.S3Properties;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.core.retry.RetryPolicy;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
Expand All @@ -18,7 +21,21 @@ public class S3Config {

@Bean
public S3Client s3Client() {
RetryPolicy retry =
RetryPolicy.builder(RetryMode.STANDARD)
.numRetries(props.retry().maxAttempts())
.build();

return S3Client.builder()
.overrideConfiguration(
config ->
config.retryPolicy(retry)
.apiCallTimeout(
Duration.ofSeconds(
props.timeout().apiCallInSeconds()))
.apiCallAttemptTimeout(
Duration.ofSeconds(
props.timeout().apiCallAttemptInSeconds())))
.region(Region.of(props.region()))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,13 @@
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "aws.s3")
public record S3Properties(String bucket, String region, long presignExpiresInMinutes) {}
public record S3Properties(
String bucket,
String region,
long presignExpiresInMinutes,
S3Retry retry,
S3Timeout timeout) {
public record S3Retry(int maxAttempts) {}

public record S3Timeout(int apiCallInSeconds, int apiCallAttemptInSeconds) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ject.studytrip.image.application.dto;

import java.util.List;

public record CleanupImagesResult(int success, List<String> failedKeys) {
public static CleanupImagesResult of(int success, List<String> failedKeys) {
return new CleanupImagesResult(success, failedKeys);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ject.studytrip.image.application.event;

import java.util.List;

public record ImageCleanupBatchEvent(List<String> imageUrls) {
public static ImageCleanupBatchEvent of(List<String> imageUrls) {
return new ImageCleanupBatchEvent(imageUrls);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ject.studytrip.image.application.event;

import com.ject.studytrip.image.application.service.ImageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class ImageEventListener {

private final ImageService imageService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCleanupBatch(ImageCleanupBatchEvent event) {
imageService.cleanupBatch(event.imageUrls());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ject.studytrip.image.application.event;

import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ImageEventPublisher {

private final ApplicationEventPublisher publisher;

public void publishCleanupBatch(List<String> imageUrls) {
if (imageUrls == null || imageUrls.isEmpty()) return;

ImageCleanupBatchEvent event = ImageCleanupBatchEvent.of(imageUrls);
publisher.publishEvent(event);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,36 @@
import com.ject.studytrip.global.config.properties.CdnProperties;
import com.ject.studytrip.global.exception.CustomException;
import com.ject.studytrip.global.util.FilenameUtil;
import com.ject.studytrip.image.application.dto.CleanupImagesResult;
import com.ject.studytrip.image.application.dto.PresignedImageInfo;
import com.ject.studytrip.image.application.event.ImageEventPublisher;
import com.ject.studytrip.image.domain.constants.ImageConstants;
import com.ject.studytrip.image.domain.factory.ImageKeyFactory;
import com.ject.studytrip.image.domain.policy.ImagePolicy;
import com.ject.studytrip.image.domain.util.ImageUrlUtil;
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider;
import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class ImageService {

private static final int MAX_BATCH = 1000;

private final S3ImageStorageProvider s3Provider;
private final TikaImageProbeProvider tikaProvider;

private final ImageEventPublisher publisher;

private final CdnProperties cdnProps;

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

// 이미지 배치 삭제
public void cleanupBatch(List<String> imageUrls) {
List<String> keys = extractKeysFromUrls(imageUrls);
if (keys.isEmpty()) return;

int attempted = 0;
int succeeded = 0;
List<String> failed = new ArrayList<>();

// 배치 삭제 (S3 DeleteObjects 최대 개수: 1000개)
for (int i = 0; i < keys.size(); i += MAX_BATCH) {
List<String> batch =
new ArrayList<>(keys.subList(i, Math.min(keys.size(), i + MAX_BATCH)));
attempted += batch.size();

CleanupImagesResult result = s3Provider.deleteByKeys(batch);
succeeded += result.success();

if (!result.failedKeys().isEmpty()) failed.addAll(result.failedKeys());
}

log.info(
"Image Cleanup Batch attempted={}, succeeded={}, failed={}",
attempted,
succeeded,
failed.size());

if (!failed.isEmpty()) {
// 우선 로깅 처리
// 추후 Outbox 패턴으로 확장 가능
log.debug(
"Image Cleanup Batch Failed. failedCount={}, failedKeys={}",
failed.size(),
failed);
}
}

public void publishCleanupBatchEvent(List<String> imageUrls) {
publisher.publishCleanupBatch(imageUrls);
}

// 이미지 사이즈 검증, 실패 시 삭제
private void validateSizeWithCleanup(String tmpKey, long contentLength) {
try {
Expand Down Expand Up @@ -111,4 +163,17 @@ private void cleanupAndThrow(String tmpKey, CustomException exception) {
s3Provider.deleteByKey(tmpKey);
throw exception;
}

// 중복, 빈 값 제거 후 키 목록 추출
private List<String> extractKeysFromUrls(List<String> urls) {
if (urls == null || urls.isEmpty()) return List.of();
return urls.stream()
.filter(Objects::nonNull)
.map(url -> ImageUrlUtil.extractKey(cdnProps.domain(), url))
.flatMap(Optional::stream)
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,11 @@ public void deleteObject(String key) {
() -> client.deleteObject(builder -> builder.bucket(props.bucket()).key(key)));
}

public void deleteObjects(List<ObjectIdentifier> objects) {
S3ExceptionTranslator.executeWithExceptionTranslation(
() ->
client.deleteObjects(
builder ->
builder.bucket(props.bucket())
.delete(d -> d.quiet(true).objects(objects))));
public DeleteObjectsResponse deleteObjects(List<ObjectIdentifier> objects) {
return client.deleteObjects(
builder ->
builder.bucket(props.bucket())
.delete(d -> d.quiet(false).objects(objects)));
}

public void copyObject(String tmpKey, String finalKey) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.ject.studytrip.image.infra.s3.provider;

import com.ject.studytrip.image.application.dto.CleanupImagesResult;
import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient;
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;

@Component
Expand Down Expand Up @@ -35,11 +37,32 @@ public void deleteByKey(String key) {
s3Client.deleteObject(key);
}

public void deleteByKeys(List<String> keys) {
public CleanupImagesResult deleteByKeys(List<String> keys) {
List<ObjectIdentifier> objects =
keys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList();
int attempts = objects.size();

s3Client.deleteObjects(objects);
try {
DeleteObjectsResponse response = s3Client.deleteObjects(objects);
List<String> failedKeys =
response.errors() == null
? List.of()
: response.errors().stream()
.map(S3Error::key)
.filter(Objects::nonNull)
.filter(key -> !key.isBlank())
.distinct()
.toList();
int success = attempts - failedKeys.size();

return CleanupImagesResult.of(success, failedKeys);
} catch (S3Exception | SdkClientException e) {
// S3 삭제는 멱등이기 때문에 키가 존재하지 않거나, 중복이여도 에러가 발생하지 않음
// 삭제 시 발생하는 에러는 보통 S3 내부 서버 문제(IO/네트워크) 혹은 인증/자격, 권한, 정책, 상태 등으로 발생
// 따라서 요청 레벨 실패로 간주하고 배치를 전체 실패로 처리
log.warn("S3 deleteObjects request failure: {}", e.getMessage(), e);
return CleanupImagesResult.of(0, keys);
}
}

public void copyByKey(String tmpKey, String finalKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@
import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest;
import com.ject.studytrip.mission.application.service.DailyMissionCommandService;
import com.ject.studytrip.mission.application.service.MissionCommandService;
import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService;
import com.ject.studytrip.stamp.application.service.StampCommandService;
import com.ject.studytrip.studylog.application.service.StudyLogCommandService;
import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService;
import com.ject.studytrip.studylog.application.service.StudyLogQueryService;
import com.ject.studytrip.trip.application.dto.TripCount;
import com.ject.studytrip.trip.application.service.TripQueryService;
import com.ject.studytrip.trip.application.service.*;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
Expand All @@ -30,8 +38,19 @@ public class MemberFacade {
private final MemberQueryService memberQueryService;
private final TripQueryService tripQueryService;
private final StudyLogQueryService studyLogQueryService;
private final TripReportQueryService tripReportQueryService;

private final MemberCommandService memberCommandService;
private final TripCommandService tripCommandService;
private final StampCommandService stampCommandService;
private final MissionCommandService missionCommandService;
private final DailyGoalCommandService dailyGoalCommandService;
private final PomodoroCommandService pomodoroCommandService;
private final DailyMissionCommandService dailyMissionCommandService;
private final StudyLogCommandService studyLogCommandService;
private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService;
private final TripReportCommandService tripReportCommandService;
private final TripReportStudyLogCommandService tripReportStudyLogCommandService;

private final ImageService imageService;

Expand Down Expand Up @@ -94,4 +113,51 @@ public void confirmImage(Long memberId, ConfirmProfileImageRequest request) {
// 새로운 이미지 업데이트
memberCommandService.updateProfileImage(member, finalKey);
}

@Transactional
public void hardDeleteMemberCascade(Long memberId) {
Member member = memberQueryService.getValidMember(memberId);

// 삭제할 이미지 목록
List<String> imageUrls = collectImageUrlsForMember(member);

// 멤버의 모든 데이터 즉시 삭제
cascadeHardDeleteByMemberId(member.getId());

// 이미지 삭제 이벤트 발행
// 트랜잭션 커밋 이후 이미지 삭제 처리
imageService.publishCleanupBatchEvent(imageUrls);
}

private List<String> collectImageUrlsForMember(Member member) {
List<String> imageUrls = new ArrayList<>();

// TripReport 이미지 목록 조회
imageUrls.addAll(tripReportQueryService.getTripReportImageUrlsByMemberId(member.getId()));

// StudyLog 이미지 목록 조회
imageUrls.addAll(studyLogQueryService.getStudyLogImageUrlsByMemberId(member.getId()));

if (member.getProfileImage() != null && !member.getProfileImage().isBlank()) {
imageUrls.add(member.getProfileImage());
}
return imageUrls;
}

private void cascadeHardDeleteByMemberId(Long memberId) {
// 자식 -> 부모 순으로 삭제 진행
tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsByMember(memberId);
tripReportCommandService.hardDeleteTripReportsByMember(memberId);

studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsByMember(memberId);
pomodoroCommandService.hardDeletePomodorosByMember(memberId);
studyLogCommandService.hardDeleteStudyLogsByMember(memberId);
dailyMissionCommandService.hardDeleteDailyMissionsByMember(memberId);
dailyGoalCommandService.hardDeleteDailyGoalsByMember(memberId);

missionCommandService.hardDeleteMissionsByMember(memberId);
stampCommandService.hardDeleteStampsByMember(memberId);
tripCommandService.hardDeleteTripsByMember(memberId);
memberCommandService.hardDeleteMemberById(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ public long hardDeleteMembers() {
return memberQueryRepository.deleteAllByDeletedAtIsNotNull();
}

public void hardDeleteMemberById(Long memberId) {
memberRepository.deleteById(memberId);
}

private void validateMemberIsUnique(SocialProvider socialProvider, String socialId) {
boolean isMemberDuplicated =
memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId);
Expand Down
Loading