[REFACTOR] 게시글 이미지(PostImageUrl) 수정/삭제 시 S3 고아 문제 해결#329
Hidden character warning
Conversation
PostFilesDeletedEvent를 재사용해 게시글 수정(PATCH)·삭제 시 교체/삭제된 이미지의 S3 객체를 커밋 이후 함께 제거하도록 변경
DELETE /v1/user/posts/{postId}/images/{imageId} 신설
첨부파일 단건 삭제 API와 동일한 구조로 구성
- 이미지가 해당 게시글에 속하는지 검증 후 작성자/관리자 권한 검증
- DB 삭제 후 PostFilesDeletedEvent로 커밋 이후 S3 객체 제거
게시글 이미지 단건 삭제 API에서 사용하는 예외와 메시지 정의
📝 WalkthroughWalkthroughPR은 게시글 이미지 단건 삭제 기능을 완성합니다. 새로운 API 엔드포인트( Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 상세 리뷰 가이드✅ 강점
🔍 검토 포인트
📋 체크리스트
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 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
📒 Files selected for processing (11)
src/main/java/com/tavemakers/surf/domain/post/controller/ResponseMessage.javasrc/main/java/com/tavemakers/surf/domain/post/controller/image/PostImageDeleteController.javasrc/main/java/com/tavemakers/surf/domain/post/exception/ErrorMessage.javasrc/main/java/com/tavemakers/surf/domain/post/exception/PostImageDeleteAccessDeniedException.javasrc/main/java/com/tavemakers/surf/domain/post/exception/PostImageMismatchException.javasrc/main/java/com/tavemakers/surf/domain/post/exception/PostImageNotFoundException.javasrc/main/java/com/tavemakers/surf/domain/post/service/image/PostImageDeleteService.javasrc/main/java/com/tavemakers/surf/domain/post/service/image/PostImageGetService.javasrc/main/java/com/tavemakers/surf/domain/post/service/post/PostDeleteService.javasrc/main/java/com/tavemakers/surf/domain/post/service/post/PostImageDeleteUsecase.javasrc/main/java/com/tavemakers/surf/domain/post/service/post/PostPatchService.java
📄 작업 내용 요약
게시글 이미지 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)DELETE /v1/user/posts/{postId}(게시글 삭제)PostDeleteService.delete()→imageDeleteService.deleteAll()PostDeleteService.expel()(제명에 의한 삭제)게시글은 이미지를 다수 포함하는 경우가 많아 첨부파일보다 orphan 누적 속도가 더 빠르고,
S3 저장 비용·관리 부담으로 직결된다.
현재 코드
2. 목표 (Goal)
PR #315 에서 정립한 Version B (서버 직접 삭제) + AFTER_COMMIT 패턴을 이미지에 동일하게 적용한다.
delete) 시 이미지 S3 객체 제거expel) 시 이미지 S3 객체 제거3. 설계 원칙 (PR #315와 동일)
3.1 DB 커밋 이후 S3 삭제 (AFTER_COMMIT)
@Transactional내부에서 S3 객체를 직접 지우면 트랜잭션 롤백 시 이미 사라진 S3 객체를 복구할 수 없다.3.2 기존 이벤트·리스너·S3 로직 전면 재사용
S3 제거 방식은 첨부파일 제거와 완전히 동일하다. 따라서 이미지 전용 신규 코드를 만들지 않고
PR #315에서 추가한 자산을 그대로 재사용한다.
PostFilesDeletedEvent(List<String> fileUrls)— 이미지 URL 목록을 그대로 담아 발행PostFilesDeletedListener(AFTER_COMMIT) — 변경 없이 이미지 URL도 함께 처리S3Service#deleteFile(String fileUrl)/extractKey(full URL / key 양방향 허용)4. 구현 방향 (TODO)
4.1 PostPatchService — DB 삭제 후 이벤트 발행
4.2 PostDeleteService — delete()/expel() 양쪽에 이벤트 발행
5. 영향 범위
PostFilesDeletedEvent/PostFilesDeletedListenerS3ServicedeleteFile/extractKey재사용)PostPatchService#deleteExistingImages6. 테스트 시나리오 (QA 가이드)
PATCH— 이미지 전체 교체DELETEexpel) 경로 게시글 삭제deleteFilethrow)extractKey가 key 그대로 사용해 정상 삭제7. 체크리스트
PostPatchService.deleteExistingImages에PostFilesDeletedEvent발행 추가PostDeleteService.delete에 이벤트 발행 추가PostDeleteService.expel에 이벤트 발행 추가Summary by CodeRabbit
릴리스 노트