
이전에 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 리뷰 요약
확장 가능성이 정말 무궁무진하다.😂
나는 RestAPI의 개발 경험이 적어서,
수정을 구현하는데 HTTP 메서드를 PUT으로 할 지, PETCH로 할 지 고민되었다.
처음에 나는 각 정보를 바꾸기 위해 여러번 요청을 받는 것 보단 프론트에서 form 형식으로 리뷰 내용 작성을 완료한 뒤
create와 같이 requestDTO형태로 한 번에 모든 정보를 담아 요청하는 것이 좋을 것 같아 PUT메서드를 사용하는 것이 좋다고 생각했다.
PUT은 보통 idempotent full update(*멱등성 전체 업데이트)에 사용된다.
예를 들어 리뷰 수정 중 사용자가 별점이나 이미지 수정 없이 코멘트만을 수정할 때, 앞서 생각한 것 처럼 리뷰에 필요한 정보를 모두 보내지 않고
{
"comment": "그냥 보통이에요"
}
만을 보내면 서버는 이를 전체 교체(PUT)로 이해하고
{
"rating": null,
"user" : null,
"comment": "그냥 보통이에요",
"images": null,
...
}
그리고 리뷰 수정이 항상 create와 같은 전체 폼 형태로 이뤄지지 않을 수도 있다.
즉, 프론트가 항상 리뷰의 전체 상태를 갖고 있지 않을 수 있다.
게다가 부분 수정의 경우 필요한 정보만 보내도 수정 정보는 충분한데,
PUT으로 구현한다면 매 수정 요청마다 무조건 모든 전체 필드를 채워서 보내야 해 비효율적이다.
PATCH는 주로 삭제할 대상만 명시하는 명령형 업데이트 방식으로 사용된다.
원본 :
[img1, img2, img3]
상태에서 사용자가 img2 이미지를 삭제하려는 경우,
클라이언트는 전체 목록을 다시 보내는 대신 다음과 같이 삭제할 대상만을 명시한다.
사용자가 img2 삭제:
{
"deleteImageIds": ["img2"]
}
서버 동작:
현재 이미지 목록에서 img2만 제거된다.
결과:
[img1, img3]
결론을 요약해서 말하자면, PUT과 PATCH는 분식집의 벽에 붙어있는 메뉴판의 있는 가격을 수정할 때와 같다.
PUT 으로 수정 하는 것은, 메뉴판 전체를 갖다 버리고 새로 만들어 다는 것이고,
PATCH 로 수정하는 것은, 메뉴판에서 바뀐 메뉴의 가격만 스티커로 덧붙이는 것이다.
그래서 리뷰 수정 구현을 위한 HTTP 메서드는 PATCH를 사용하기로 했다.
이미지 업로드가 우리가 계획한 S3 업로드 처럼 별도 프로세스로 나눠져 있을 때,
이미지 수가 많고 관리가 복잡하거나, 노션처럼 실시간으로 이미지를 조작해야하는 경우 엔드포인트를 분리하는 경우도 있다고 한다.
하지만 배달 앱 리뷰의 경우 비교적 이미지 수가 적고, 수정도 빈번하게 일어나지 않아 단일 API로 처리하기로 했다.
이미지 수정의 경우 요청 DTO에 바뀐 정보만을 포함시키기 때문에 @NotNull 검증을 사용하지 않는다.
public record ReviewUpdateRequest(
@Min(1)
@Max(5)
Double rating,
String comment,
List<ReviewImage> updatedImages,
List<String> deleteImages
) {}
그런데 이제 이미지는 create 때 처럼 MultipartFile로 처리해야겠다 하다가 문득 드는 생각.
아이고.. 역시 생각엔 끝이 없다...😅
그치만 처음부터 완벽한 서비스가 어딨을까! 일단 프론트서버 없이 정적 소스만 준다 생각하고 그대로 Multipart로 구현해보기로! (벌써 테스트 할 생각에 아득해지지만...🥲)
@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);
}
@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);
}
기존 정보:

수정 실행:

createdAt 업데이트가 되지 않았다...!?
JPA의 @LastModifiedDate는 자바 객체의 필드를 바꾼 즉시 세팅되는 것이 아니라, DB에 UPDATE 쿼리가 나가는 시점(Flush)에 AuditingEntityListener 가 동작하며 값을 채워넣기 때문이었다.
현재 상황:
1. service.updateReview() 내부에서 review.updateReview(...) 호출 (필드 값 변경).
2. 아직 DB에 반영(Flush)되기 전에 ReviewResponse DTO를 생성.
이때 review.getUpdatedAt()은 아직 수정 전의 값을 가지고 있음.
3. 메서드가 끝나고 트랜잭션이 커밋될 때 실제 DB에는 수정된 시간으로 저장됨.
4. 결과적으로 DB는 바뀌었지만, 지금 받은 응답창(JSON)에는 이전 시간이 찍히게 됨.
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




그러나 Review가 직접 가지고있는 ReviewImage만을 조작하면
처음엔 Review가 ReviewImage를 포함하는 양방향 관계이기 때문에 JPA가 자동으로 Audit를 해줄 줄 알았다.
하지만, 양방향 관계를 가진 엔티티라도 독립적이라 함께 dirty check되지 않는다.
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()) {
...


드디어 원하는대로 동작한다!!🥵
{
"updateImages" :
[
{"imageUrl" : "업로드 이후 생성된 S3 url1", "sequence": "1"},
{"imageUrl" : "업로드 이후 생성된 S3 url2", "sequence": "0"}
]
}
+Json을 쓸 때: 쉼표 주의! 따옴표 주의!



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


리뷰의 순서를 바꾸는데 성공했지만, 이미지 삭제 로직이 발제문에서 제시된 모든 데이터에 대한 soft 삭제 구현이 안 되어 있었다는걸 알게 되었다.
(이제 그만 퇴근하고 싶어요...)
// 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("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️⃣ 추가 로직은 동일 ...
}


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



조회 할 땐 @SQLRestriction("is_deleted = false") 때문에 삭제되어 보인다.
{
"rating": 5,
"comment": "식으니까 더 맛있네요",
"deleteImages": [
"업로드 이후 생성된 S3 url3"
]
}

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


삭제 후 삽입하더라도 삭제된 이미지를 제외하고 필요한 4개를 정보가 들어감을 확인 할 수 있었다.
@DeleteMapping("/reviews/{reviewId}")
public ResponseEntity<Void> deleteReview(@PathVariable UUID reviewId, @AuthenticationPrincipal User user) {
reviewService.deleteReview(reviewId, user);
return ResponseEntity.ok().build();
}
@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);
}
기존에 작성된 메서드를 사용했다.
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);
}


