Skip to content

Commit cef5637

Browse files
committed
✨feat: 선 업로드 이미지에 대한 가비지 컬렉션을 구현[#213]
주기적으로 만료된 그룹 이미지를 Redis 및 S3에서 삭제하는 예약 작업을 추가하여 스토리지 용량 증가를 방지합니다. 작업자는 유예 기간을 기준으로 만료된 이미지를 식별하고 메타데이터와 이미지 파일을 삭제합니다. 인덱스만 남아 있는 경우에도 완전한 정리를 수행합니다.
1 parent 9952eaa commit cef5637

2 files changed

Lines changed: 118 additions & 13 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package team.wego.wegobackend.group.v2.application.service;
2+
3+
import java.time.Instant;
4+
import java.time.Duration;
5+
import java.util.List;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
import team.wego.wegobackend.group.v2.application.dto.common.PreUploadedGroupImage;
11+
import team.wego.wegobackend.group.v2.infrastructure.redis.PreUploadedGroupImageRedisRepository;
12+
import team.wego.wegobackend.image.application.service.ImageUploadService;
13+
14+
@Slf4j
15+
@RequiredArgsConstructor
16+
@Component
17+
public class PreUploadedGroupImageOrphanGcWorker {
18+
19+
private final PreUploadedGroupImageRedisRepository redisRepository;
20+
private final ImageUploadService imageUploadService;
21+
22+
// “사용자가 업로드 후 모임 생성까지 걸릴 수 있는 최대 시간”
23+
// 최소 변경이 목적이면 1~2시간 정도를 권장 (너무 짧으면 정상 플로우도 삭제 위험)
24+
// private static final Duration ORPHAN_GRACE = Duration.ofHours(2);
25+
private static final Duration ORPHAN_GRACE = Duration.ofSeconds(30);
26+
27+
// 한 번에 너무 많이 지우지 않도록 제한
28+
private static final int BATCH_LIMIT = 200;
29+
30+
// 10분마다 정도면 충분히 안정적
31+
@Scheduled(fixedDelay = 30_000L)
32+
public void gc() {
33+
long thresholdEpochSec = Instant.now().minus(ORPHAN_GRACE).getEpochSecond();
34+
35+
List<String> candidates = redisRepository.findExpiredCandidates(thresholdEpochSec, BATCH_LIMIT);
36+
if (candidates.isEmpty()) {
37+
return;
38+
}
39+
40+
int deleted = 0;
41+
int indexOnly = 0;
42+
int failed = 0;
43+
44+
for (String imageKey : candidates) {
45+
try {
46+
// 메타가 있어야 S3 URL을 알 수 있음
47+
PreUploadedGroupImage meta = redisRepository.find(imageKey).orElse(null);
48+
49+
if (meta == null) {
50+
// TTL로 메타는 이미 만료됨 -> 인덱스만 청소
51+
redisRepository.removeIndex(imageKey);
52+
indexOnly++;
53+
continue;
54+
}
55+
56+
// S3 삭제 (main + thumb)
57+
imageUploadService.deleteAllByUrls(List.of(meta.url440x240(), meta.url100x100()));
58+
59+
// Redis에서 원자적으로 소비(삭제) + index 제거
60+
// (이미 누가 consume했을 수도 있으니, 여기서는 "있으면 지운다" 수준으로 충분)
61+
redisRepository.consume(imageKey);
62+
63+
deleted++;
64+
65+
} catch (Exception e) {
66+
failed++;
67+
// 실패한 건 다음 스케줄에서 재시도하게 “인덱스를 남겨둔다”
68+
log.error("[PRE_UPLOADED_IMG_GC] failed imageKey={} reason={}",
69+
imageKey, e.toString(), e);
70+
}
71+
}
72+
73+
log.info("[PRE_UPLOADED_IMG_GC] done candidates={} deleted={} indexOnly={} failed={}",
74+
candidates.size(), deleted, indexOnly, failed);
75+
}
76+
}
Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,72 @@
11
package team.wego.wegobackend.group.v2.infrastructure.redis;
22

33
import java.time.Duration;
4+
import java.util.ArrayList;
5+
import java.util.List;
46
import java.util.Optional;
7+
import java.util.Set;
58
import lombok.RequiredArgsConstructor;
69
import org.springframework.data.redis.core.RedisTemplate;
10+
import org.springframework.data.redis.core.StringRedisTemplate;
711
import org.springframework.stereotype.Repository;
812
import team.wego.wegobackend.group.v2.application.dto.common.PreUploadedGroupImage;
913

1014
@RequiredArgsConstructor
1115
@Repository
1216
public class PreUploadedGroupImageRedisRepository {
1317

14-
private static final Duration TTL = Duration.ofHours(2);
18+
// 메타데이터는 넉넉히: 1일 (GC, 장애 대비)
19+
private static final Duration META_TTL = Duration.ofDays(1);
20+
1521
private static final String PREFIX = "group:v2:img:pre:";
22+
private static final String IDX_KEY = "group:v2:img:pre:idx";
1623

17-
private final RedisTemplate<String, PreUploadedGroupImage> preUploadedGroupImageRedisTemplate;
24+
private final RedisTemplate<String, PreUploadedGroupImage> valueTemplate;
25+
private final StringRedisTemplate stringRedisTemplate;
1826

1927
private String key(String imageKey) {
2028
return PREFIX + imageKey;
2129
}
2230

2331
public void save(PreUploadedGroupImage value) {
24-
preUploadedGroupImageRedisTemplate.opsForValue().set(
25-
key(value.imageKey()),
26-
value,
27-
TTL
28-
);
29-
}
32+
valueTemplate.opsForValue().set(key(value.imageKey()), value, META_TTL);
3033

31-
public Optional<PreUploadedGroupImage> find(String imageKey) {
32-
PreUploadedGroupImage value =
33-
preUploadedGroupImageRedisTemplate.opsForValue().get(key(imageKey));
34-
return Optional.ofNullable(value);
34+
long score = value.createdAt().atZone(java.time.ZoneId.systemDefault()).toEpochSecond();
35+
stringRedisTemplate.opsForZSet().add(IDX_KEY, value.imageKey(), score);
36+
// IDX 자체 TTL은 옵션: 하루에 한 번 갱신해도 되고, 그냥 둬도 됩니다(멤버 정리로 관리).
3537
}
3638

39+
40+
// 소비는 원자적으로 가져가면서 삭제
3741
public Optional<PreUploadedGroupImage> consume(String imageKey) {
3842
PreUploadedGroupImage value =
39-
preUploadedGroupImageRedisTemplate.opsForValue().getAndDelete(key(imageKey));
43+
valueTemplate.opsForValue().getAndDelete(key(imageKey));
44+
45+
// 인덱스에서도 제거(있든 없든)
46+
stringRedisTemplate.opsForZSet().remove(IDX_KEY, imageKey);
47+
4048
return Optional.ofNullable(value);
4149
}
50+
51+
public Optional<PreUploadedGroupImage> find(String imageKey) {
52+
return Optional.ofNullable(valueTemplate.opsForValue().get(key(imageKey)));
53+
}
54+
55+
56+
// 고아 이미지 삭제 전용: 특정 시간 이전 imageKey들 배치로 뽑기
57+
public List<String> findExpiredCandidates(long thresholdEpochSec, int limit) {
58+
// ZRANGEBYSCORE idx -inf threshold LIMIT 0 limit
59+
Set<String> set = stringRedisTemplate.opsForZSet()
60+
.rangeByScore(IDX_KEY, Double.NEGATIVE_INFINITY, thresholdEpochSec, 0, limit);
61+
62+
if (set == null || set.isEmpty()) {
63+
return List.of();
64+
}
65+
return new ArrayList<>(set);
66+
}
67+
68+
public void removeIndex(String imageKey) {
69+
stringRedisTemplate.opsForZSet().remove(IDX_KEY, imageKey);
70+
}
4271
}
4372

0 commit comments

Comments
 (0)