Skip to content
Merged
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.retry:spring-retry'

// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@
import com.cheeeese.cheese4cut.domain.Cheese4cutPhoto;
import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper;
import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository;
import com.cheeeese.global.util.ObjectStorageDeleteUtil;
import com.cheeeese.photo.domain.Photo;
import com.cheeeese.photo.domain.PhotoStatus;
import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand All @@ -34,7 +35,7 @@ public class AlbumExpirationService {
private final AlbumRepository albumRepository;
private final PhotoRepository photoRepository;
private final Cheese4cutRepository cheese4cutRepository;
private final ObjectStorageDeleteUtil objectStorageDeleteUtil;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public void expireAlbum(Long albumId) {
Expand Down Expand Up @@ -67,11 +68,7 @@ private List<Long> createCheese4cutIfPossible(Long albumId, Album album) {
);

if (topPhotoIds.size() < CHEESE4CUT_PHOTO_COUNT) {
log.warn(
"[AlbumExpiration] Album id={} does not have enough photos to create cheese4cut (found={})",
albumId,
topPhotoIds.size()
);
log.warn("[AlbumExpiration] Album id={} has less than 4 photos, skipping cheese4cut creation", albumId);
return List.of();
}

Expand All @@ -81,7 +78,7 @@ private List<Long> createCheese4cutIfPossible(Long albumId, Album album) {

List<Photo> orderedPhotos = topPhotoIds.stream()
.map(photoMap::get)
.collect(Collectors.toList());
.toList();

if (orderedPhotos.stream().anyMatch(Objects::isNull)) {
log.warn("[AlbumExpiration] Album id={} has missing photos for cheese4cut creation", albumId);
Expand All @@ -94,6 +91,10 @@ private List<Long> createCheese4cutIfPossible(Long albumId, Album album) {
return topPhotoIds;
}

/**
* 트랜잭션 안에서는 "DB 삭제"만 처리
* 스토리지 삭제는 AFTER_COMMIT 이벤트로 넘김
*/
private void cleanupPhotosExceptCheese4cut(Album album, List<Long> cheese4cutPhotoIds) {
List<Photo> photosToDelete = cheese4cutPhotoIds.isEmpty()
? photoRepository.findAllByAlbumId(album.getId())
Expand All @@ -102,14 +103,32 @@ private void cleanupPhotosExceptCheese4cut(Album album, List<Long> cheese4cutPho
cheese4cutPhotoIds
);

if (photosToDelete.isEmpty()) {
return;
// 이벤트 payload 구성 (스토리지 삭제 대상 URL만 수집)
List<AlbumStorageDeleteEvent.PhotoObjectDeleteTarget> photoObjectTargets = new ArrayList<>();
for (Photo photo : photosToDelete) {
photoObjectTargets.add(new AlbumStorageDeleteEvent.PhotoObjectDeleteTarget(
photo.getImageUrl(),
photo.getThumbnailUrl(),
true
));
}

for (Photo photo : photosToDelete) {
objectStorageDeleteUtil.deletePhotoObjects(photo.getImageUrl(), photo.getThumbnailUrl());
// DB 삭제(트랜잭션 내)
if (!photosToDelete.isEmpty()) {
// photoRepository.deleteAll(photosToDelete);
photoRepository.deleteAllInBatch(photosToDelete);

log.info("[AlbumExpiration] Album id={} deleted photos count={}", album.getId(), photosToDelete.size());
}

photoRepository.deleteAll(photosToDelete);
// 트랜잭션 커밋 이후 실행될 이벤트 발행
if (!photoObjectTargets.isEmpty()) {
eventPublisher.publishEvent(new AlbumStorageDeleteEvent(
album.getId(),
photoObjectTargets
));
log.info("[AlbumExpiration] Album id={} published storage delete event (photoObjects={})",
album.getId(), photoObjectTargets.size());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.cheeeese.album.application;

import java.util.List;

public record AlbumStorageDeleteEvent(
Long albumId,
List<PhotoObjectDeleteTarget> photoObjectTargets
) {
public record PhotoObjectDeleteTarget(
String imageUrl,
String thumbnailUrl,
boolean deleteOriginal
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.cheeeese.album.application;

import com.cheeeese.global.util.ObjectStorageDeleteUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Slf4j
@Component
@RequiredArgsConstructor
public class AlbumStorageDeleteEventHandler {

private final ObjectStorageDeleteUtil objectStorageDeleteUtil;
private final StorageDeleteOutboxWriter outboxWriter;
private final ObjectMapper objectMapper;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Retryable(
retryFor = Exception.class,
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2.0) // 0.5s -> 1s -> 2s
)
public void handle(AlbumStorageDeleteEvent event) {
Long albumId = event.albumId();

// 1) 삭제된 Photo들의 원본/썸네일 삭제
for (AlbumStorageDeleteEvent.PhotoObjectDeleteTarget t : event.photoObjectTargets()) {
objectStorageDeleteUtil.deletePhotoObjectsStrict(
t.imageUrl(),
t.thumbnailUrl(),
t.deleteOriginal()
);
}

log.info("[AlbumExpiration][StorageDelete] albumId={} delete completed (photoObjects={})",
albumId, event.photoObjectTargets().size());
}

/**
* 3회 다 실패한 경우
* - Outbox에 이벤트 payload 저장
*/
@Recover
@Transactional
public void recover(Exception e, AlbumStorageDeleteEvent event) {
Long albumId = event.albumId();

String payloadJson = safeToJson(event);
String reason = (e.getMessage() == null) ? e.getClass().getSimpleName() : e.getMessage();

outboxWriter.save(albumId, payloadJson, reason);
log.error("[AlbumExpiration][StorageDelete][OUTBOX] albumId={} saved to outbox. reason={}", albumId, reason, e);
}

private String safeToJson(AlbumStorageDeleteEvent event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException ex) {
// JSON 변환 실패는 payload 최소화해서 남김
return "{\"albumId\":" + event.albumId() + ",\"photoObjectTargetsCount\":" + event.photoObjectTargets().size() + "}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.cheeeese.album.application;

import com.cheeeese.album.domain.StorageDeleteOutbox;
import com.cheeeese.album.infrastructure.persistence.StorageDeleteOutboxRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class StorageDeleteOutboxWriter {

private final StorageDeleteOutboxRepository outboxRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Long albumId, String payloadJson, String reason) {
outboxRepository.save(StorageDeleteOutbox.of(albumId, payloadJson, reason));
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/cheeeese/album/domain/StorageDeleteOutbox.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.cheeeese.album.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StorageDeleteOutbox {

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "album_id", nullable = false)
private Long albumId;

@Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT")
@Lob
private String payloadJson;

@Column(nullable = false)
private String reason;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

private StorageDeleteOutbox(Long albumId, String payloadJson, String reason) {
this.albumId = albumId;
this.payloadJson = payloadJson;
this.reason = reason;
this.createdAt = LocalDateTime.now();
}

public static StorageDeleteOutbox of(Long albumId, String payloadJson, String reason) {
return new StorageDeleteOutbox(albumId, payloadJson, reason);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.cheeeese.album.infrastructure.persistence;

import com.cheeeese.album.domain.StorageDeleteOutbox;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StorageDeleteOutboxRepository extends JpaRepository<StorageDeleteOutbox, Long> {
}
9 changes: 9 additions & 0 deletions src/main/java/com/cheeeese/global/config/RetryConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.cheeeese.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;

@Configuration
@EnableRetry
public class RetryConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,50 @@ public class ObjectStorageDeleteUtil {
@Value("${ncp.object-storage.thumbnail-bucket}")
private String thumbnailBucket;

// =========================
// 실패해도 로그만 남기는 용
// =========================

public void deletePhotoObjects(String imageUrl, String thumbnailUrl) {
deleteObjectIfPresent(originalBucket, extractObjectKey(imageUrl, "say-cheeeese/"));
deletePhotoObjectsInternal(imageUrl, thumbnailUrl, true, false);
}

public void deleteThumbnailVariants(String thumbnailUrl) {
deleteThumbnailVariantsInternal(thumbnailUrl, false);
}

// =========================
// Strict 메서드: 실패 시 예외 던짐
// =========================

public void deletePhotoObjectsStrict(String imageUrl, String thumbnailUrl, boolean deleteOriginal) {
deletePhotoObjectsInternal(imageUrl, thumbnailUrl, deleteOriginal, true);
}

public void deleteThumbnailVariantsStrict(String thumbnailUrl) {
deleteThumbnailVariantsInternal(thumbnailUrl, true);
}

private void deletePhotoObjectsInternal(String imageUrl, String thumbnailUrl,
boolean deleteOriginal, boolean throwOnFailure) {
if (deleteOriginal) {
deleteObjectIfPresent(originalBucket, extractObjectKey(imageUrl, "say-cheeeese/"), throwOnFailure);
}

List<String> thumbnailKeys = buildThumbnailKeys(thumbnailUrl);
for (String key : thumbnailKeys) {
deleteObjectIfPresent(thumbnailBucket, key, throwOnFailure);
}
}

private void deleteThumbnailVariantsInternal(String thumbnailUrl, boolean throwOnFailure) {
List<String> thumbnailKeys = buildThumbnailKeys(thumbnailUrl);
for (String key : thumbnailKeys) {
deleteObjectIfPresent(thumbnailBucket, key);
deleteObjectIfPresent(thumbnailBucket, key, throwOnFailure);
}
}

private void deleteObjectIfPresent(String bucket, String objectKey) {
private void deleteObjectIfPresent(String bucket, String objectKey, boolean throwOnFailure) {
if (objectKey == null || objectKey.isBlank()) {
return;
}
Expand All @@ -42,6 +76,12 @@ private void deleteObjectIfPresent(String bucket, String objectKey) {
.build());
} catch (Exception exception) {
log.warn("[ObjectStorage] Failed to delete object bucket={} key={}", bucket, objectKey, exception);

if (throwOnFailure) {
throw new IllegalStateException(
"Failed to delete object bucket=" + bucket + " key=" + objectKey, exception
);
}
}
}

Expand Down