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(