Skip to content

Conversation

@dahyun24
Copy link
Contributor

@dahyun24 dahyun24 commented Jan 13, 2026

🔗 연관된 이슈

🚀 변경 유형

  • ✨ 기능 추가 (feature)
  • 🐛 버그 수정 (fix)
  • 📝 문서 변경 (docs)
  • ♻️ 리팩토링 (refactor)
  • 🧪 테스트 추가 / 수정 (test)
  • ⚙️ 설정 변경 (chore)

📝 작업 내용

CEEZ-35에서의 작업

  • 기존 PR에서 앨범 만료 시 DB 기준 사진 삭제 로직은 이미 구현됨
  • 다만 Object Storage 삭제가 서비스 로직에 직접 결합되어 있어
    • 트랜잭션 안정성 부족
    • 외부 I/O 실패 시 복구 불가
    • 운영 중 장애 대응이 어려운 구조였음

CEEZ-62에 추가한 내용

  1. 스토리지 삭제를 도메인 이벤트로 분리
  • AlbumExpirationService에서는
    • DB 삭제만 책임
    • 스토리지 삭제는 AlbumStorageDeleteEvent 발행으로 위임
  1. AFTER_COMMIT 이벤트 핸들러 도입
  • 트랜잭션 커밋 이후에만 Object Storage 삭제 수행
  • DB 정합성 보장
  1. Spring Retry 적용
  • 스토리지 삭제 실패 시 최대 3회 재시도
  1. 최종 실패 시 Outbox 저장
  • 재시도 모두 실패할 경우 storage_delete_outbox 테이블에 payload 저장
  • 운영 중 수동/배치 재처리 가능하도록 기반 마련

💬 리뷰 요구사항

📜 리뷰 규칙

Reviewer는 아래 P5 Rule을 참고하여 리뷰를 진행합니다.
P5 Rule을 통해 Reviewer는 Reviewee에게 리뷰의 의도를 보다 정확히 전달할 수 있습니다.

  • P1: 꼭 반영해주세요 (Comment)
  • P2: 적극적으로 고려해주세요 (Comment)
  • P3: 웬만하면 반영해 주세요 (Comment)
  • P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)
  • P5: 그냥 사소한 의견입니다 (Approve)

Summary by CodeRabbit

  • Refactor

    • 앨범 만료 시 파일 삭제를 이벤트 기반으로 전환해 트랜잭션 후 처리 및 자동 재시도/대체 경로 적용
  • New Features

    • 삭제 이벤트 처리기에서 재시도와 실패 시 아웃박스에 페이로드 저장하는 복구 흐름 추가
    • 아웃박스 엔티티 및 저장 서비스 추가
  • Chores

    • 객체 삭제 유틸에 엄격/비엄격 모드와 오류 처리 옵션 추가
    • 재시도 기능 활성화를 위한 구성 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@dahyun24 dahyun24 self-assigned this Jan 13, 2026
@dahyun24 dahyun24 added the ✨feature New feature or request label Jan 13, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 13, 2026

Walkthrough

앨범 만료 시 저장소 직접 삭제를 이벤트 기반으로 전환합니다. 사진 삭제는 트랜잭션 내에서 DB에서 제거하고, 커밋 후 AlbumStorageDeleteEvent를 발행해 재시도 가능한 이벤트 핸들러가 실제 스토리지 삭제를 수행하며 실패 시 아웃박스에 페이로드를 저장합니다.

Changes

코호트 / 파일(들) 변경 요약
빌드 설정
build.gradle
org.springframework.retry:spring-retry 의존성 추가
핵심 서비스 리팩토링
src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
직접 ObjectStorageDeleteUtil 호출 제거, ApplicationEventPublisher 주입 및 AlbumStorageDeleteEvent 발행으로 변경; 트랜잭션 내에서 사진 레코드 삭제 유지
이벤트 및 데이터 구조
src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEvent.java
삭제 대상 정보를 담는 AlbumStorageDeleteEvent 레코드 및 중첩 PhotoObjectDeleteTarget 추가
이벤트 핸들러
src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java
@TransactionalEventListener(phase = AFTER_COMMIT) 핸들러 추가, @Retryable 재시도 설정 및 @Recover에서 실패 시 아웃박스 저장 로직 구현
아웃박스 인프라
src/main/java/com/cheeeese/album/domain/StorageDeleteOutbox.java, .../persistence/StorageDeleteOutboxRepository.java, .../application/StorageDeleteOutboxWriter.java
실패 페이로드를 보관하는 JPA 엔티티, 리포지토리, REQUIRES_NEW 트랜잭션으로 저장하는 서비스 추가
재시도 설정
src/main/java/com/cheeeese/global/config/RetryConfig.java
@EnableRetry 설정 빈 추가
스토리지 유틸리티 확장
src/main/java/com/cheeeese/global/util/ObjectStorageDeleteUtil.java
실패 시 예외/비예외 모드 선택 가능한 엄격/비엄격 삭제 메서드 오버로드 및 내부 처리 로직 추가

Sequence Diagram(s)

sequenceDiagram
    actor Trigger
    participant AlbumExpirationService
    participant DB as Database
    participant EventPublisher as ApplicationEventPublisher
    participant EventHandler as AlbumStorageDeleteEventHandler
    participant StorageUtil as ObjectStorageDeleteUtil
    participant OutboxWriter as StorageDeleteOutboxWriter

    Trigger->>AlbumExpirationService: expireAlbum()
    AlbumExpirationService->>DB: delete photo records (transaction)
    DB-->>AlbumExpirationService: confirm deletion
    AlbumExpirationService->>EventPublisher: publish AlbumStorageDeleteEvent (AFTER_COMMIT)
    Note over AlbumExpirationService: transaction commits
    EventPublisher->>EventHandler: handle(event)
    alt deletion succeeds
        EventHandler->>StorageUtil: deletePhotoObjects(...) for each target
        StorageUtil-->>EventHandler: success
    else retries exhausted
        EventHandler->>OutboxWriter: save(albumId, payloadJson, reason)
        OutboxWriter->>DB: persist StorageDeleteOutbox (REQUIRES_NEW)
        DB-->>OutboxWriter: saved
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

🐛bug

Suggested reviewers

  • zyovn

Poem

🐰 저장소 바람에 사진들이 흩날려도,
이벤트 종이 울려 모두 모여요.
재시도랑 아웃박스가 손을 잡고,
실패한 조각도 안전히 맡겨두죠.
폴짝폴짝, 코드도 평화롭게 정리되네 🌱

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 변경 사항의 주요 내용을 명확하게 요약합니다: 앨범 만료 시 스토리지 삭제 이벤트 분리 및 Outbox 기반 실패 처리 추가.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.



📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cb682a3 and ce02651.

📒 Files selected for processing (1)
  • build.gradle
🚧 Files skipped from review as they are similar to previous changes (1)
  • build.gradle

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
build.gradle (1)

36-37: 중복 의존성 선언 제거 필요

spring-boot-starter-aop가 라인 66에 이미 선언되어 있습니다. 중복 선언을 제거해 주세요.

♻️ 제안된 수정
 	implementation 'org.springframework.retry:spring-retry'
-	implementation 'org.springframework.boot:spring-boot-starter-aop'
src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java (1)

51-61: @Transactional 제거를 고려해보세요.

recover() 메서드의 @Transactional은 불필요합니다. outboxWriter.save()가 이미 Propagation.REQUIRES_NEW로 자체 트랜잭션을 관리하고 있으므로, 여기서 별도 트랜잭션 경계를 설정할 필요가 없습니다.

제안된 변경
     @Recover
-    @Transactional
     public void recover(Exception e, AlbumStorageDeleteEvent event) {
src/main/java/com/cheeeese/album/application/AlbumExpirationService.java (1)

116-122: 주석 처리된 코드 제거를 권장합니다.

Line 118의 // photoRepository.deleteAll(photosToDelete);는 삭제해주세요. 사용하지 않는 주석 코드는 코드베이스를 혼란스럽게 만듭니다.

제안된 변경
         // DB 삭제(트랜잭션 내)
         if (!photosToDelete.isEmpty()) {
-            // photoRepository.deleteAll(photosToDelete);
             photoRepository.deleteAllInBatch(photosToDelete);
 
             log.info("[AlbumExpiration] Album id={} deleted photos count={}", album.getId(), photosToDelete.size());
         }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b49e398 and cb682a3.

📒 Files selected for processing (9)
  • build.gradle
  • src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
  • src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEvent.java
  • src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java
  • src/main/java/com/cheeeese/album/application/StorageDeleteOutboxWriter.java
  • src/main/java/com/cheeeese/album/domain/StorageDeleteOutbox.java
  • src/main/java/com/cheeeese/album/infrastructure/persistence/StorageDeleteOutboxRepository.java
  • src/main/java/com/cheeeese/global/config/RetryConfig.java
  • src/main/java/com/cheeeese/global/util/ObjectStorageDeleteUtil.java
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: dahyun24
Repo: Say-Cheeeese/BE PR: 113
File: src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoRepository.java:155-157
Timestamp: 2026-01-09T17:28:49.242Z
Learning: PhotoRepository의 findAllByAlbumId와 findAllByAlbumIdAndIdNotIn 메서드는 의도적으로 isDeleted 필터를 생략합니다. 이 메서드들은 AlbumExpirationService에서 7일 만료 후 앨범의 모든 사진(isDeleted = TRUE/FALSE 모두)을 물리적으로 삭제(하드 delete)하기 위해 사용되며, 프로젝트에서 유일한 하드 delete 로직입니다.
Learnt from: zyovn
Repo: Say-Cheeeese/BE PR: 114
File: src/main/java/com/cheeeese/album/application/AlbumService.java:234-246
Timestamp: 2026-01-12T16:03:32.619Z
Learning: 블랙리스트(추방) 처리 시 해당 사용자의 사진들은 하드 삭제(물리적 삭제)를 수행합니다. 이는 AlbumService의 removeUserFromAlbum 메서드에서 photoRepository.deleteAllByIds()를 통해 구현되며, 소프트 삭제가 아닌 의도적인 설계입니다.
📚 Learning: 2025-11-13T12:56:22.161Z
Learnt from: dahyun24
Repo: Say-Cheeeese/BE PR: 58
File: src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java:149-156
Timestamp: 2025-11-13T12:56:22.161Z
Learning: In src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java, the finalizeCheese4cut method intentionally re-sorts photos using findAllByIdInOrderByLikesDescCreatedDesc(request.photoIds()) instead of preserving the client's requested order. This is a defensive measure to ensure photos are always ordered by likes (DESC) and creation time (DESC), regardless of what order the client sends, preventing incorrect ordering from client errors.

Applied to files:

  • src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
📚 Learning: 2026-01-09T17:28:49.242Z
Learnt from: dahyun24
Repo: Say-Cheeeese/BE PR: 113
File: src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoRepository.java:155-157
Timestamp: 2026-01-09T17:28:49.242Z
Learning: PhotoRepository의 findAllByAlbumId와 findAllByAlbumIdAndIdNotIn 메서드는 의도적으로 isDeleted 필터를 생략합니다. 이 메서드들은 AlbumExpirationService에서 7일 만료 후 앨범의 모든 사진(isDeleted = TRUE/FALSE 모두)을 물리적으로 삭제(하드 delete)하기 위해 사용되며, 프로젝트에서 유일한 하드 delete 로직입니다.

Applied to files:

  • src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
  • src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java
📚 Learning: 2025-10-31T13:17:52.523Z
Learnt from: dahyun24
Repo: Say-Cheeeese/BE PR: 35
File: src/main/java/com/cheeeese/photo/application/PhotoService.java:46-52
Timestamp: 2025-10-31T13:17:52.523Z
Learning: In src/main/java/com/cheeeese/photo/application/PhotoService.java, the getRecentThumbnailUrls method intentionally returns only the first thumbnail URL when photos.size() < 5, rather than returning all available thumbnails. This is according to product requirements: 0 photos → empty list, 1-4 photos → single thumbnail (most recent), 5 photos → all 5 thumbnails.

Applied to files:

  • src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
📚 Learning: 2026-01-12T16:03:32.619Z
Learnt from: zyovn
Repo: Say-Cheeeese/BE PR: 114
File: src/main/java/com/cheeeese/album/application/AlbumService.java:234-246
Timestamp: 2026-01-12T16:03:32.619Z
Learning: 블랙리스트(추방) 처리 시 해당 사용자의 사진들은 하드 삭제(물리적 삭제)를 수행합니다. 이는 AlbumService의 removeUserFromAlbum 메서드에서 photoRepository.deleteAllByIds()를 통해 구현되며, 소프트 삭제가 아닌 의도적인 설계입니다.

Applied to files:

  • src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
  • src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java
🔇 Additional comments (11)
src/main/java/com/cheeeese/global/config/RetryConfig.java (1)

1-9: LGTM!

Spring Retry 활성화를 위한 표준 설정입니다. @Retryable 어노테이션 사용을 위해 필요한 구성이 올바르게 추가되었습니다.

src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEvent.java (1)

5-14: LGTM!

Java record를 활용한 깔끔한 도메인 이벤트 설계입니다. 불변 객체로 이벤트 데이터를 안전하게 전달할 수 있습니다.

src/main/java/com/cheeeese/album/domain/StorageDeleteOutbox.java (1)

10-41: LGTM!

Outbox 패턴 구현을 위한 엔티티가 잘 설계되었습니다. 정적 팩토리 메서드와 protected 기본 생성자를 사용하여 객체 생성을 적절히 캡슐화했습니다.

테스트 시 LocalDateTime.now() 직접 호출로 인해 시간 의존성이 생길 수 있습니다. 필요시 Clock을 주입받는 방식을 고려할 수 있으나, 현재 Outbox 용도로는 충분합니다.

src/main/java/com/cheeeese/album/infrastructure/persistence/StorageDeleteOutboxRepository.java (1)

1-7: LGTM!

표준 JpaRepository 인터페이스 구현입니다.

향후 운영 시 배치 재처리를 위한 커스텀 쿼리 메서드(예: findAllByCreatedAtBefore, 상태 기반 조회 등)가 필요할 수 있습니다. 현재 구현은 기본 기능으로 충분합니다.

src/main/java/com/cheeeese/album/application/StorageDeleteOutboxWriter.java (1)

10-19: LGTM!

Outbox Writer가 Propagation.REQUIRES_NEW를 사용하는 것은 올바른 설계입니다. 이를 통해 호출자의 트랜잭션 상태와 관계없이 outbox 엔트리가 독립적으로 저장됩니다.

src/main/java/com/cheeeese/global/util/ObjectStorageDeleteUtil.java (2)

25-47: LGTM! Strict/Non-strict 패턴이 잘 분리되어 있습니다.

기존 호출자는 영향받지 않으면서, 이벤트 핸들러에서 재시도가 필요한 경우 strict 버전을 사용할 수 있어 유연합니다.


68-86: LGTM!

예외 메시지에 bucket과 key 정보가 포함되어 있어 디버깅에 유용합니다. 로그는 throwOnFailure 값과 관계없이 항상 남기므로 모니터링에도 적합합니다.

src/main/java/com/cheeeese/album/application/AlbumStorageDeleteEventHandler.java (2)

25-45: 부분 실패 시 재시도 동작 확인 필요

루프 중간에 실패하면 이미 삭제된 객체들도 재시도 시 다시 삭제를 시도합니다. S3 delete는 idempotent하므로 기능상 문제는 없지만, 이 동작이 의도된 것인지 확인이 필요합니다.

또한 @TransactionalEventListener@Retryable 조합이 예상대로 동작하는지 테스트해보시기 바랍니다. Spring의 이벤트 리스너 프록시와 Retry 프록시가 동일 메서드에 적용될 때 순서에 따라 동작이 달라질 수 있습니다.


63-70: LGTM!

JSON 직렬화 실패 시 fallback으로 최소한의 정보(albumId, count)를 보존하는 방어적 코딩이 좋습니다.

src/main/java/com/cheeeese/album/application/AlbumExpirationService.java (2)

124-132: LGTM! 이벤트 기반 접근 방식이 잘 설계되었습니다.

트랜잭션 내에서 이벤트를 발행하고 AFTER_COMMIT 핸들러에서 스토리지 삭제를 처리하는 방식이 올바릅니다. 이를 통해:

  • DB 삭제가 커밋된 후에만 스토리지 삭제가 실행됩니다
  • 트랜잭션 롤백 시 스토리지 삭제가 수행되지 않아 일관성이 유지됩니다

Based on learnings, findAllByAlbumIdisDeleted 필터 없이 모든 사진을 조회하는 것은 의도된 하드 삭제 로직입니다.


106-114: LGTM!

삭제 전에 URL 정보를 수집하여 이벤트 payload를 구성하는 것이 올바른 순서입니다. DB에서 삭제된 후에는 URL 정보에 접근할 수 없기 때문입니다.

Copy link
Member

@zyovn zyovn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다~~
나중에 outbox 관련 삭제 스케쥴러나 ttl 추가해도 좋을 것 같아요!
뭔가 계속 영구 보관할 필요는 없을 것 같으니..

@dahyun24 dahyun24 merged commit c21e61d into develop Jan 17, 2026
1 check passed
@zyovn zyovn deleted the CEEZ-62-사진-삭제-트랜잭션 branch January 18, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants