Skip to content

Commit d7de353

Browse files
committed
feat: Media S3삭제 즉시삭제 및 비동기 방식으로 변경 + 삭제 실패 스케쥴링 재시도 추가
1 parent ff1529a commit d7de353

File tree

8 files changed

+124
-49
lines changed

8 files changed

+124
-49
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.example.log4u.common.config;
2+
3+
import java.util.concurrent.Executor;
4+
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.scheduling.annotation.EnableAsync;
8+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
9+
10+
@Configuration
11+
@EnableAsync
12+
public class AsyncConfig {
13+
@Bean(name = "mediaTaskExecutor")
14+
public Executor mediaTaskExecutor() {
15+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
16+
executor.setCorePoolSize(2);
17+
executor.setMaxPoolSize(5);
18+
executor.setQueueCapacity(100);
19+
executor.setThreadNamePrefix("media-async-");
20+
executor.initialize();
21+
return executor;
22+
}
23+
}

src/main/java/com/example/log4u/domain/media/MediaStatus.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
public enum MediaStatus {
44
TEMPORARY,
55
PERMANENT,
6-
DELETED
6+
FAILED_DELETE
77
}

src/main/java/com/example/log4u/domain/media/controller/MediaController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import com.example.log4u.domain.media.dto.PresignedUrlResponseDto;
1818
import com.example.log4u.domain.media.entity.Media;
1919
import com.example.log4u.domain.media.service.MediaService;
20-
import com.example.log4u.domain.media.service.PresignedUrlService;
20+
import com.example.log4u.domain.media.service.S3Service;
2121

2222
import lombok.RequiredArgsConstructor;
2323
import lombok.extern.slf4j.Slf4j;
@@ -29,7 +29,7 @@
2929
public class MediaController {
3030

3131
private final MediaService mediaService;
32-
private final PresignedUrlService presignedUrlService;
32+
private final S3Service presignedUrlService;
3333

3434
@PostMapping("/presigned-url")
3535
public ResponseEntity<PresignedUrlResponseDto> getPresignedUrl(

src/main/java/com/example/log4u/domain/media/entity/Media.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public void connectToDiary(Long diaryId) {
5151
this.status = MediaStatus.PERMANENT;
5252
}
5353

54-
public void markAsDeleted() {
55-
this.status = MediaStatus.DELETED;
54+
public void markAsFailedDelete() {
55+
this.status = MediaStatus.FAILED_DELETE;
5656
}
5757
}

src/main/java/com/example/log4u/domain/media/repository/MediaRepository.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ public interface MediaRepository extends JpaRepository<Media, Long> {
1616

1717
List<Media> findByDiaryId(Long diaryId);
1818

19-
List<Media> findByDiaryIdIn(List<Long> diaryIds);
20-
2119
// 임시 상태이면서 특정 시간 이전에 생성된 미디어 조회
2220
List<Media> findByStatusAndCreatedAtBefore(MediaStatus status, LocalDateTime dateTime);
2321

src/main/java/com/example/log4u/domain/media/scheduler/MediaScheduler.java

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,31 @@
33
import java.time.LocalDateTime;
44
import java.util.List;
55

6-
import org.springframework.beans.factory.annotation.Value;
76
import org.springframework.scheduling.annotation.Scheduled;
87
import org.springframework.stereotype.Component;
98
import org.springframework.transaction.annotation.Transactional;
109

1110
import com.example.log4u.domain.media.MediaStatus;
1211
import com.example.log4u.domain.media.entity.Media;
1312
import com.example.log4u.domain.media.repository.MediaRepository;
13+
import com.example.log4u.domain.media.service.S3Service;
1414

1515
import lombok.RequiredArgsConstructor;
1616
import lombok.extern.slf4j.Slf4j;
17-
import software.amazon.awssdk.services.s3.S3Client;
18-
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
1917

2018
@Component
2119
@RequiredArgsConstructor
2220
@Slf4j
2321
public class MediaScheduler {
2422

2523
private final MediaRepository mediaRepository;
26-
private final S3Client s3Client;
24+
private final S3Service s3Service;
2725

28-
@Value("${S3_BUCKET_NAME}")
29-
private String bucketName;
30-
31-
// 임시 미디어 정리 (24시간 이상 지난 것)
32-
@Scheduled(cron = "0 0 * * * *") // 매시간 실행
26+
/**
27+
* 임시 미디어 정리 (24시간 이상 지난 것)
28+
* 매시간 실행
29+
*/
30+
@Scheduled(cron = "0 0 * * * *")
3331
@Transactional
3432
public void cleanupTemporaryMedia() {
3533
LocalDateTime cutoffTime = LocalDateTime.now().minusHours(24);
@@ -38,32 +36,33 @@ public void cleanupTemporaryMedia() {
3836
cutoffTime
3937
);
4038

41-
cleanupMedia(temporaryMedia);
39+
if (temporaryMedia.isEmpty()) {
40+
return;
41+
}
42+
43+
log.info("Found {} temporary media files to clean up", temporaryMedia.size());
44+
45+
// 비동기 삭제 처리
46+
s3Service.deleteFilesFromS3(temporaryMedia);
4247
}
4348

44-
// DELETED 상태 미디어 정리
45-
@Scheduled(cron = "0 30 * * * *") // 매시간 30분에 실행
49+
/**
50+
* 삭제 실패 미디어 재시도
51+
* 15분마다 실행
52+
*/
53+
@Scheduled(cron = "0 */15 * * * *")
4654
@Transactional
47-
public void cleanupDeletedMedia() {
48-
List<Media> deletedMedia = mediaRepository.findByStatus(MediaStatus.DELETED);
49-
cleanupMedia(deletedMedia);
50-
}
55+
public void retryDelete() {
56+
// 삭제 실패 상태인 미디어 조회
57+
List<Media> failedMedia = mediaRepository.findByStatus(MediaStatus.FAILED_DELETE);
5158

52-
private void cleanupMedia(List<Media> mediaList) {
53-
for (Media media : mediaList) {
54-
try {
55-
// S3에서 파일 삭제
56-
DeleteObjectRequest request = DeleteObjectRequest.builder()
57-
.bucket(bucketName)
58-
.key(media.getStoredName())
59-
.build();
59+
if (failedMedia.isEmpty()) {
60+
return;
61+
}
6062

61-
s3Client.deleteObject(request);
63+
log.info("Retrying deletion for {} failed media files", failedMedia.size());
6264

63-
mediaRepository.delete(media);
64-
} catch (Exception e) {
65-
log.error(e.getMessage());
66-
}
67-
}
65+
// 비동기 삭제 처리
66+
s3Service.deleteFilesFromS3(failedMedia);
6867
}
69-
}
68+
}

src/main/java/com/example/log4u/domain/media/service/MediaService.java

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
public class MediaService {
2424

2525
private final MediaRepository mediaRepository;
26+
private final S3Service s3Service;
2627

2728
@Transactional
2829
public void saveMedia(Long diaryId, List<MediaRequestDto> mediaList) {
@@ -60,14 +61,18 @@ public List<Media> getMediaByDiaryId(Long diaryId) {
6061

6162
@Transactional
6263
public void deleteMediaByDiaryId(Long diaryId) {
64+
// 1. 미디어 목록 조회
6365
List<Media> mediaList = mediaRepository.findByDiaryId(diaryId);
6466

65-
// 미디어 삭제 상태로 변경
66-
for (Media media : mediaList) {
67-
media.markAsDeleted();
67+
if (mediaList.isEmpty()) {
68+
return;
6869
}
6970

70-
mediaRepository.saveAll(mediaList);
71+
// 2. DB에서 미디어 정보 삭제 (트랜잭션 내에서)
72+
mediaRepository.deleteByDiaryId(diaryId);
73+
74+
// 3. S3에서 파일 비동기 삭제 (별도 트랜잭션에서)
75+
s3Service.deleteFilesFromS3(mediaList);
7176
}
7277

7378
@Transactional
@@ -86,13 +91,22 @@ public void updateMediaByDiaryId(Long diaryId, List<MediaRequestDto> newMediaLis
8691
.toList();
8792

8893
// 삭제할 미디어(기존에 있지만 새 목록에 없는 것)
94+
List<Media> mediaToDelete = new ArrayList<>();
8995
for (Media media : existingMedia) {
9096
if (!newMediaIds.contains(media.getMediaId())) {
91-
media.markAsDeleted();
92-
allMediaToSave.add(media);
97+
mediaToDelete.add(media);
9398
}
9499
}
95100

101+
// 삭제할 미디어가 있으면 비동기로 S3에서 삭제
102+
if (!mediaToDelete.isEmpty()) {
103+
// DB에서 연결 해제
104+
mediaRepository.deleteAll(mediaToDelete);
105+
106+
// S3에서 비동기 삭제
107+
s3Service.deleteFilesFromS3(mediaToDelete);
108+
}
109+
96110
// 새 미디어 연결
97111
if (!newMediaList.isEmpty()) {
98112
List<Media> newMedia = mediaRepository.findAllById(newMediaIds);
@@ -145,8 +159,12 @@ public Media getMediaById(Long mediaId) {
145159
public void deleteMediaById(Long mediaId) {
146160
Media media = mediaRepository.findById(mediaId)
147161
.orElseThrow(NotFoundMediaException::new);
148-
media.markAsDeleted();
149-
mediaRepository.save(media);
162+
163+
// DB에서 삭제
164+
mediaRepository.delete(media);
165+
166+
// S3에서 비동기 삭제
167+
s3Service.deleteFilesFromS3(List.of(media));
150168
}
151169

152170
// 미디어 개수 검증 로직
@@ -155,4 +173,4 @@ private void validateMediaLimit(List<MediaRequestDto> mediaList) {
155173
throw new MediaLimitExceededException();
156174
}
157175
}
158-
}
176+
}

src/main/java/com/example/log4u/domain/media/service/PresignedUrlService.java renamed to src/main/java/com/example/log4u/domain/media/service/S3Service.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.example.log4u.domain.media.service;
22

33
import java.time.Duration;
4+
import java.util.List;
45
import java.util.UUID;
56

67
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.scheduling.annotation.Async;
79
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Propagation;
811
import org.springframework.transaction.annotation.Transactional;
912

1013
import com.example.log4u.domain.media.MediaStatus;
@@ -15,16 +18,19 @@
1518

1619
import lombok.RequiredArgsConstructor;
1720
import lombok.extern.slf4j.Slf4j;
21+
import software.amazon.awssdk.services.s3.S3Client;
22+
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
1823
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
1924
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
2025
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
2126

2227
@Service
2328
@RequiredArgsConstructor
2429
@Slf4j
25-
public class PresignedUrlService {
30+
public class S3Service {
2631

2732
private final S3Presigner s3Presigner;
33+
private final S3Client s3Client;
2834
private final MediaRepository mediaRepository;
2935

3036
@Value("${S3_BUCKET_NAME}")
@@ -33,6 +39,9 @@ public class PresignedUrlService {
3339
@Value("${AWS_REGION}")
3440
private String s3Region;
3541

42+
/**
43+
* Presigned URL 생성 및 임시 미디어 엔티티 저장
44+
*/
3645
@Transactional
3746
public PresignedUrlResponseDto generatePresignedUrl(PresignedUrlRequestDto request) {
3847
// 파일명 생성
@@ -83,8 +92,36 @@ public PresignedUrlResponseDto generatePresignedUrl(PresignedUrlRequestDto reque
8392
);
8493
}
8594

95+
/**
96+
* S3에서 파일 삭제 (비동기)
97+
*/
98+
@Async("mediaTaskExecutor")
99+
@Transactional(propagation = Propagation.REQUIRES_NEW)
100+
public void deleteFilesFromS3(List<Media> mediaList) {
101+
for (Media media : mediaList) {
102+
try {
103+
// S3에서 파일 삭제
104+
DeleteObjectRequest request = DeleteObjectRequest.builder()
105+
.bucket(bucketName)
106+
.key(media.getStoredName())
107+
.build();
108+
109+
s3Client.deleteObject(request);
110+
111+
// 성공하면 DB에서도 삭제
112+
mediaRepository.delete(media);
113+
log.info("Successfully deleted media from S3 and DB: {}", media.getMediaId());
114+
} catch (Exception e) {
115+
// 실패하면 FAILED_DELETE 상태로 변경
116+
media.markAsFailedDelete();
117+
mediaRepository.save(media);
118+
log.error("Failed to delete media from S3: {}", media.getMediaId(), e);
119+
}
120+
}
121+
}
122+
86123
private String getFileExtension(String fileName) {
87124
int lastDotIndex = fileName.lastIndexOf(".");
88125
return lastDotIndex > 0 ? fileName.substring(lastDotIndex) : "";
89126
}
90-
}
127+
}

0 commit comments

Comments
 (0)