ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,44 @@
+package nextstep.security.authorization;
+
+import nextstep.security.authentication.Authentication;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collection;
+import java.util.Set;
+
+public class AuthoritiesAuthorizationManager implements AuthorizationManager<Collection<String>> {
+    private final RoleHierarchy roleHierarchy;
+
+    public AuthoritiesAuthorizationManager(RoleHierarchy roleHierarchy) {
+        this.roleHierarchy = roleHierarchy;
+    }
+
+    @Override
+    public AuthorizationDecision check(Authentication authentication, Collection<String> authorities) {
+        if (authentication == null || !authentication.isAuthenticated()) {
+            return AuthorizationDecision.deny();
+        }
+
+        if (isHasAccess(authentication, authorities)) {
+            return AuthorizationDecision.granted();
+        }
+
+        return AuthorizationDecision.deny();
+    }
+
+    private boolean isHasAccess(Authentication authentication, Collection<String> authorities) {
+        Set<String> authenticationAuthorities = authentication.getAuthorities();
+
+        if (isMatchAuthority(authorities, authenticationAuthorities)) {
+            return true;
+        }
+
+        Collection<String> reachableGrantedAuthorities = roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities());
+
+        return isMatchAuthority(reachableGrantedAuthorities, authenticationAuthorities);
+    }
+
+    private static boolean isMatchAuthority(Collection<String> authorities, Set<String> authenticationAuthorities) {
+        return CollectionUtils.containsAny(authorities, authenticationAuthorities);
+    }
+}

리뷰: AuthoritiesAuthorizationManager 분리를 통한 책임과 역할 분리 👍
@@ -2,33 +2,43 @@
 
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import nextstep.security.authentication.Authentication;
 import nextstep.security.context.SecurityContextHolder;
-import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.filter.GenericFilterBean;
 
 import java.io.IOException;
 
-public class CheckAuthenticationFilter extends OncePerRequestFilter {
+public class CheckAuthenticationFilter extends GenericFilterBean {
 
-    private final RequestMatcherDelegatingAuthorizationManager requestMatcherDelegatingAuthorizationManager;
+    private final AuthorizationManager<HttpServletRequest> authorizationManager;
+    private final AuthorizationDeniedHandler authorizationDeniedHandler;
 
-    public CheckAuthenticationFilter(RequestMatcherDelegatingAuthorizationManager requestMatcherDelegatingAuthorizationManager) {
-        this.requestMatcherDelegatingAuthorizationManager = requestMatcherDelegatingAuthorizationManager;
+    public CheckAuthenticationFilter(AuthorizationManager<HttpServletRequest> authorizationManager) {
+        this(authorizationManager, response -> response.setStatus(HttpServletResponse.SC_FORBIDDEN));
+    }
+
+    public CheckAuthenticationFilter(AuthorizationManager<HttpServletRequest> authorizationManager
+            , AuthorizationDeniedHandler authorizationDeniedHandler
+    ) {
+        this.authorizationManager = authorizationManager;
+        this.authorizationDeniedHandler = authorizationDeniedHandler;
     }
 
     @Override
-    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 
-        AuthorizationDecision authorizationDecision = requestMatcherDelegatingAuthorizationManager.check(authentication, request);
+        AuthorizationDecision authorizationDecision = authorizationManager.check(authentication, (HttpServletRequest) request);
 
         if (authorizationDecision.isDeny()) {
-            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+            authorizationDeniedHandler.handle((HttpServletResponse) response);
             return;
         }
 
-        filterChain.doFilter(request, response);
+        chain.doFilter(request, response);
     }
 }

리뷰: 구현해주신 코드를 보니 ExceptionTranslationFilter에서 처리하는 것 처럼 AuthorizationDeniedHandler를 통해 에러를 처리하도록 작업해주셨군요 👍 

사실 코멘트 드리려 했던 부분은 401이 발생할 수도 있어서  403 외 다른 응답 코드가 응답될 수 있도록 처리해보는 것을 생각해보시길 의미하는 코멘트엿습니다 :)
이렇게 할 경우 AuthorizationDeniedHandler 외 다른 handler가 필요할 수 있고, 특히 Authentication 관련 예외를 처리하는 handler는 다른 인증 필터에서도 사용될 수 있겠네요!  

요구사항에는 없던 내용이지만 참고해보시길 바라는 마음에서 남긴 코멘트였어요 :)
@@ -0,0 +1,73 @@
+package com.eureka.spartaonetoone.review.application;
+
+import com.eureka.spartaonetoone.review.application.dtos.request.ReviewRequestDto;
+import com.eureka.spartaonetoone.review.application.dtos.response.ReviewResponseDto;
+import com.eureka.spartaonetoone.review.application.exception.ReviewException;
+import com.eureka.spartaonetoone.review.domain.Review;
+import com.eureka.spartaonetoone.review.domain.repository.ReviewRepository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import jakarta.validation.Valid;
+
+@Service
+@Validated
+public class ReviewService {
+
+	private final ReviewRepository reviewRepository;
+
+	public ReviewService(ReviewRepository reviewRepository) {
+		this.reviewRepository = reviewRepository;
+	}
+
+	// 리뷰 등록 - DTO를 엔티티로 변환한 후 저장하고, 저장된 엔티티를 DTO로 변환하여 반환
+	public ReviewResponseDto createReview(@Valid ReviewRequestDto dto) {
+		Review review = dto.createReview();
+		Review savedReview = reviewRepository.save(review);
+		return ReviewResponseDto.from(savedReview);
+	}
+
+	// 특정 리뷰 조회 - 주어진 reviewId로 Review 엔티티를 조회하며, 존재하지 않으면 CustomException을 발생
+	public ReviewResponseDto getReviewById(UUID reviewId) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		return ReviewResponseDto.from(review);
+	}
+
+	// 특정 주문(orderId)에 속한 리뷰 목록 조회
+	// ReviewRepository의 findByOrderId()를 사용하여 해당 주문의 모든 리뷰를 조회
+	// 리스트를 Pageable로 감싸서 반환
+	public Page<ReviewResponseDto> getReviewsByOrderId(UUID orderId, Pageable pageable) {
+		List<Review> reviews = reviewRepository.findByOrderId(orderId);
+		List<ReviewResponseDto> dtoList = reviews.stream()
+			.map(ReviewResponseDto::from)
+			.collect(Collectors.toList());
+		return new PageImpl<>(dtoList, pageable, dtoList.size());
+	}
+
+	// 리뷰 수정 - 특정 reviewId에 해당하는 Review 엔티티를 조회한 후, DTO의 값으로 업데이트하고 저장
+	public ReviewResponseDto updateReview(UUID reviewId, @Valid ReviewRequestDto dto) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		review.update(dto.getContent(), dto.getRating(), dto.getImage());
+		Review updatedReview = reviewRepository.save(review);
+		return ReviewResponseDto.from(updatedReview);
+	}
+
+
+	// 리뷰 삭제 - 특정 reviewId로 Review 엔티티를 조회한 후, markDeleted()를 호출
+	public void deleteReview(UUID reviewId) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		review.markDeleted();
+		reviewRepository.save(review);
+	}
+}
\ No newline at end of file

리뷰: 아래 코드로 변경 부탁드릴게요 ~~ :) 
return ReviewResponseDto.from(review);
@@ -0,0 +1,73 @@
+package com.eureka.spartaonetoone.review.application;
+
+import com.eureka.spartaonetoone.review.application.dtos.request.ReviewRequestDto;
+import com.eureka.spartaonetoone.review.application.dtos.response.ReviewResponseDto;
+import com.eureka.spartaonetoone.review.application.exception.ReviewException;
+import com.eureka.spartaonetoone.review.domain.Review;
+import com.eureka.spartaonetoone.review.domain.repository.ReviewRepository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import jakarta.validation.Valid;
+
+@Service
+@Validated
+public class ReviewService {
+
+	private final ReviewRepository reviewRepository;
+
+	public ReviewService(ReviewRepository reviewRepository) {
+		this.reviewRepository = reviewRepository;
+	}
+
+	// 리뷰 등록 - DTO를 엔티티로 변환한 후 저장하고, 저장된 엔티티를 DTO로 변환하여 반환
+	public ReviewResponseDto createReview(@Valid ReviewRequestDto dto) {
+		Review review = dto.createReview();
+		Review savedReview = reviewRepository.save(review);
+		return ReviewResponseDto.from(savedReview);
+	}
+
+	// 특정 리뷰 조회 - 주어진 reviewId로 Review 엔티티를 조회하며, 존재하지 않으면 CustomException을 발생
+	public ReviewResponseDto getReviewById(UUID reviewId) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		return ReviewResponseDto.from(review);
+	}
+
+	// 특정 주문(orderId)에 속한 리뷰 목록 조회
+	// ReviewRepository의 findByOrderId()를 사용하여 해당 주문의 모든 리뷰를 조회
+	// 리스트를 Pageable로 감싸서 반환
+	public Page<ReviewResponseDto> getReviewsByOrderId(UUID orderId, Pageable pageable) {
+		List<Review> reviews = reviewRepository.findByOrderId(orderId);
+		List<ReviewResponseDto> dtoList = reviews.stream()
+			.map(ReviewResponseDto::from)
+			.collect(Collectors.toList());
+		return new PageImpl<>(dtoList, pageable, dtoList.size());
+	}
+
+	// 리뷰 수정 - 특정 reviewId에 해당하는 Review 엔티티를 조회한 후, DTO의 값으로 업데이트하고 저장
+	public ReviewResponseDto updateReview(UUID reviewId, @Valid ReviewRequestDto dto) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		review.update(dto.getContent(), dto.getRating(), dto.getImage());
+		Review updatedReview = reviewRepository.save(review);
+		return ReviewResponseDto.from(updatedReview);
+	}
+
+
+	// 리뷰 삭제 - 특정 reviewId로 Review 엔티티를 조회한 후, markDeleted()를 호출
+	public void deleteReview(UUID reviewId) {
+		Review review = reviewRepository.findById(reviewId)
+			.orElseThrow(ReviewException.ReviewNotFoundException::new);
+		review.markDeleted();
+		reviewRepository.save(review);
+	}
+}
\ No newline at end of file

리뷰: 요 메서드는 제거해주셔도 될 것 같습니다 민수님 ~~!
@@ -1 +1,23 @@
 # spring-security
+## 🚀 1-1단계 - OAuth 2.0 Login
+
+- [x] Github Application 등록
+- [x] 인증 URL 리다이렉트 필터 구현
+- [x] Github Access Token 획득
+- [x] OAuth2 사용자 정보 조회
+- [x] 후처리
+  - [x] 이후 프로필 정보를 가지고 회원 가입 & 로그인을 구현한다.
+  - [x] 기존 멤버 정보가 있는 경우 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트
+  - [x] 새로운 멤버인 경우 회원 가입 후 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트
+
+## 🚀 1-2단계 - 리팩터링 & OAuth 2.0 Resource 연동
+- [x] Google 계정을 사용한 인증, 인가
+- [x] 리팩터링 Google 인증을 추가하면서 발생한 중복된 코드를 제거하고, 코드 구조를 개선한다.
+- [x] Github과 Google 인증 로직 간의 중복된 코드를 제거하고, 프레임워크의 인증 로직과 애플리케이션의 비즈니스 로직을 분리한다.
+- [x] Github과 Google의 정보를 properties(혹은 yaml)파일로 분리한다.
+
+## 🚀 2-1단계 - 리다이렉트 필터
+- [x] OAuth2AuthorizationRequestRedirectFilter 구현
+
+## 🚀 2-2단계 - OAuth 인증 필터
+- [x] OAuth2LoginAuthenticationFilter 구현

리뷰: 요구사항 정리 👍
@@ -0,0 +1,50 @@
+package nextstep.security.oauth2.authentication;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import nextstep.security.authentication.AuthenticationException;
+import nextstep.security.oauth2.exchange.AuthorizationRequestRepository;
+import nextstep.security.oauth2.exchange.OAuth2AuthorizationRequest;
+import nextstep.security.oauth2.exchange.OAuth2AuthorizationRequestResolver;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
+
+    private final OAuth2AuthorizationRequestResolver authorizationRequestResolver;
+    private final AuthorizationRequestRepository authorizationRequestRepository;
+
+    public OAuth2AuthorizationRequestRedirectFilter(OAuth2AuthorizationRequestResolver authorizationRequestResolver,
+                                                    AuthorizationRequestRepository authorizationRequestRepository) {
+
+        this.authorizationRequestResolver = authorizationRequestResolver;
+        this.authorizationRequestRepository = authorizationRequestRepository;
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+
+        try {
+            OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
+            if (authorizationRequest != null) {
+                this.sendRedirectForAuthorization(request, response, authorizationRequest);
+                return;
+            }
+        } catch (Exception e) {
+            throw new AuthenticationException("exception ... %s".formatted(e.getMessage()));
+        }
+
+        filterChain.doFilter(request, response);
+    }
+
+    private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
+                                              OAuth2AuthorizationRequest authorizationRequest) throws IOException {
+
+        this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
+        response.sendRedirect(authorizationRequest.getAuthorizationRequestUri());
+    }
+}

리뷰: OAuth2AuthorizationRequestResolver에서 OAuth2AuthorizationRequest를 생성할 때, authorizationRequestUri에 필수적인 쿼리 파라미터(client_id, response_type, scope, redirect_uri 등)를 미리 포함할 수도 있을 것 같은데, 어떻게 생각하시나요? 😄 

```suggestion
        response.sendRedirect(authorizationRequest.getAuthorizationRequestUri());
```
@@ -0,0 +1,50 @@
+package nextstep.security.oauth2.authentication;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import nextstep.security.authentication.AuthenticationException;
+import nextstep.security.oauth2.exchange.AuthorizationRequestRepository;
+import nextstep.security.oauth2.exchange.OAuth2AuthorizationRequest;
+import nextstep.security.oauth2.exchange.OAuth2AuthorizationRequestResolver;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
+
+    private final OAuth2AuthorizationRequestResolver authorizationRequestResolver;
+    private final AuthorizationRequestRepository authorizationRequestRepository;
+
+    public OAuth2AuthorizationRequestRedirectFilter(OAuth2AuthorizationRequestResolver authorizationRequestResolver,
+                                                    AuthorizationRequestRepository authorizationRequestRepository) {
+
+        this.authorizationRequestResolver = authorizationRequestResolver;
+        this.authorizationRequestRepository = authorizationRequestRepository;
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+
+        try {
+            OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
+            if (authorizationRequest != null) {
+                this.sendRedirectForAuthorization(request, response, authorizationRequest);
+                return;
+            }
+        } catch (Exception e) {
+            throw new AuthenticationException("exception ... %s".formatted(e.getMessage()));
+        }
+
+        filterChain.doFilter(request, response);
+    }
+
+    private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
+                                              OAuth2AuthorizationRequest authorizationRequest) throws IOException {
+
+        this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
+        response.sendRedirect(authorizationRequest.getAuthorizationRequestUri());
+    }
+}

리뷰: 이부분은 Redirect Filter가 모든 Redirect Url 스펙을 알게 되는 것 같아서, 생성 책임을 Resolver에게 맡기는 것이 좋아보이네요 !! 👍
@@ -1,5 +1,7 @@
 import { ReviewGroupData } from '@/types';
 
+import { MOCK_USER_PROFILE } from './userProfileData';
+
 export const VALIDATED_PASSWORD = '1234';
 
 export const MOCK_AUTH_TOKEN_NAME = 'mockAuthToken';
@@ -14,12 +16,18 @@ export const bothCookie = [MOCK_AUTH_TOKEN_NAME, MOCK_LOGIN_TOKEN_NAME];
  */
 
 // 변수명이 카멜 케이스인 이유는 회원/비회원에 따라 revieweeId가 달라지기 때문에 reviewRequestCode보고 판단해서 회원이면 number, 비회원이면 null로 설정
-export const REVIEW_GROUP_DATA: ReviewGroupData = {
-  revieweeId: 1,
+export const MEMBER_REVIEW_GROUP_DATA: ReviewGroupData = {
+  revieweeId: MOCK_USER_PROFILE.memberId, //💡 본인이 만든 리뷰 링크가 아닐 때 토스트 기능을 확인하고 싶다면 다른 리뷰 아이디를 넣어서 확인해보세요
   revieweeName: '바다',
   projectName: '2024-review-me',
 };
 
+export const NONMEMBER_REVIEW_GROUP_DATA: ReviewGroupData = {
+  revieweeId: null,
+  revieweeName: '손님',
+  projectName: '2024-review-me',
+};
+
 /**리뷰 연결 페이지에서 유효한 reviewRequestCode */
 export const VALID_REVIEW_REQUEST_CODE = {
   nonMember: `ABCD1234`,

리뷰: revieweeId가 수정된 이유가 궁금합니다! 의미 있는 값이라면 상수화해야 할 것 같아서요.
@@ -1,5 +1,7 @@
 import { ReviewGroupData } from '@/types';
 
+import { MOCK_USER_PROFILE } from './userProfileData';
+
 export const VALIDATED_PASSWORD = '1234';
 
 export const MOCK_AUTH_TOKEN_NAME = 'mockAuthToken';
@@ -14,12 +16,18 @@ export const bothCookie = [MOCK_AUTH_TOKEN_NAME, MOCK_LOGIN_TOKEN_NAME];
  */
 
 // 변수명이 카멜 케이스인 이유는 회원/비회원에 따라 revieweeId가 달라지기 때문에 reviewRequestCode보고 판단해서 회원이면 number, 비회원이면 null로 설정
-export const REVIEW_GROUP_DATA: ReviewGroupData = {
-  revieweeId: 1,
+export const MEMBER_REVIEW_GROUP_DATA: ReviewGroupData = {
+  revieweeId: MOCK_USER_PROFILE.memberId, //💡 본인이 만든 리뷰 링크가 아닐 때 토스트 기능을 확인하고 싶다면 다른 리뷰 아이디를 넣어서 확인해보세요
   revieweeName: '바다',
   projectName: '2024-review-me',
 };
 
+export const NONMEMBER_REVIEW_GROUP_DATA: ReviewGroupData = {
+  revieweeId: null,
+  revieweeName: '손님',
+  projectName: '2024-review-me',
+};
+
 /**리뷰 연결 페이지에서 유효한 reviewRequestCode */
 export const VALID_REVIEW_REQUEST_CODE = {
   nonMember: `ABCD1234`,

리뷰: 의미 있는 값은 아닙니다😅
리뷰 확인 시, 본인이 만든 링크인지 아닌지 판단하는 과정에서 값을 바꾸면서 비교하다가 그냥 커밋됐네요.
바다가 상수화 작업해주었습니다🙏🏻
@@ -1,5 +1,7 @@
 package com.chone.server.domains.review.domain;
 
+import static org.springframework.util.StringUtils.hasText;
+
 import com.chone.server.commons.jpa.BaseEntity;
 import com.chone.server.domains.order.domain.Order;
 import com.chone.server.domains.store.domain.Store;
@@ -80,4 +82,12 @@ public static ReviewBuilder builder(
         .rating(rating)
         .isPublic(isPublic);
   }
+
+  public void update(String content, BigDecimal rating, String imageUrl, Boolean isPublic) {
+
+    if (hasText(content)) this.content = content;
+    if (rating != null) this.rating = rating;
+    if (hasText(imageUrl)) this.imageUrl = imageUrl;
+    if (isPublic != null) this.isPublic = isPublic;
+  }
 }

리뷰: 해당 update 메서드의 경우에 not null이나 not blank 유효성 검증을 안 하고 있습니다.
따로 이유가 있을까요?
만일 이럴 경우 request에서 null이 있는 값이 있다면 엔티티의 필드 값이 null로 변경되고, 이것이 변경감지가 되어서
데이터베이스에 데이터가 null 값으로 업데이트될 가능성이 있습니다!(예를 들어, `update(null, null, null, null)`을 호출하면 해당 필드가 null로 덮어씌워집니다)

```java
import static org.springframework.util.StringUtils.hasText;

public void update(String content, BigDecimal rating, String imageUrl, Boolean isPublic) {
    if (hasText(content)) this.content = content;
    if (rating != null) this.rating = rating;
    if (hasText(imageUrl)) this.imageUrl = imageUrl;
    if (isPublic != null) this.isPublic = isPublic
```
이런식으로 수정해보면 어떨까요? 참고로 String의 white space까지 검사하는 것이 hasText 유틸 메서드입니다.
@@ -8,9 +8,11 @@
 import com.chone.server.domains.review.domain.Review;
 import com.chone.server.domains.review.dto.request.CreateRequestDto;
 import com.chone.server.domains.review.dto.request.ReviewListRequestDto;
+import com.chone.server.domains.review.dto.request.UpdateRequestDto;
 import com.chone.server.domains.review.dto.response.ReviewDetailResponseDto;
 import com.chone.server.domains.review.dto.response.ReviewListResponseDto;
 import com.chone.server.domains.review.dto.response.ReviewResponseDto;
+import com.chone.server.domains.review.dto.response.ReviewUpdateResponseDto;
 import com.chone.server.domains.review.exception.ReviewExceptionCode;
 import com.chone.server.domains.review.repository.ReviewRepository;
 import com.chone.server.domains.review.repository.ReviewSearchRepository;
@@ -19,6 +21,8 @@
 import com.chone.server.domains.user.domain.User;
 import jakarta.transaction.Transactional;
 import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import java.util.UUID;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -106,7 +110,33 @@ public ReviewDetailResponseDto getReviewById(UUID reviewId, CustomUserDetails pr
 
   private void validateAccess(User user, Review review) {
     if (!review.getIsPublic() && !review.getUser().getId().equals(user.getId())) {
-      throw new ApiBusinessException(ReviewExceptionCode.REVIEW_ACCESS_DENIED);
+      throw new ApiBusinessException(ReviewExceptionCode.REVIEW_FORBIDDEN_ACTION);
     }
   }
+
+  @Transactional
+  public ReviewUpdateResponseDto updateReview(
+      UUID reviewId, UpdateRequestDto request, CustomUserDetails principal) {
+    if (principal == null || principal.getUser() == null) {
+      throw new ApiBusinessException(ReviewExceptionCode.REVIEW_UNAUTHORIZED);
+    }
+
+    Review review =
+        reviewRepository
+            .findById(reviewId)
+            .orElseThrow(() -> new ApiBusinessException(ReviewExceptionCode.REVIEW_NOT_FOUND));
+
+    if (!review.getUser().getId().equals(principal.getUser().getId())) {
+      throw new ApiBusinessException(ReviewExceptionCode.REVIEW_FORBIDDEN_ACTION);
+    }
+
+    if (ChronoUnit.HOURS.between(review.getCreatedAt(), LocalDateTime.now()) > 72) {
+      throw new ApiBusinessException(ReviewExceptionCode.REVIEW_UPDATE_EXPIRED);
+    }
+
+    review.update(
+        request.getContent(), request.getRating(), request.getImageUrl(), request.getIsPublic());
+
+    return new ReviewUpdateResponseDto(review.getId(), review.getUpdatedAt());
+  }
 }

리뷰: 저도 이번에 주문 삭제 기능을 구현하면서 갑자기 깨달은 건데, 
저희가 soft delete를 하고 있어서 findById를 할 때 조건으로 deletedAt null 조건을 걸어야 삭제된 것을 제외할 수 있게 됩니다. 
그래서 조회 시 해당 조건을 포함하여 수정하는 것이 좋을 것 같아요! 아니면 브랜치를 새로 파서 해당 관련 조회들을 수정해보면 좋을 것 같습니다!
@@ -1,5 +1,7 @@
 package com.chone.server.domains.review.domain;
 
+import static org.springframework.util.StringUtils.hasText;
+
 import com.chone.server.commons.jpa.BaseEntity;
 import com.chone.server.domains.order.domain.Order;
 import com.chone.server.domains.store.domain.Store;
@@ -80,4 +82,12 @@ public static ReviewBuilder builder(
         .rating(rating)
         .isPublic(isPublic);
   }
+
+  public void update(String content, BigDecimal rating, String imageUrl, Boolean isPublic) {
+
+    if (hasText(content)) this.content = content;
+    if (rating != null) this.rating = rating;
+    if (hasText(imageUrl)) this.imageUrl = imageUrl;
+    if (isPublic != null) this.isPublic = isPublic;
+  }
 }

리뷰: 서비스 레이어에서 유효성 검사를 수행하고 있기 때문에 이렇게 되면 해당 필드에서 null 로 DB 반영된다는 걸 놓쳤습니다! 알려주신 방법으로 수정하도록 하겠습니다 :)