|
| 1 | +package com.web.coreclass.global.s3; |
| 2 | + |
| 3 | +import com.web.coreclass.domain.article.repository.ArticleRepository; |
| 4 | +import com.web.coreclass.domain.game.entity.GameType; |
| 5 | +import com.web.coreclass.domain.instructor.repository.InstructorRepository; |
| 6 | +import io.awspring.cloud.s3.S3Template; |
| 7 | +import lombok.RequiredArgsConstructor; |
| 8 | +import lombok.extern.slf4j.Slf4j; |
| 9 | +import org.springframework.beans.factory.annotation.Value; |
| 10 | +import org.springframework.scheduling.annotation.Scheduled; |
| 11 | +import org.springframework.stereotype.Component; |
| 12 | +import software.amazon.awssdk.services.s3.S3Client; |
| 13 | +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; |
| 14 | +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; |
| 15 | +import software.amazon.awssdk.services.s3.model.S3Object; |
| 16 | + |
| 17 | +import java.net.URLDecoder; |
| 18 | +import java.nio.charset.StandardCharsets; |
| 19 | +import java.time.Instant; |
| 20 | +import java.time.temporal.ChronoUnit; |
| 21 | +import java.util.HashSet; |
| 22 | +import java.util.List; |
| 23 | +import java.util.Set; |
| 24 | +import java.util.regex.Matcher; |
| 25 | +import java.util.regex.Pattern; |
| 26 | + |
| 27 | +@Slf4j |
| 28 | +@Component |
| 29 | +@RequiredArgsConstructor |
| 30 | +public class S3CleanupScheduler { |
| 31 | + |
| 32 | + private final S3Client s3Client; // AWS SDK Client (목록 조회용) |
| 33 | + private final S3Template s3Template; // Spring Cloud S3 (삭제용) |
| 34 | + private final InstructorRepository instructorRepository; |
| 35 | + private final ArticleRepository articleRepository; |
| 36 | + |
| 37 | + @Value("${spring.cloud.aws.s3.bucket}") |
| 38 | + private String bucket; |
| 39 | + |
| 40 | + // 매일 새벽 4시에 실행 (초 분 시 일 월 요일) |
| 41 | + @Scheduled(cron = "0 0 4 * * *") |
| 42 | + public void cleanupOrphanImages() { |
| 43 | + log.info("🧹 [S3 고아 파일 청소] 시작합니다..."); |
| 44 | + |
| 45 | + // 1. DB에 등록된 '사용 중인' 이미지 파일명 다 모으기 |
| 46 | + Set<String> validFileNames = new HashSet<>(); |
| 47 | + |
| 48 | + // (1) 강사 관련 이미지 |
| 49 | + validFileNames.addAll(extractFileNames(instructorRepository.findAllProfileImgUrls())); |
| 50 | + validFileNames.addAll(extractFileNames(instructorRepository.findAllSgeaLogoImgUrls())); |
| 51 | + validFileNames.addAll(extractFileNames(instructorRepository.findAllCareerLogoImgUrls())); |
| 52 | + |
| 53 | + // (2) 공지사항 관련 이미지 (썸네일) |
| 54 | + validFileNames.addAll(extractFileNames(articleRepository.findAllThumbnailUrls())); |
| 55 | + |
| 56 | + // (3) 공지사항 본문(Markdown)에 포함된 이미지 파싱 |
| 57 | + List<String> contents = articleRepository.findAllContents(); |
| 58 | + for (String content : contents) { |
| 59 | + validFileNames.addAll(extractUrlsFromMarkdown(content)); |
| 60 | + } |
| 61 | + |
| 62 | + // (4) Enum(GameType)에 하드코딩된 이미지도 보호해야 함! |
| 63 | + for (GameType game : GameType.values()) { |
| 64 | + validFileNames.add(extractFileNameFromUrl(game.getLogoUrl())); |
| 65 | + } |
| 66 | + |
| 67 | + log.info("✅ DB에서 확인된 사용 중인 파일 개수: {}개", validFileNames.size()); |
| 68 | + |
| 69 | + // 2. S3에 있는 모든 파일 목록 조회 및 비교 |
| 70 | + int deletedCount = 0; |
| 71 | + ListObjectsV2Request request = ListObjectsV2Request.builder().bucket(bucket).build(); |
| 72 | + ListObjectsV2Response result; |
| 73 | + |
| 74 | + do { |
| 75 | + result = s3Client.listObjectsV2(request); |
| 76 | + |
| 77 | + for (S3Object s3Object : result.contents()) { |
| 78 | + String key = s3Object.key(); // S3 파일명 (예: uuid_image.png) |
| 79 | + |
| 80 | + // (A) DB 목록에 없고 |
| 81 | + // (B) 생성된 지 24시간이 지난 파일만 삭제 (방금 업로드 중인 파일 보호) |
| 82 | + if (!validFileNames.contains(key) && isOlderThan24Hours(s3Object.lastModified())) { |
| 83 | + try { |
| 84 | + log.info("🗑️ 고아 파일 발견 및 삭제: {}", key); |
| 85 | + s3Template.deleteObject(bucket, key); |
| 86 | + deletedCount++; |
| 87 | + } catch (Exception e) { |
| 88 | + log.error("삭제 실패: {}", key, e); |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + // 다음 페이지가 있으면 계속 조회 |
| 93 | + request = request.toBuilder().continuationToken(result.nextContinuationToken()).build(); |
| 94 | + } while (result.isTruncated()); |
| 95 | + |
| 96 | + log.info("✨ [S3 고아 파일 청소] 완료. 총 {}개 파일 삭제됨.", deletedCount); |
| 97 | + } |
| 98 | + |
| 99 | + // --- Helper Methods --- |
| 100 | + |
| 101 | + // 1. URL 리스트에서 파일명만 추출 (예: https://.../abc.png -> abc.png) |
| 102 | + private Set<String> extractFileNames(List<String> urls) { |
| 103 | + Set<String> fileNames = new HashSet<>(); |
| 104 | + for (String url : urls) { |
| 105 | + fileNames.add(extractFileNameFromUrl(url)); |
| 106 | + } |
| 107 | + return fileNames; |
| 108 | + } |
| 109 | + |
| 110 | + // 2. 단일 URL에서 파일명 추출 |
| 111 | + private String extractFileNameFromUrl(String url) { |
| 112 | + if (url == null || url.isEmpty()) return ""; |
| 113 | + try { |
| 114 | + // URL 디코딩 (한글 파일명 대비) |
| 115 | + String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8); |
| 116 | + return decodedUrl.substring(decodedUrl.lastIndexOf("/") + 1); |
| 117 | + } catch (Exception e) { |
| 118 | + return ""; |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + // 3. 마크다운 본문에서 이미지 URL 추출 (정규식) |
| 123 | + private Set<String> extractUrlsFromMarkdown(String content) { |
| 124 | + Set<String> fileNames = new HashSet<>(); |
| 125 | + // 마크다운 이미지 패턴:  또는 <img src="URL"> |
| 126 | + // 간단하게 http로 시작해서 괄호나 따옴표로 끝나는 패턴을 잡습니다. |
| 127 | + Pattern pattern = Pattern.compile("https://[^\\s)\"]+"); |
| 128 | + Matcher matcher = pattern.matcher(content); |
| 129 | + |
| 130 | + while (matcher.find()) { |
| 131 | + String url = matcher.group(); |
| 132 | + fileNames.add(extractFileNameFromUrl(url)); |
| 133 | + } |
| 134 | + return fileNames; |
| 135 | + } |
| 136 | + |
| 137 | + // 4. 24시간 지났는지 확인 |
| 138 | + private boolean isOlderThan24Hours(Instant lastModified) { |
| 139 | + return lastModified.isBefore(Instant.now().minus(1, ChronoUnit.DAYS)); |
| 140 | + } |
| 141 | +} |
0 commit comments