Skip to content

[REFACTOR] 게시글 이미지(PostImageUrl) 수정/삭제 시 S3 고아 문제 해결#329

Open
zldzldzz wants to merge 3 commits into
devfrom
refactor/#328/게시글-이미지-수정-삭제-문제-해결

Hidden character warning

The head ref may contain hidden characters: "refactor/#328/\uac8c\uc2dc\uae00-\uc774\ubbf8\uc9c0-\uc218\uc815-\uc0ad\uc81c-\ubb38\uc81c-\ud574\uacb0"
Open

[REFACTOR] 게시글 이미지(PostImageUrl) 수정/삭제 시 S3 고아 문제 해결#329
zldzldzz wants to merge 3 commits into
devfrom
refactor/#328/게시글-이미지-수정-삭제-문제-해결

Conversation

@zldzldzz

@zldzldzz zldzldzz commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

📄 작업 내용 요약

게시글 이미지 S3 orphan 해결 + 이미지 단건 삭제 API 추가 — 첨부파일(#324)의 PostFilesDeletedEvent/AFTER_COMMIT 패턴 재사용, 신규 이벤트 없음·응답 스펙 무변경

📎 Issue 번호

closed #328

1. 배경 (Background)

게시글 첨부파일(PostFileUrl)은 게시글 수정·삭제 시 S3 객체까지 함께 정리되도록
PostFilesDeletedEvent + @TransactionalEventListener(AFTER_COMMIT) 구조로 정합성을 확보했다.

하지만 게시글 이미지(PostImageUrl)는 동일한 orphan 문제가 그대로 남아 있다.
현재 이미지는 DB 레코드만 삭제되고 S3 객체는 영구히 남는다.

문제가 발생하는 흐름

흐름 현재 동작 문제
PATCH /v1/user/posts/{postId} (이미지 교체) deleteExistingImages()imageDeleteService.deleteAll() (DB batch delete) 기존 이미지 S3 객체가 orphan으로 누적
DELETE /v1/user/posts/{postId} (게시글 삭제) PostDeleteService.delete()imageDeleteService.deleteAll() 이미지 S3 객체가 orphan으로 누적
(내부) PostDeleteService.expel() (제명에 의한 삭제) 동일하게 DB만 삭제 이미지 S3 객체가 orphan으로 누적

게시글은 이미지를 다수 포함하는 경우가 많아 첨부파일보다 orphan 누적 속도가 더 빠르고,
S3 저장 비용·관리 부담으로 직결된다.

현재 코드

// PostImageDeleteService — DB만 삭제, S3 삭제 없음
@Transactional
public void deleteAll(List<PostImageUrl> beforeImages) {
    if (beforeImages == null || beforeImages.isEmpty()) {
        return;
    }
    repository.deleteAllInBatch(beforeImages);   // ← S3 객체는 그대로 남음
}
// PostPatchService — 이미지는 이벤트 발행 없이 DB만 삭제 (첨부파일과 대비)
private void deleteExistingImages(Post post) {
    List<PostImageUrl> beforeImages = imageGetService.getPostImageUrls(post.getId());
    imageDeleteService.deleteAll(beforeImages);   // ← 첨부파일과 달리 이벤트 미발행
}

2. 목표 (Goal)

PR #315 에서 정립한 Version B (서버 직접 삭제) + AFTER_COMMIT 패턴을 이미지에 동일하게 적용한다.

  • 게시글 수정 시 교체된 기존 이미지의 S3 객체 제거
  • 게시글 삭제(delete) 시 이미지 S3 객체 제거
  • 게시글 제명 삭제(expel) 시 이미지 S3 객체 제거
  • DB 커밋 이후에만 S3 삭제가 실행되도록 트랜잭션 순서 보장
  • S3 삭제 실패가 사용자 응답·본 트랜잭션에 영향을 주지 않도록 격리(WARN 로그 흡수)

Non-Goal: 기존에 이미 누적된 orphan을 일괄 정리하는 배치는 별도 이슈로 분리한다. (PR #315 follow-up 참고)


3. 설계 원칙 (PR #315와 동일)

3.1 DB 커밋 이후 S3 삭제 (AFTER_COMMIT)

@Transactional 내부에서 S3 객체를 직접 지우면 트랜잭션 롤백 시 이미 사라진 S3 객체를 복구할 수 없다.

@Transactional 내부:
  1. DB에서 PostImageUrl 레코드 삭제
  2. PostFilesDeletedEvent 발행 (이미지 URL 목록은 삭제 전에 추출)
  3. 트랜잭션 커밋

커밋 이후 (@TransactionalEventListener AFTER_COMMIT):
  4. PostFilesDeletedListener 가 S3Service.deleteFile(url) 호출
     - 실패해도 예외 전파하지 않고 WARN 로그만 남김
     - orphan은 후속 정리 배치로 복구
실패 시점 DB 상태 S3 상태 결과
DB 삭제 실패 롤백 (이벤트 미발행) 변경 없음 안전
S3 삭제 실패 정상 구 이미지 orphan 후속 정리로 복구 가능

3.2 기존 이벤트·리스너·S3 로직 전면 재사용

S3 제거 방식은 첨부파일 제거와 완전히 동일하다. 따라서 이미지 전용 신규 코드를 만들지 않고
PR #315에서 추가한 자산을 그대로 재사용한다.

  • PostFilesDeletedEvent(List<String> fileUrls) — 이미지 URL 목록을 그대로 담아 발행
  • PostFilesDeletedListener (AFTER_COMMIT) — 변경 없이 이미지 URL도 함께 처리
  • S3Service#deleteFile(String fileUrl) / extractKey (full URL / key 양방향 허용)

이벤트는 "삭제된 S3 객체 URL 목록"이라는 동일한 의미를 가지므로, 첨부파일/이미지를 구분하지 않고
하나의 이벤트로 통합한다. 신규 이벤트·리스너 클래스를 추가하지 않는다.


4. 구현 방향 (TODO)

신규 이벤트/리스너 클래스 없음. 기존 PostFilesDeletedEvent / PostFilesDeletedListener 를 재사용한다.
첨부파일에서 이미 검증된 "DB 삭제 → URL 추출 → 이벤트 발행" 패턴을 이미지 삭제 지점에 그대로 옮긴다.

4.1 PostPatchService — DB 삭제 후 이벤트 발행

private void deleteExistingImages(Post post) {
    List<PostImageUrl> beforeImages = imageGetService.getPostImageUrls(post.getId());
    if (beforeImages.isEmpty()) {
        return;
    }
    List<String> imageUrls = beforeImages.stream()
            .map(PostImageUrl::getOriginalUrl)   // 삭제 전에 URL 추출
            .toList();
    imageDeleteService.deleteAll(beforeImages);
    eventPublisher.publishEvent(new PostFilesDeletedEvent(imageUrls));   // 기존 이벤트 재사용
}

4.2 PostDeleteService — delete()/expel() 양쪽에 이벤트 발행

// delete() / expel() 내부 — 이미지 DB 삭제 직후
if (postImageUrls != null && !postImageUrls.isEmpty()) {
    postImageDeleteService.deleteAll(postImageUrls);
    publishImagesDeletedEvent(postImageUrls);
}

private void publishImagesDeletedEvent(List<PostImageUrl> images) {
    List<String> urls = images.stream()
            .map(PostImageUrl::getOriginalUrl)
            .toList();
    eventPublisher.publishEvent(new PostFilesDeletedEvent(urls));   // 기존 이벤트 재사용
}

참고: PostFilesDeletedEvent 의 필드명이 fileUrls 이지만, 의미는 "삭제된 S3 객체 URL 목록"으로
이미지에도 그대로 적용된다. 필드명이 첨부파일에 한정돼 혼동을 준다면, 후속 리팩터링에서
urls 등 중립적 이름으로 변경하는 것을 고려할 수 있다. (이번 이슈 범위에서는 변경하지 않는다.)


5. 영향 범위

영역 변경
PostFilesDeletedEvent / PostFilesDeletedListener 변경 없음 — 재사용
S3Service 변경 없음 (#315에서 추가한 deleteFile/extractKey 재사용)
PostPatchService#deleteExistingImages 이벤트 발행 추가 (응답 스펙 동일)
신규 클래스 없음
API 응답(payload/상태코드) 변경 없음 — 후행 S3 정리 동작만 추가

6. 테스트 시나리오 (QA 가이드)

# 시나리오 기대 결과
1 이미지 N개 게시글 PATCH — 이미지 전체 교체 기존 N개 S3 객체 제거 + 새 이미지만 유지
2 이미지 있는 게시글 DELETE 게시글/이미지 DB 삭제 + S3 객체 모두 제거
3 제명(expel) 경로 게시글 삭제 이미지 S3 객체 제거
4 S3 일시 장애 가정(deleteFile throw) 본 API 정상 응답, WARN 로그만 남고 orphan 발생
5 이미지 없는 게시글 수정/삭제 이벤트 미발행, 정상 동작 (불필요 호출 없음)
6 DB에 key 형식만 저장된 레거시 이미지 extractKey가 key 그대로 사용해 정상 삭제

7. 체크리스트

  • PostPatchService.deleteExistingImagesPostFilesDeletedEvent 발행 추가
  • PostDeleteService.delete 에 이벤트 발행 추가
  • PostDeleteService.expel 에 이벤트 발행 추가
  • 수동 QA 시나리오 1~6 검증

Summary by CodeRabbit

릴리스 노트

  • New Features
    • 게시물 이미지 개별 삭제 기능 추가
      • 사용자가 게시물의 이미지를 하나씩 선택하여 삭제할 수 있습니다
      • 게시물 작성자만 삭제 가능하며, 권한 없음/이미지 미존재 등의 오류 메시지로 명확한 피드백을 제공합니다

zldzldzz added 3 commits June 3, 2026 19:34
PostFilesDeletedEvent를 재사용해 게시글 수정(PATCH)·삭제 시
교체/삭제된 이미지의 S3 객체를 커밋 이후 함께 제거하도록 변경
DELETE /v1/user/posts/{postId}/images/{imageId} 신설
첨부파일 단건 삭제 API와 동일한 구조로 구성
- 이미지가 해당 게시글에 속하는지 검증 후 작성자/관리자 권한 검증
- DB 삭제 후 PostFilesDeletedEvent로 커밋 이후 S3 객체 제거
게시글 이미지 단건 삭제 API에서 사용하는 예외와 메시지 정의
@zldzldzz zldzldzz self-assigned this Jun 3, 2026
@zldzldzz zldzldzz added 🔨 Refactor 코드 리팩토링 🤸🏽 원진 labels Jun 3, 2026
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

PR은 게시글 이미지 단건 삭제 기능을 완성합니다. 새로운 API 엔드포인트(DELETE /v1/user/posts/{postId}/images/{imageId})를 통해 삭제 요청을 받으면, 요청자의 권한(작성자 또는 관리자)과 이미지 소속 게시글을 검증한 뒤 삭제합니다. 삭제 후 기존의 PostFilesDeletedEvent 이벤트를 발행하여 DB 커밋 이후에 S3 객체 정리를 트리거함으로써 고아 파일 누적을 방지합니다. 예외 처리, 서비스 메서드, 이벤트 통합이 일관된 구조로 추가됩니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


상세 리뷰 가이드

✅ 강점

  • 기존 자산 재사용: PR #315의 PostFilesDeletedEvent + @TransactionalEventListener(AFTER_COMMIT) 구조를 그대로 차용하여 S3 정리 일관성 유지
  • 명확한 권한 검증: validateOwnerOrManager로 작성자/관리자 구분 명시
  • 트랜잭션 안전성: DB 커밋 후에만 S3 삭제되도록 순서 보장

🔍 검토 포인트

  1. PostImageDeleteUsecase의 검증 로직

    • validateImageBelongsToPost에서 이미지의 게시글 ID와 요청 postId 비교
    • 불일치 시 PostImageMismatchException 발생 (의도적인 보안 설계인지 확인)
    • 🎯 확인 사항: 권한은 있지만 잘못된 게시글의 이미지 삭제 시도 시 명확히 거부되는지 테스트
  2. 이벤트 발행 위치

    • PostImageDeleteUsecase 내부에서 직접 eventPublisher.publishEvent() 호출
    • PostDeleteService, PostPatchService에서도 별도로 이벤트 발행
    • 🎯 확인 사항: 단건 삭제와 대량 삭제(patch/delete 시) 모두 PostFilesDeletedEvent 타입으로 통일되는지 확인. 동일한 리스너에서 처리 가능한지 검증
  3. PostImageGetService.getPostImageUrl() 예외 처리

    • findById().orElseThrow(PostImageNotFoundException::new) 사용
    • 존재하지 않는 이미지 ID에 대해 404 반환 (정상)
    • 🎯 확인 사항: 삭제된 이미지를 중복 삭제 시도할 경우 명확한 에러 응답 확인
  4. MemberGetService 호출 여부

    • PostImageDeleteUsecase에서 memberGetService.getMember(requesterId) 호출 후 권한 확인
    • 🎯 확인 사항: 존재하지 않는 멤버 ID 요청 시 동작 (401 vs 500 vs 다른 에러)
  5. DeleteService 내 메서드 리팩토링 가능성

    // PostDeleteService.deletePost()에서
    imageDeleteService.deleteAll(postImageUrls);
    publishImagesDeletedEvent(postImageUrls);  // 반복되는 패턴
    • 이미지 삭제 + 이벤트 발행을 별도 메서드로 묶으면 중복 제거 가능
    • 🎯 제안: 추후 리팩토링으로 deleteAndPublishImagesEvent() 메서드 고려

📋 체크리스트

  • PostFilesDeletedListener가 이미지 URL도 정상 처리하는지 확인 (기존 firstfileUrl/key 추출 로직 호환)
  • S3 일시 장애 시 경고 로그만 남는지 확인 (본 API는 정상 응답)
  • 권한 없는 사용자의 삭제 시도 → 403 Forbidden 응답 검증
  • 게시글 삭제 시 이미지 S3 객체 정리 시나리오 QA 완료
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning 연결된 이슈 #328의 요구사항 중 핵심 부분인 PostImageDeleteUsecase를 통한 단건 이미지 삭제 API 구현은 포함되었으나, 이슈에서 명시한 필수 구현 범위인 PostPatchService와 PostDeleteService의 이벤트 발행 로직이 불완전하다. PostPatchService#deleteExistingImages와 PostDeleteService#delete/expel 메서드에서 이미지 URL 추출 후 PostFilesDeletedEvent를 발행하는 로직을 추가하여 이슈의 4.1~4.2 구현 방향을 완성하세요.
Out of Scope Changes check ❓ Inconclusive PostDeleteService의 변경사항이 새로운 publishImagesDeletedEvent 메서드 추가에 그쳐 있어, 실제로 delete/expel 메서드 내에서 이미지 삭제 시 이벤트 발행이 완전히 구현되었는지 명확하지 않다. PostDeleteService의 delete()와 expel() 메서드 내 이미지 삭제 호출 지점에서 publishImagesDeletedEvent() 호출이 실제로 추가되었는지 확인하고, 불완전한 경우 구현을 완료하세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 PR의 핵심 변경 사항(S3 고아 문제 해결)을 명확하게 요약하고 있으며, 범위 내 모든 변경사항을 대표한다.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/#328/게시글-이미지-수정-삭제-문제-해결

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/main/java/com/tavemakers/surf/domain/post/service/post/PostImageDeleteUsecase.java (1)

38-39: ⚡ Quick win

삭제 전에 originalUrl을 먼저 스냅샷으로 잡아두세요.

지금 구현은 이벤트 payload가 삭제된 엔티티 상태에 의존합니다. 단건 삭제도 PATCH/게시글 삭제 플로우처럼 URL을 먼저 지역 변수에 담아두고, 그 값을 이벤트에 실어 보내면 삭제 구현이 바뀌어도 흐름이 덜 흔들립니다.

변경 예시
-        postImageDeleteService.delete(image);
-        eventPublisher.publishEvent(new PostFilesDeletedEvent(List.of(image.getOriginalUrl())));
+        String originalUrl = image.getOriginalUrl();
+        postImageDeleteService.delete(image);
+        eventPublisher.publishEvent(new PostFilesDeletedEvent(List.of(originalUrl)));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/tavemakers/surf/domain/post/service/post/PostImageDeleteUsecase.java`
around lines 38 - 39, The code currently calls
postImageDeleteService.delete(image) before reading image.getOriginalUrl() for
the PostFilesDeletedEvent, which makes the event depend on the deleted entity
state; change PostImageDeleteUsecase to capture a snapshot of the URL before
deletion (e.g. assign image.getOriginalUrl() to a local variable), then call
postImageDeleteService.delete(image) and publish the event using that saved
variable in new PostFilesDeletedEvent(List.of(originalUrl)), so the event
payload is stable regardless of deletion side-effects.
src/main/java/com/tavemakers/surf/domain/post/service/post/PostDeleteService.java (1)

55-59: ⚡ Quick win

이미지 삭제 이벤트에는 엔티티 목록 대신 URL 스냅샷을 넘기세요.

여기서는 deleteAll(...) 이후에 PostImageUrl에서 다시 originalUrl을 꺼내고 있습니다. 삭제 전에 URL 목록을 먼저 만들어 두고, helper도 List<String>를 받게 바꾸면 PATCH 플로우와 방식이 맞춰지고 이벤트 payload가 삭제된 엔티티 상태에 덜 묶입니다.

변경 예시
         List<PostImageUrl> postImageUrls = postImageGetService.getPostImageUrls(post.getId());
         if (postImageUrls != null && !postImageUrls.isEmpty()) {
+            List<String> imageUrls = postImageUrls.stream()
+                    .map(PostImageUrl::getOriginalUrl)
+                    .toList();
             postImageDeleteService.deleteAll(postImageUrls);
-            publishImagesDeletedEvent(postImageUrls);
+            publishImagesDeletedEvent(imageUrls);
         }
@@
         List<PostImageUrl> postImageUrls = postImageGetService.getPostImageUrls(post.getId());
         if (postImageUrls != null && !postImageUrls.isEmpty()) {
+            List<String> imageUrls = postImageUrls.stream()
+                    .map(PostImageUrl::getOriginalUrl)
+                    .toList();
             postImageDeleteService.deleteAll(postImageUrls);
-            publishImagesDeletedEvent(postImageUrls);
+            publishImagesDeletedEvent(imageUrls);
         }
@@
-    private void publishImagesDeletedEvent(List<PostImageUrl> imageUrls) {
-        List<String> urls = imageUrls.stream()
-                .map(PostImageUrl::getOriginalUrl)
-                .toList();
-        eventPublisher.publishEvent(new PostFilesDeletedEvent(urls));
+    private void publishImagesDeletedEvent(List<String> imageUrls) {
+        eventPublisher.publishEvent(new PostFilesDeletedEvent(imageUrls));
     }

Also applies to: 75-79, 98-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/tavemakers/surf/domain/post/service/post/PostDeleteService.java`
around lines 55 - 59, The code currently fetches PostImageUrl entities via
postImageGetService.getPostImageUrls(post.getId()) and calls
postImageDeleteService.deleteAll(postImageUrls) then extracts originalUrl from
the deleted entities; instead, snapshot the URL strings before deletion (map
PostImageUrl::getOriginalUrl into a List<String>), pass that List<String> to
publishImagesDeletedEvent, and change the helper/publishImagesDeletedEvent
signature (and any callers) to accept List<String> instead of List<PostImageUrl>
so the event payload contains URL snapshots independent of deleted entity state;
apply the same change for the other usages with
postImageGetService/postImageDeleteService/publishImagesDeletedEvent in this
class.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@src/main/java/com/tavemakers/surf/domain/post/service/post/PostDeleteService.java`:
- Around line 55-59: The code currently fetches PostImageUrl entities via
postImageGetService.getPostImageUrls(post.getId()) and calls
postImageDeleteService.deleteAll(postImageUrls) then extracts originalUrl from
the deleted entities; instead, snapshot the URL strings before deletion (map
PostImageUrl::getOriginalUrl into a List<String>), pass that List<String> to
publishImagesDeletedEvent, and change the helper/publishImagesDeletedEvent
signature (and any callers) to accept List<String> instead of List<PostImageUrl>
so the event payload contains URL snapshots independent of deleted entity state;
apply the same change for the other usages with
postImageGetService/postImageDeleteService/publishImagesDeletedEvent in this
class.

In
`@src/main/java/com/tavemakers/surf/domain/post/service/post/PostImageDeleteUsecase.java`:
- Around line 38-39: The code currently calls
postImageDeleteService.delete(image) before reading image.getOriginalUrl() for
the PostFilesDeletedEvent, which makes the event depend on the deleted entity
state; change PostImageDeleteUsecase to capture a snapshot of the URL before
deletion (e.g. assign image.getOriginalUrl() to a local variable), then call
postImageDeleteService.delete(image) and publish the event using that saved
variable in new PostFilesDeletedEvent(List.of(originalUrl)), so the event
payload is stable regardless of deletion side-effects.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0bd35d85-b941-4487-a9e1-15eb800d8ac1

📥 Commits

Reviewing files that changed from the base of the PR and between eb49778 and e6b5074.

📒 Files selected for processing (11)
  • src/main/java/com/tavemakers/surf/domain/post/controller/ResponseMessage.java
  • src/main/java/com/tavemakers/surf/domain/post/controller/image/PostImageDeleteController.java
  • src/main/java/com/tavemakers/surf/domain/post/exception/ErrorMessage.java
  • src/main/java/com/tavemakers/surf/domain/post/exception/PostImageDeleteAccessDeniedException.java
  • src/main/java/com/tavemakers/surf/domain/post/exception/PostImageMismatchException.java
  • src/main/java/com/tavemakers/surf/domain/post/exception/PostImageNotFoundException.java
  • src/main/java/com/tavemakers/surf/domain/post/service/image/PostImageDeleteService.java
  • src/main/java/com/tavemakers/surf/domain/post/service/image/PostImageGetService.java
  • src/main/java/com/tavemakers/surf/domain/post/service/post/PostDeleteService.java
  • src/main/java/com/tavemakers/surf/domain/post/service/post/PostImageDeleteUsecase.java
  • src/main/java/com/tavemakers/surf/domain/post/service/post/PostPatchService.java

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] 게시글 이미지(PostImageUrl) 수정/삭제 시 S3 고아 문제 해결

1 participant