diff --git a/Seohui/keyword_summary/ch07.md b/Seohui/keyword_summary/ch07.md
new file mode 100644
index 00000000..3f576205
--- /dev/null
+++ b/Seohui/keyword_summary/ch07.md
@@ -0,0 +1,246 @@
+# Page와 Slice
+
+> **목적 / 용도**
+> - **Page:** 게시판처럼 정확한 페이지 번호가 필요한 경우
+> - **Slice:** 무한 스크롤이나 더 보기 버튼이 필요한 경우
+
+| 구분 | Page | Slice |
+| --- | --- | --- |
+| **특징** | 전체 데이터 건수, 전체 페이지 수를 알 수 있음 | 전체 건수는 모르고 다음 페이지가 있는지의 여부만 알 수 있음 |
+| **동작 방식** | 데이터 조회 쿼리 + 전체 개수를 세는 `COUNT` 쿼리가 같이 실행됨 | • `COUNT` 쿼리 실행 안 함
• 요청한 개수보다 **1개 더 (Limit + 1)** 가져와서 다음 페이지 여부 확인 |
+| **성능** | 데이터가 많아질수록 `COUNT` 쿼리 때문에 성능이 저하될 수 있음 | `COUNT` 쿼리가 없어서 대용량 데이터 조회 시 성능이 훨씬 빠름 |
+| **상속 구조** | `Slice` 인터페이스를 상속받음 (Slice의 모든 기능 + COUNT 기능) | 부모 인터페이스 |
+| **UI** | [1] [2] [3] [4] [5][다음] | 스크롤을 맨 아래로 내리면 자동 로딩 |
+
+**e.g.**
+```java
+@Repository
+public interface ReviewRepository extends JpaRepository {
+
+ // Page 타입 반환
+ Page findPageBy(Pageable pageable);
+
+ // Slice 타입 반환
+ Slice findSliceBy(Pageable pageable);
+}
+
+@RestController
+// ...
+ /**
+ Page 방식
+ /reviews/page?page=0&size=5
+ **/
+ @GetMapping("/reviews/page")
+ public Page getReviewsByPage(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size
+ ) {
+ // id 기준 최신순 정렬 Pageable 객체 생성
+ Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
+
+ // 반환 시 전체 개수 정보가 응답됨
+ return reviewRepository.findPageBy(pageable);
+ }
+
+ /**
+ Slice 방식
+ /reviews/slice?page=0&size=5
+ **/
+ @GetMapping("/reviews/slice")
+ public Slice getReviewsBySlice(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size
+ ) {
+ Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
+
+ // 전체 개수는 안 나옴
+ // 데이터 내용, 다음 페이지 여부만 응답됨
+ return reviewRepository.findSliceBy(pageable);
+ }
+```
+
+# Java Stream API
+
+> **Stream API**
+> 자바 8에 추가된 기능으로 컬렉션(List, Set, Map)이나 배열에 저장된 데이터를 반복문 없이 깔끔하게 처리하는 기술.
+> 데이터의 흐름을 만듦 → 람다식과 함께 데이터를 필터링, 변환, 수집 작업을 연결해서 수행.
+
+### Stream API 동작 흐름
+1. **생성(Creation)** → 컬렉션이나 배열을 스트림 객체로 만듦 (`.stream()`)
+2. **중간 연산(Intermediate)** → 데이터를 가공함 (여러 번 연결 가능)
+3. **최종 연산(Terminal)** → 가공된 데이터를 결과로 만듦 (한 번만 사용 가능하며, 이때 실제 연산이 시작됨)
+
+---
+
+### 중간 연산
+
+* **특징:**
+ * 데이터를 가공하는 단계
+ * 연산 결과로 또 다른 `Stream`을 반환함 → 메서드 체이닝 가능
+
+| 메서드 | 설명 | 예시 / 비고 |
+| --- | --- | --- |
+| `filter(조건)` | 조건에 맞는 데이터만 걸러냄 | 짝수만 추출 |
+| `map(변환 규칙)` | 데이터를 다른 형태로 변환 | User 객체에서 이름만 추출 |
+| `sorted()` | 데이터 정렬 | 기본 오름차순, 사용자 지정 정렬 |
+| `distinct()` | 중복된 데이터 제거 | |
+| `limit(n)` | 데이터의 흐름에서 앞에서부터 n개의 데이터만 잘라냄 | |
+
+---
+
+### 최종 연산
+
+* **특징:**
+ * 가공된 데이터에서 최종 결과를 도출하는 마지막 단계
+ * 실제 값이나 컬렉션을 반환하며, 최종 연산이 호출되어야 비로소 전체 스트림 연산이 실행됨 (지연 연산)
+ * 한 번 실행되면 스트림이 닫힘
+
+| 메서드 | 설명 | 예시 / 비고 |
+| --- | --- | --- |
+| `collect()` | 가공된 스트림 데이터를 List, Set, Map 등으로 묶어서 반환 | |
+| `forEach()` | 스트림의 각 요소를 순회하며 출력하거나 작업을 수행함 | 반환값 없음 |
+| `count()` | 스트림에 남은 데이터의 **총 개수** 반환 | |
+| `reduce()` | 데이터를 하나씩 누적 계산 | 모든 숫자 합 계산 |
+| `anyMatch(조건)` | 조건을 만족하는 데이터가 **하나라도 존재하는지** 확인 | boolean 반환 |
+
+---
+
+### 주의 사항
+* **스트림은 일회용임** → 한 번 최종 연산(`collect`, `count` 등)을 수행해서 결과를 얻으면 스트림은 닫혀서 다시 쓸 수 없다! 필요 시 재생성해야 함.
+* **지연 연산(Lazy Evaluation)을 함** → 중간 연산(`filter`, `map`)을 많이 적어두어도 맨 끝에 최종 연산(`collect` 등)을 호출하지 않으면 계산이 아예 실행되지 않음.
+
+# 객체 그래프 탐색
+
+> **객체 그래프 탐색**
+> JPA나 ORM을 사용할 때 자주 등장하는 개념으로, 객체들 사이의 **연관 관계를 연쇄적으로 따라가며 조회하는 것** → 객체 안에 또 다른 객체가 있을 때 점(`.`)을 통해 타고 들어가면 된다.
+> 실제 DB 조회 시점과 관련이 있기 때문에 중요함.
+> 참조 변수를 통해 연관된 객체로 이동하는 행위를 말함.
+
+**e.g.**
+`comment.getUser().getName()`
+* `comment`: 댓글 객체
+* `comment.getUser()`: 댓글을 작성한 사용자 (User)
+* `comment.getUser().getName()`: 사용자의 이름 (Name)
+
+---
+
+### N+1 문제
+객체 그래프 탐색은 필요할 때 가져온다는 지연 로딩(Lazy Loading) 특성을 가지기에 **N+1 문제**가 발생될 우려가 있음 → `fetch join`으로 해결 (한 번에 가져와서 객체 그래프 탐색 시 추가 쿼리 X)
+
+* **현상:** 최초에 목록을 조회하는 쿼리를 한 번 날림
+* **문제:** 그 목록에 담긴 객체들을 하나씩 탐색할 때마다 연관된 데이터를 가져오기 위해 추가적인 쿼리가 N번 더 발생
+* **원인:** JPA 입장에서는 어디까지 탐색할지 미리 알 수 없음 → 처음에는 딱 요청한 것만 가져오고(프록시) 나중에 탐색을 시도할 때마다 쿼리를 날리기 때문에 발생하는 문제
+
+# @Valid vs @Validated
+
+### 차이점
+
+| 구분 | @Valid (자바 표준) | @Validated (스프링용) |
+| --- | --- | --- |
+| **소속** | Java/Jakarta EE 표준 규격(JSR-380) | Spring Framework |
+| **동작 위치** | 주로 Controller의 파라미터 변환 시 동작 | Controller, Service, Repository 등 스프링 빈 어디서나 동작 |
+| **동작 원리** | 스프링의 ArgumentResolver가 개입하여 검증 | 스프링의 AOP를 기반으로 메서드 호출을 가로채서 검증 |
+| **그룹 지정** | 불가능 (무조건 전체 검증) | 가능 (상황에 따라 원하는 조건만 묶어서 검증) |
+| **적용 대상** | 메서드 파라미터, 필드(객체 내부의 객체 검증 시) | 클래스 레벨, 메서드 파라미터 |
+| **발생 예외 (Exception)** | `MethodArgumentNotValidException` | AOP 동작 시 `ConstraintViolationException` 발생 |
+
+### 공통점
+객체에 설정된 제약 조건(`@NotNull`, `@Size` 등)을 확인하여 옳지 않은 데이터가 서버 내부로 들어오는 것을 막아줌
+
+---
+
+## @Validated
+
+> **@Validated**
+> Spring 프레임워크에서 전용으로 제공하는 유효성 검증 어노테이션.
+> 자바 표준인 `@Valid`의 모든 기능을 포함하면서, 추가적으로 **그룹화(Grouping)** 기능을 제공하여 특정 상황에 맞는 검증만 실행하라고 알려주는 신호.
+> `@Valid`가 주로 웹(Controller) 계층에서만 동작한다면, `@Validated`는 스프링 빈으로 등록된 클래스라면 어디서든 유효성 검증이 가능해짐.
+
+* **주 기능 : 그룹 유효성 검사 (Validation Groups)**
+ * ex) 회원 가입할 때와 회원 정보를 수정할 때 요구하는 데이터의 조건이 다를 경우 → 가입할 때는 비밀번호가 필수지만, 프로필 수정 시에는 비밀번호를 바꾸지 않을 수 있음
+
+### @Validated 동작 흐름
+1. 클라이언트 → Controller/Service 호출
+2. Spring AOP 동작 (메서드 가로채기)
+3. 지정된 그룹 또는 파라미터 검증
+ * **실패 :** `ConstraintViolationException` 또는 `MethodArgumentNotValidException` 발생
+ * **성공 :** 대상 메서드의 비즈니스 로직 정상 수행
+
+### 어노테이션 설명 (예시)
+* `@NotBlank(groups = CreateGroup.class)` : 가입(Create)할 때만 필수 값으로 검증
+* `@NotNull(groups = UpdateGroup.class)` : 수정(Update)할 때만 null이 아닌지 검증
+* `@Validated(CreateGroup.class)` : 해당 그룹(Create)의 규칙만 검사하도록 지시함
+
+### 클래스 레벨 검증 (Method Validation)
+클래스 위에 붙여 해당 클래스의 모든 메서드 파라미터에 대한 유효성 검사를 활성화할 수 있음
+
+### 자바 @Valid와 비교했을 때 주의할 점
+`@Validated`를 클래스 레벨에 적용하여 발생하는 예외(`ConstraintViolationException`)와 컨트롤러 DTO 검증 시 발생하는 예외(`MethodArgumentNotValidException`)가 다름
+→ 글로벌 예외 처리를 구현할 때 이 두 가지를 모두 처리해주어야 한다.
+
+---
+
+## @Valid
+
+> **@Valid**
+> Java Bean Validation의 기본 어노테이션.
+> DTO, Entity, Method Parameter 등에서 검증을 시작하라고 Spring/Hibernate Validator에게 알려주는 신호.
+> `@Valid`를 사용하면 객체 안에서 들어오는 값에 대해 검증이 가능해짐.
+
+### @Valid 동작 흐름
+1. 클라이언트 → Controller 호출
+2. Valid → Hibernate Validator 호출
+3. DTO 필드 검증
+ * **실패 :** `MethodArgumentNotValidException` 발생
+ * **성공 :** Controller 비즈니스 로직 수행
+
+### 검증 어노테이션 종류
+
+#### 문자열 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@NotBlank` | null X, 공백 제외 길이 > 0 |
+| `@NotEmpty` | null X, 빈 문자열("") X |
+| `@NotNull` | null X (타입 상관 없음) |
+| `@Null` | 반드시 null 값 |
+
+#### 최대/최솟값 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@DecimalMax` | 지정값 이하 (String 기반) |
+| `@DecimalMin` | 지정값 이상 (String 기반) |
+| `@Max` | 지정값 이하 (숫자 기반) |
+| `@Min` | 지정값 이상 (숫자 기반) |
+
+#### 범위 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Positive` | 양수만 허용 |
+| `@PositiveOrZero` | 0 이상 |
+| `@Negative` | 음수만 허용 |
+| `@NegativeOrZero` | 0 이하 |
+
+*참고: `DecimalMax/Min`은 BigDecimal, BigInteger, String 등 정밀 수치용 / `Max/Min`은 기본 숫자형(int, long 등) 용*
+
+#### 시간 값 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Future` | 현재 시간보다 미래 |
+| `@FutureOrPresent` | 현재 or 미래 |
+| `@Past` | 현재 시간보다 과거 |
+| `@PastOrPresent` | 현재 or 과거 |
+
+#### Boolean 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@AssertTrue` | true 값만 허용 |
+| `@AssertFalse` | false 값만 허용 |
+
+#### 크기 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Size` | 값의 길이가 min 이상 max 이하
→ `String`, `Collection`, `Map`, `Array` 에 적용 |
+
+### 자바 Valid 가 제공하지 않는 기능
+`@Valid`는 `javax.validation` 표준이지만, 그룹 유효성 검사를 지원하지 않음.
+→ 필요한 경우 `@Validated` 사용
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
index e56ec820..d2acaf42 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
@@ -1,6 +1,7 @@
package com.study.UMC10.domain.mission.controller;
import com.study.UMC10.domain.mission.code.MissionSuccessCode;
+import com.study.UMC10.domain.mission.dto.request.MissionRequestDto;
import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
import com.study.UMC10.domain.mission.service.MissionService;
import com.study.UMC10.global.apiPayload.ApiResponse;
@@ -10,12 +11,7 @@
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@@ -25,18 +21,21 @@ public class MissionController {
private final MissionService missionService;
- @Operation(summary = "미션 목록 조회 API", description = "상태값에 따른 미션 목록을 조회하는 API입니다.")
+ @Operation(summary = "미션 목록 조회 API", description = "나의 미션 목록을 조회합니다.")
@Parameters({
@Parameter(name = "MissionStatus", description = "조회할 미션 상태", example = "IN_PROGRESS, SUCCESS, FAILED"),
- @Parameter(name = "page", description = "페이지 번호", example = "0")
+ @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10"),
+ @Parameter(name = "pageNumber", description = "페이지 번호", example = "0")
})
@GetMapping("/v1/missions")
- public ApiResponse getMissions(
+ public ApiResponse> getMissions(
+ @RequestBody MissionRequestDto.GetMyMissionsDto requestDto,
@RequestParam("MissionStatus") String status,
- @RequestParam(name = "page", defaultValue = "0") Integer page
+ @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+ @RequestParam(name = "pageNumber", defaultValue = "0") Integer pageNumber
) {
BaseSuccessCode code = MissionSuccessCode.OK;
- return ApiResponse.onSuccess(code, missionService.getMissions(status, page));
+ return ApiResponse.onSuccess(code, missionService.getMissions(requestDto.userId(), status, pageSize, pageNumber));
}
@Operation(summary = "미션 완료 처리 API", description = "진행 중인 미션을 완료 상태로 변경하는 API입니다.")
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
index 8584fa2b..58fe9a35 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
@@ -1,4 +1,19 @@
package com.study.UMC10.domain.mission.converter;
+import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
+
+import java.util.List;
+
public class MissionConverter {
+ public static MissionResponseDto.Pagination toPagination(
+ List data,
+ Integer pageNumber,
+ Integer pageSize
+ ){
+ return MissionResponseDto.Pagination.builder()
+ .data(data)
+ .pageNumber(pageNumber)
+ .pageSize(pageSize)
+ .build();
+ }
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
index fc6eeee9..01f321a5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
@@ -1,4 +1,17 @@
package com.study.UMC10.domain.mission.dto.request;
+import io.swagger.v3.oas.annotations.media.Schema;
+
public class MissionRequestDto {
-}
+
+ /*
+ * NOTE:
+ * GET 요청에 Request Body를 사용하지 않지만 미션 요구사항에 따라 Request Body로 userId를 전달받음
+ */
+ @Schema(description = "진행 중인 미션 조회 요청")
+ public record GetMyMissionsDto(
+ @Schema(description = "조회할 유저 ID", example = "1")
+ Long userId
+ ) {
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
index dae8104f..77dce4dc 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
@@ -44,4 +44,11 @@ public record MissionCompleteResultDto(
String status
) {
}
+
+ @Builder
+ public record Pagination(
+ List data,
+ Integer pageNumber,
+ Integer pageSize
+ ) {}
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
index 09accced..ac89d900 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
@@ -1,5 +1,6 @@
package com.study.UMC10.domain.mission.service;
+import com.study.UMC10.domain.mission.converter.MissionConverter;
import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
import com.study.UMC10.domain.mission.entity.UserMission;
import com.study.UMC10.domain.mission.enums.MissionStatus;
@@ -20,16 +21,14 @@ public class MissionService {
private final UserMissionRepository userMissionRepository;
@Transactional(readOnly = true)
- public MissionResponseDto.MissionListDto getMissions(String status, Integer page) {
-
- // 임시 유저
- Long dummyUserId = 1L;
+ public MissionResponseDto.Pagination getMissions(
+ Long userId, String status, Integer pageSize, Integer pageNumber) {
MissionStatus missionStatus = MissionStatus.valueOf(status.toUpperCase());
- // 페이징
- PageRequest pageRequest = PageRequest.of(page, 10);
- Page userMissionPage = userMissionRepository.findMyMissions(dummyUserId, missionStatus, pageRequest);
+ PageRequest pageRequest = PageRequest.of(pageNumber, pageSize);
+
+ Page userMissionPage = userMissionRepository.findMyMissions(userId, missionStatus, pageRequest);
List missionDetailDtoList = userMissionPage.stream()
.map(userMission -> MissionResponseDto.MissionDetailDto.builder()
@@ -41,12 +40,17 @@ public MissionResponseDto.MissionListDto getMissions(String status, Integer page
.build())
.collect(Collectors.toList());
- return MissionResponseDto.MissionListDto.builder()
- .missions(missionDetailDtoList)
- .build();
+ return MissionConverter.toPagination(
+ missionDetailDtoList,
+ userMissionPage.getNumber(),
+ userMissionPage.getSize()
+ );
}
- // 아직 구현Xx
+ /*
+ * NOTE:
+ * 아직 구현 안함
+ */
public MissionResponseDto.MissionCompleteResultDto completeMission(Long missionId) {
return null;
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
index ea33545e..22f8abf2 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
@@ -8,12 +8,16 @@
import com.study.UMC10.global.apiPayload.code.BaseSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -29,10 +33,29 @@ public class ReviewController {
@PostMapping("/v1/stores/{storeId}/reviews")
public ApiResponse createReview(
@PathVariable("storeId") Long storeId,
- @RequestBody ReviewRequestDto.CreateReviewDto requestDto
+ @RequestBody @Valid ReviewRequestDto.CreateReviewDto requestDto
) {
BaseSuccessCode code = ReviewSuccessCode.REVIEW_CREATED;
return ApiResponse.onSuccess(code, reviewService.createReview(storeId, requestDto));
}
+
+ @Operation(summary = "내가 작성한 리뷰 목록 조회 API", description = "커서 기반 페이징으로 내가 작성한 리뷰를 최신순/별점순으로 조회합니다.")
+ @Parameters({
+ @Parameter(name = "userId", description = "조회할 유저 ID", example = "1"),
+ @Parameter(name = "query", description = "정렬 기준(최신순: id, 별점순: rate)", example = "id"),
+ @Parameter(name = "cursor", description = "다음 페이지 커서/ 최초 요청 시 -1 입력", example = "-1"),
+ @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10")
+ })
+ @GetMapping("/v1/reviews/me")
+ public ApiResponse> getMyReviews(
+ @RequestParam("userId") Long userId,
+ @RequestParam(name = "query", defaultValue = "id") String query,
+ @RequestParam(name = "cursor", defaultValue = "-1") String cursor,
+ @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize
+ ) {
+ BaseSuccessCode code = com.study.UMC10.global.apiPayload.code.GeneralSuccessCode.OK;
+
+ return ApiResponse.onSuccess(code, reviewService.getMyReviews(userId, query, cursor, pageSize));
+ }
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
index 9ff77d70..b7159e16 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
@@ -1,15 +1,24 @@
package com.study.UMC10.domain.review.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
public class ReviewRequestDto {
@Schema(description = "리뷰 생성")
public record CreateReviewDto(
+
+ @NotNull(message = "별점은 필수 입력 항목입니다.")
+ @DecimalMin(value = "0.0", message = "별점은 0.0 이상이어야 합니다.")
+ @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.")
@Schema(description = "가게 별점", example = "4.5")
Double rate,
- @Schema(description = "리뷰 내용", example = "음식이 맛있습니다!")
+ @NotBlank(message = "리뷰 내용은 비어있을 수 없습니다.")
+ @Schema(description = "리뷰 내용", example = "음식이 맛있어요!")
String content
) {
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
index 1e32ccfc..f2d064c5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
+import java.time.LocalDate;
import java.util.List;
public class ReviewResponseDto {
@@ -26,4 +27,45 @@ public record CreateReviewResultDto(
List images
) {
}
+
+ @Builder
+ @Schema(description = "내가 작성한 리뷰 상세 정보")
+ public record MyReviewDto(
+ @Schema(description = "리뷰 ID", example = "1")
+ Long reviewId,
+
+ @Schema(description = "가게 이름", example = "반이학생마라탕마라반")
+ String storeName,
+
+ @Schema(description = "유저 닉네임", example = "닉네임1234")
+ String nickname,
+
+ @Schema(description = "별점", example = "4.5")
+ Double score,
+
+ @Schema(description = "작성 날짜", example = "2022.05.14")
+ LocalDate createdAt,
+
+ @Schema(description = "사장님 답글 내용 (없으면 null)", example = "감사합니다.")
+ String ownerComment,
+
+ @Schema(description = "사장님 답글 작성 날짜 (없으면 null)", example = "2022.05.15")
+ LocalDate ownerCommentCreatedAt
+ ) {}
+
+ @Builder
+ @Schema(description = "커서 기반 페이징")
+ public record CursorPagination(
+ @Schema(description = "데이터 리스트")
+ List data,
+
+ @Schema(description = "다음 페이지 존재 여부", example = "true")
+ Boolean hasNext,
+
+ @Schema(description = "다음 페이지 조회를 위한 커서 값", example = "383:383")
+ String nextCursor,
+
+ @Schema(description = "요청한 데이터 수", example = "10")
+ Integer pageSize
+ ) {}
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
index ad412495..1b0e9ef5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
@@ -3,17 +3,7 @@
import com.study.UMC10.domain.store.entity.Store;
import com.study.UMC10.domain.user.entity.User;
import com.study.UMC10.global.apiPayload.code.BaseEntity;
-import jakarta.persistence.CascadeType;
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.FetchType;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.JoinColumn;
-import jakarta.persistence.ManyToOne;
-import jakarta.persistence.OneToMany;
-import jakarta.persistence.Table;
+import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -51,4 +41,7 @@ public class Review extends BaseEntity {
@OneToMany(mappedBy = "review", cascade = CascadeType.ALL)
private List reviewPhotoList = new ArrayList<>();
+
+ @OneToOne(mappedBy = "review", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
+ private OwnerComment ownerComment;
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
index 21d2d2f2..cb61b6d1 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
@@ -1,7 +1,30 @@
package com.study.UMC10.domain.review.repository;
import com.study.UMC10.domain.review.entity.Review;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface ReviewRepository extends JpaRepository {
+
+ // 리뷰 ID 기준 내림차순
+ // 커서 없는 최초 조회
+ @Query("SELECT r FROM Review r WHERE r.user.id = :userId ORDER BY r.id DESC")
+ Slice findMyReviewsOrderByIdDesc(@Param("userId") Long userId, Pageable pageable);
+
+ // 커서 조회
+ @Query("SELECT r FROM Review r WHERE r.user.id = :userId AND r.id < :cursorId ORDER BY r.id DESC")
+ Slice findMyReviewsByCursorId(@Param("userId") Long userId, @Param("cursorId") Long cursorId, Pageable pageable);
+
+ // 별점 순
+ // 최초 조회
+ @Query("SELECT r FROM Review r WHERE r.user.id = :userId ORDER BY r.score DESC, r.id DESC")
+ Slice findMyReviewsOrderByScoreDesc(@Param("userId") Long userId, Pageable pageable);
+
+ // 커서 조회
+ @Query("SELECT r FROM Review r WHERE r.user.id = :userId AND (r.score < :cursorScore OR (r.score = :cursorScore AND r.id < :cursorId)) ORDER BY r.score DESC, r.id DESC")
+ Slice findMyReviewsByCursorScoreAndId(@Param("userId") Long userId, @Param("cursorScore") Double cursorScore, @Param("cursorId") Long cursorId, Pageable pageable);
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
index 8b2859ab..625fc69d 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
@@ -9,10 +9,14 @@
import com.study.UMC10.domain.user.entity.User;
import com.study.UMC10.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -25,7 +29,11 @@ public class ReviewService {
@Transactional
public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, ReviewRequestDto.CreateReviewDto requestDto) {
- // 임시 유저 (로그인 구현 X)
+ /*
+ * NOTE:
+ * 임시 유저로 하드 코딩
+ * 추후 로그인 구현 후 수정 예정
+ */
Long dummyUserId = 1L;
User user = userRepository.findById(dummyUserId)
.orElseThrow(() -> new RuntimeException("해당 유저를 찾을 수 없습니다."));
@@ -51,4 +59,65 @@ public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, Review
.images(new ArrayList<>())
.build();
}
+
+ // 내 리뷰 목록 조회
+ @Transactional(readOnly = true)
+ public ReviewResponseDto.CursorPagination getMyReviews(
+ Long userId, String query, String cursor, Integer pageSize) {
+
+ PageRequest pageRequest = PageRequest.of(0, pageSize);
+ Slice reviewSlice;
+
+ if (query.equalsIgnoreCase("rate")) {
+ // 별점 순
+ if (cursor.equals("-1")) {
+ reviewSlice = reviewRepository.findMyReviewsOrderByScoreDesc(userId, pageRequest);
+ } else {
+ String[] cursorSplit = cursor.split(":");
+ Double scoreCursor = Double.parseDouble(cursorSplit[0]);
+ Long idCursor = Long.parseLong(cursorSplit[1]);
+ reviewSlice = reviewRepository.findMyReviewsByCursorScoreAndId(userId, scoreCursor, idCursor, pageRequest);
+ }
+ } else {
+ // 리뷰 ID 순
+ if (cursor.equals("-1")) {
+ reviewSlice = reviewRepository.findMyReviewsOrderByIdDesc(userId, pageRequest);
+ } else {
+ Long idCursor = Long.parseLong(cursor);
+ reviewSlice = reviewRepository.findMyReviewsByCursorId(userId, idCursor, pageRequest);
+ }
+ }
+
+ List dtoList = reviewSlice.getContent().stream()
+ .map(review -> ReviewResponseDto.MyReviewDto.builder()
+ .reviewId(review.getId())
+ .storeName(review.getStore().getStoreName())
+ .nickname(review.getUser().getNickname())
+ .score(review.getScore())
+ .createdAt(review.getCreatedAt() != null ? review.getCreatedAt().toLocalDate() : null)
+ .ownerComment(review.getOwnerComment() != null ? review.getOwnerComment().getOwnerCommentContent() : null)
+ .ownerCommentCreatedAt(review.getOwnerComment() != null && review.getOwnerComment().getCreatedAt() != null ? review.getOwnerComment().getCreatedAt().toLocalDate() : null)
+ .build())
+ .collect(Collectors.toList());
+
+ // 다음 커서 계산
+ String nextCursor = "-1";
+ if (reviewSlice.hasNext() && !reviewSlice.getContent().isEmpty()) {
+ Review lastReview = reviewSlice.getContent().get(reviewSlice.getContent().size() - 1);
+ if (query.equalsIgnoreCase("rate")) {
+ // 별점 순 커서: (별점:리뷰ID) .. 별점 같을 시 리뷰 ID순
+ nextCursor = lastReview.getScore() + ":" + lastReview.getId();
+ } else {
+ // 최신순 커서: 리뷰ID
+ nextCursor = String.valueOf(lastReview.getId());
+ }
+ }
+
+ return ReviewResponseDto.CursorPagination.builder()
+ .data(dtoList)
+ .hasNext(reviewSlice.hasNext())
+ .nextCursor(nextCursor)
+ .pageSize(reviewSlice.getSize())
+ .build();
+ }
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
index dc0fe1b3..7dff42f9 100644
--- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
+++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
@@ -5,9 +5,13 @@
import com.study.UMC10.global.apiPayload.code.GeneralErrorCode;
import com.study.UMC10.global.apiPayload.exception.GeneralException;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import java.util.HashMap;
+import java.util.Map;
+
@RestControllerAdvice
public class GeneralExceptionAdvice {
@@ -21,6 +25,22 @@ public ResponseEntity> handleMemberException(
.body(ApiResponse.onFailure(errorCode, null));
}
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity>> handleMethodArgumentNotValidException(
+ MethodArgumentNotValidException e
+ ) {
+ Map errors = new HashMap<>();
+
+ e.getBindingResult().getFieldErrors().forEach(error -> {
+ errors.put(error.getField(), error.getDefaultMessage());
+ });
+
+ BaseErrorCode code = GeneralErrorCode.BAD_REQUEST;
+
+ return ResponseEntity.status(code.getStatus())
+ .body(ApiResponse.onFailure(code, errors));
+ }
+
// 그 외의 정의되지 않은 모든 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity> handleException(