3/9(월) PATCH 메서드로 리뷰 수정 API 구현하기, soft delete 구현

dev_joo·2026년 3월 9일
post-thumbnail

update - 본인의 리뷰 수정

코드 짜기 전에 생각했나요??

이전에 create Service 코드를 짤 때 생각하지 못한 조건이나 잘못 구현한 것을 갈아 엎느라 너무 많은 시간이 소요됐다.

때문에, 먼저 프랩 코드와 구현 방식을 구상한 뒤에 코드를 작성하기로 했다.
(토요일 출근길 지하철에서 지피티와 함께...ㅎㅎ;)

function canEditReview(userId, reviewId) {

  review = reviewRepository.find(reviewId)

  if (review.userId != userId)
      return false

  if (now > review.createdAt + 48hours)
      return false

  return true
}
//주문 기준 일때:
if (now > order.deliveredAt + 2days)
    return false

+ 리뷰 도메인에서 추가 구현기능 아이디어

내 GPT만 그런지 모르겠는데, 자꾸 사담을 늘어놓으면서 현업에선 어떻게 하는지 알려줄까? 라며 오지랖을 놓는다.

그래서 결국 호기심을 이기지 못하고 대화를 하며 추가 구현 할 수 있는 부분을 적어두었다.

추가 구현 가능
1. 사장님 댓글 (수정정책 적용)
2. 리뷰 신고
3. 리뷰 히스토리 (악성리뷰 관리용 이었나? 수정 전 원본을 저장하기도 한다고..:
review_history
--------------
id
review_id
old_content
new_content
modified_at
)
4. 추가 정렬 기준 (사진 포함, 사장님 답글 :
ORDER BY
has_reply DESC,
has_image DESC,
created_at DESC)
5. 신고 누적 숨김 report_count >= 5 → hidden
6. 금칙어 필터링 (욕설,광고,전화번호)
7. 좋아요
8. 태그 : 맛있어요 / 양 많아요
9. AI 리뷰 요약

확장 가능성이 정말 무궁무진하다.😂

HTTP Method 선택에 대한 고민

수정에 PUT vs PATCH

나는 RestAPI의 개발 경험이 적어서,
수정을 구현하는데 HTTP 메서드를 PUT으로 할 지, PETCH로 할 지 고민되었다.
처음에 나는 각 정보를 바꾸기 위해 여러번 요청을 받는 것 보단 프론트에서 form 형식으로 리뷰 내용 작성을 완료한 뒤
create와 같이 requestDTO형태로 한 번에 모든 정보를 담아 요청하는 것이 좋을 것 같아 PUT메서드를 사용하는 것이 좋다고 생각했다.

PUT 이해하기

PUT은 보통 idempotent full update(*멱등성 전체 업데이트)에 사용된다.

*멱등성: 연산(작업)을 한 번 적용하든 여러 번 반복해서 적용하든 결과가 달라지지 않고 같은 상태를 유지하는 성질, API에선 동일한 요청을 여러 번 보내도 서버의 상태가 처음과 같이 유지되는 것

PUT을 쓰면 발생할 수 있는 문제:

예를 들어 리뷰 수정 중 사용자가 별점이나 이미지 수정 없이 코멘트만을 수정할 때, 앞서 생각한 것 처럼 리뷰에 필요한 정보를 모두 보내지 않고

{
  "comment": "그냥 보통이에요"
}

만을 보내면 서버는 이를 전체 교체(PUT)로 이해하고

{
  "rating": null,
  "user" : null,
  "comment": "그냥 보통이에요",
  "images": null,
  ...
}

그리고 리뷰 수정이 항상 create와 같은 전체 폼 형태로 이뤄지지 않을 수도 있다.
즉, 프론트가 항상 리뷰의 전체 상태를 갖고 있지 않을 수 있다.

게다가 부분 수정의 경우 필요한 정보만 보내도 수정 정보는 충분한데,
PUT으로 구현한다면 매 수정 요청마다 무조건 모든 전체 필드를 채워서 보내야 해 비효율적이다.

PATCH 이해하기

PATCH는 주로 삭제할 대상만 명시하는 명령형 업데이트 방식으로 사용된다.

이미지 리스트에서 이미지 하나만 삭제할 때:

원본 :
[img1, img2, img3]

상태에서 사용자가 img2 이미지를 삭제하려는 경우,
클라이언트는 전체 목록을 다시 보내는 대신 다음과 같이 삭제할 대상만을 명시한다.

사용자가 img2 삭제:
{
  "deleteImageIds": ["img2"]
}

서버 동작:
현재 이미지 목록에서 img2만 제거된다.

결과:
[img1, img3]

결론을 요약해서 말하자면, PUT과 PATCH는 분식집의 벽에 붙어있는 메뉴판의 있는 가격을 수정할 때와 같다.

PUT 으로 수정 하는 것은, 메뉴판 전체를 갖다 버리고 새로 만들어 다는 것이고,
PATCH 로 수정하는 것은, 메뉴판에서 바뀐 메뉴의 가격만 스티커로 덧붙이는 것이다.

그래서 리뷰 수정 구현을 위한 HTTP 메서드는 PATCH를 사용하기로 했다.

이미지 수정을 위한 엔드포인트를 따로 나눌것인가?

이미지 업로드가 우리가 계획한 S3 업로드 처럼 별도 프로세스로 나눠져 있을 때,

이미지 수가 많고 관리가 복잡하거나, 노션처럼 실시간으로 이미지를 조작해야하는 경우 엔드포인트를 분리하는 경우도 있다고 한다.

하지만 배달 앱 리뷰의 경우 비교적 이미지 수가 적고, 수정도 빈번하게 일어나지 않아 단일 API로 처리하기로 했다.

코드 짜기 전에 생각했어요!!

  1. 리뷰 수정 요청(create와 비슷하지만 리뷰Id와 변경된 데이터 정보만 전달)
  2. 원본 리뷰를 DB에서 읽어와 객체 정보 수정
  3. 수정된 정보로 DB에 저장

ReviewUpdateRequest

이미지 수정의 경우 요청 DTO에 바뀐 정보만을 포함시키기 때문에 @NotNull 검증을 사용하지 않는다.

public record ReviewUpdateRequest(
        @Min(1)
        @Max(5)
        Double rating,
        String comment,
        List<ReviewImage> updatedImages,
        List<String> deleteImages
) {}

그런데 이제 이미지는 create 때 처럼 MultipartFile로 처리해야겠다 하다가 문득 드는 생각.

이미지 업로드를 프론트 서버에서 S3에 올리고 나서 url만 DTO에 담아 요청하면 MultipartFile을 쓸 필요 없지 않을까?

아이고.. 역시 생각엔 끝이 없다...😅
그치만 처음부터 완벽한 서비스가 어딨을까! 일단 프론트서버 없이 정적 소스만 준다 생각하고 그대로 Multipart로 구현해보기로! (벌써 테스트 할 생각에 아득해지지만...🥲)

Controller

@PatchMapping("/reviews/{reviewId}")
public ResponseEntity<ReviewResponse> update(@PathVariable UUID reviewId,
                                             @AuthenticationPrincipal User user,
                                             @Valid @RequestPart ReviewUpdateRequest request,
                                             @RequestPart(value = "images", required = false) List<MultipartFile> images) {
    ReviewResponse response = reviewService.updateReview(reviewId, user, request, images);
    return ResponseEntity.ok(response);
}

Servie

@Transactional
    public ReviewResponse updateReview(UUID reviewId, User       user, ReviewUpdateRequest request, List<MultipartFile> images) {
        // 리뷰 조회
        Review old = reviewRepository.findByIdAndIsDeletedFalse(reviewId)
                .orElseThrow(() -> new OminBusinessException(ErrorCode.REVIEW_NOT_FOUND));

        // 본인의 리뷰가 아니면 예외
        if (!old.getUser().getId().equals(user.getId())) {
            throw new OminBusinessException(ErrorCode.REVIEW_USER_MISMATCH);
        }

        // 주문일 + 2일 초과면 예외 --> status 변경시간 + 2일
        if (old.getOrder().getCreatedAt().plusDays(2).isBefore(LocalDateTime.now())) {
            throw new OminBusinessException(ErrorCode.REVIEW_UPDATE_PERIOD_EXPIRED);
        }

        if (images != null && images.size() - request.deleteImageUrls().size() > 5) {
            throw new OminBusinessException(ErrorCode.REVIEW_IMAGE_COUNT_EXCEEDED);
        }

        // 통계 업데이트 (일단 비관적 락 사용)
        if (request.rating() != null && !request.rating().equals(old.getRating())) {
            double ratingDiff = request.rating() - old.getRating(); // 기존 평점과의 차이(양수: 올라감, 음수: 내려감)
            StoreRatingStat stat = statRepository.findByStoreIdWithLock(old.getStore().getId())
                    .orElseGet(() -> statRepository.save(StoreRatingStat.create(old.getStore().getId(), 0)));
            stat.updateRatingByDiff(ratingDiff);
        }

        // TODO: 이미지 처리 로직
        handleImageUpdates(old, request.updateImageUrls(), request.deleteImageUrls(), images);

        old.updateReview(
                request.rating() != null ? request.rating() : old.getRating(),
                request.comment() != null ? request.comment() : old.getComment(),
                user.getId()
        );
        
        return ReviewResponse.from(old);
    }

1차 테스트

기존 정보:
기존 정보
수정 실행:

createdAt 업데이트가 되지 않았다...!?

Auditing은 "Flush" 시점에 동작

JPA의 @LastModifiedDate는 자바 객체의 필드를 바꾼 즉시 세팅되는 것이 아니라, DB에 UPDATE 쿼리가 나가는 시점(Flush)AuditingEntityListener 가 동작하며 값을 채워넣기 때문이었다.

현재 상황:

1. service.updateReview() 내부에서 review.updateReview(...) 호출 (필드 값 변경).

2. 아직 DB에 반영(Flush)되기 전에 ReviewResponse DTO를 생성.
이때 review.getUpdatedAt()은 아직 수정 전의 값을 가지고 있음.

3. 메서드가 끝나고 트랜잭션이 커밋될 때 실제 DB에는 수정된 시간으로 저장됨.

4. 결과적으로 DB는 바뀌었지만, 지금 받은 응답창(JSON)에는 이전 시간이 찍히게 됨.

해결 1 : saveAndFlush()

saveAndFlush() 메서드를 사용하여
Service 단에서 수정한 후 명시적으로 DB에 반영하고 엔티티를 최신화한 뒤 DTO로 변환한다.

old.updateReview(
	request.rating() != null ? request.rating() : 		old.getRating(),
	request.comment() != null ? request.comment() : old.getComment(),
	user.getId()
);
// 3. 명시적 저장을 통해 Auditing 트리거 (중요!)
// saveAndFlush를 하면 이 시점에 updatedAt이 세팅됩니다.
reviewRepository.saveAndFlush(review);

return ReviewResponse.from(old);

} // Transactional end

2차 테스트

request만 포함

사진만 포함


연관관계 엔티티는 dirty check에 포함되지 않는다.

그러나 Review가 직접 가지고있는 ReviewImage만을 조작하면
처음엔 Review가 ReviewImage를 포함하는 양방향 관계이기 때문에 JPA가 자동으로 Audit를 해줄 줄 알았다.

하지만, 양방향 관계를 가진 엔티티라도 독립적이라 함께 dirty check되지 않는다.

해결 2: 명시적으로 수동 갱신 (touch 메서드)

Auditing에만 의존하지 않고, 비즈니스 로직 상 수정이 일어났을 때 수동으로 시간을 찍어주는 방식이다.

public void markUpdated() {
	this.updatedAt = LocalDateTime.now(); 
}

해당 메서드를 Review 엔티티에 추가하고 이미지 수정 작업이 일어나는 곳에 추가했다.

private void handleImageJustAdd(Review review, List<MultipartFile> newFiles){
	review.markUpdated();
    //  새 이미지 S3 업로드 및 추가
    if (newFiles != null && !newFiles.isEmpty()) {
    ...

3차 테스트


드디어 원하는대로 동작한다!!🥵

4차 테스트

{
"updateImages" :
    [ 
    	{"imageUrl" : "업로드 이후 생성된 S3 url1", "sequence": "1"},
    	{"imageUrl" : "업로드 이후 생성된 S3 url2", "sequence": "0"}
    ]
}

+Json을 쓸 때: 쉼표 주의! 따옴표 주의!

초기 목록 조회

목록

수정으로 이미지 3개 초기화

수정으로 이미지 초기화

DB에도 초기화 됨을 확인

수정으로 초기화된 이미지

PATCH 수정으로 이미지 순서 변경

{
"comment" : "두 번째 수정",
"updateImages" :
    [ 
    	{"imageUrl" : "업로드 이후 생성된 S3 url1", "sequence": "1"},
		{"imageUrl" : "업로드 이후 생성된 S3 url2", "sequence": "0"}
    ]
}

이미지 순서 변경 요청
바뀐 순서 확인

리뷰 수정 - 이미지 삭제

리뷰의 순서를 바꾸는데 성공했지만, 이미지 삭제 로직이 발제문에서 제시된 모든 데이터에 대한 soft 삭제 구현이 안 되어 있었다는걸 알게 되었다.

(이제 그만 퇴근하고 싶어요...)

@Where

// Review.java 수정
public class Review extends BaseEntity {
    // ... 
    @BatchSize(size = 100)
    @OrderBy("sequence ASC")
    @Where(clause = "is_deleted = false") // 조회 시 삭제된 이미지는 무시
    @OneToMany(mappedBy = "review", cascade = CascadeType.ALL)
    private List<ReviewImage> images = new ArrayList<>();

    // ...
}

org.hibernate.annotations.Where 는 Hibernate 6.3부터 deprecated 되었고, Hibernate 6.4+에서는 @SQLRestriction 사용을 권장한다고 한다.

@SQLRestriction

    @SQLRestriction("is_deleted = false") // 조회 시 삭제된 이미지는 무시
    @BatchSize(size = 100)
    @OrderBy("sequence ASC")
    @OneToMany(mappedBy = "review", cascade = CascadeType.ALL)
    private List<ReviewImage> images = new ArrayList<>();
// ReviewService.java 내 handleReviewImageUpdates 수정
private void handleReviewImageUpdates(
        Review review, 
        List<ReviewImage> updateImages, 
        List<String> deleteUrls, 
        List<MultipartFile> newFiles,
        UUID actorId // 작성자 ID 추가 전달
) {
    boolean isUpdated = false;

    // 1️⃣ 소프트 삭제 처리
    if (deleteUrls != null && !deleteUrls.isEmpty()) {
        for (String url : deleteUrls) {
            // S3 삭제 여부는 팀 정책에 따름 (DB만 남길지, S3도 지울지)
            // imageUploader.deleteReviewImage(url); 

            review.getImages().stream()
                    .filter(img -> img.getImageUrl().equals(url))
                    .findFirst()
                    .ifPresent(img -> {
                        img.delete(actorId); // 물리 삭제 대신 상태 변경
                    });
            isUpdated = true;
        }
        // 중요: 리스트에서 명시적으로 제거하여 영속성 컨텍스트 반영
        review.getImages().removeIf(ReviewImage::isDeleted);
    }

    // ... 2️⃣ 재배치 및 3️⃣ 추가 로직은 동일 ...
}

soft 삭제 테스트


"deleteImages" :
[
  "업로드 이후 생성된 S3 url2", 
]

softdeleted

다음 조회 때
조회 할 땐 @SQLRestriction("is_deleted = false") 때문에 삭제되어 보인다.

경계 테스트 (이미지 개수 최대는 5개): 기존 3개, 삭제 1개, 추가 2개 -> 성공해야함!

{
    "rating": 5,
    "comment": "식으니까 더 맛있네요",
    "deleteImages": [
        "업로드 이후 생성된 S3 url3"
    ]
}

1차 테스트

검증 과정 중에 삭제할 이미지 갯수를 빼 먹은 곳이 있었다.

2차 테스트 (성공!)



삭제 후 삽입하더라도 삭제된 이미지를 제외하고 필요한 4개를 정보가 들어감을 확인 할 수 있었다.

Delete - (soft delete)

    @DeleteMapping("/reviews/{reviewId}")
    public ResponseEntity<Void> deleteReview(@PathVariable UUID reviewId, @AuthenticationPrincipal User user) {
        reviewService.deleteReview(reviewId, user);
        return ResponseEntity.ok().build();
    }

Service

@Transactional
    public void deleteReview(UUID reviewId, User user) {
        Review review = reviewRepository.findByIdAndIsDeletedFalse(reviewId)
                .orElseThrow(() -> new OminBusinessException(ErrorCode.REVIEW_NOT_FOUND));
        if (!review.getUser().getId().equals(user.getId())) {
            throw new OminBusinessException(ErrorCode.REVIEW_USER_MISMATCH);
        }
        statRepository.findByStoreIdWithLock(review.getStore().getId())
                .ifPresent(stat -> stat.updateRatingByDiff(-review.getRating()));
        review.delete();
        review.getImages().forEach(ReviewImage::delete);
    }

Repository

기존에 작성된 메서드를 사용했다.

public interface ReviewRepository extends JpaRepository<Review, UUID> {
      boolean existsByOrder_IdAndIsDeletedFalse(@NotNull UUID id);

      @EntityGraph(attributePaths = {"user", "order", "images"})
      Optional<Review> findByIdAndIsDeletedFalse(@Param("reviewId") UUID reviewId);
}
public interface StoreRatingStatRepository extends JpaRepository<StoreRatingStat, UUID> {
    Optional<StoreRatingStat> findByStoreId(UUID storeId);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from StoreRatingStat s where s.storeId = :storeId")
    Optional<StoreRatingStat> findByStoreIdWithLock(@Param("storeId") UUID id);
}

테스트



profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글