From f3398bd50dca2bfe03b61b969e776e47caaf3fb3 Mon Sep 17 00:00:00 2001 From: chazicer Date: Thu, 14 May 2026 14:22:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?keyword:=207=EC=A3=BC=EC=B0=A8=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keyword/chapter07/keyword.md | 914 +++++++++++++++++++++++++++++++++++ 1 file changed, 914 insertions(+) create mode 100644 keyword/chapter07/keyword.md diff --git a/keyword/chapter07/keyword.md b/keyword/chapter07/keyword.md new file mode 100644 index 0000000..c9edee6 --- /dev/null +++ b/keyword/chapter07/keyword.md @@ -0,0 +1,914 @@ +# 1. Page, Slice, Pageable + +## 한 줄 정의 + +`Page`와 `Slice`는 Spring Data에서 페이지네이션 결과를 표현하는 타입이다. + +```text +Page = 현재 페이지 데이터 + 전체 개수 정보 +Slice = 현재 조각 데이터 + 다음 데이터 존재 여부 +``` + +`Pageable`은 페이지 요청 정보를 담는 인터페이스이고, 보통 `PageRequest`로 만든다. + +```java +Pageable pageable = PageRequest.of( + pageNumber, + pageSize, + Sort.by(Sort.Direction.DESC, "id") +); +``` + +--- + +## 왜 필요한가 + +목록 데이터를 한 번에 모두 내려주면 DB 조회량, 네트워크 응답 크기, 프론트 렌더링 비용이 커진다. + +```text +리뷰 200개를 한 번에 응답 +→ 사용자가 보지 않는 데이터까지 전송 +→ 응답 느려짐 +→ 화면 렌더링 부담 증가 +``` + +그래서 목록 API는 보통 일정 크기로 끊어 내려준다. + +```text +처음 10개 조회 +다음 10개 조회 +다시 다음 10개 조회 +``` + +페이징이 들어간 조회 API는 단순히 데이터를 가져오는 코드가 아니라, 요청 검증부터 DTO 변환까지 한 흐름으로 이어진다. + +```mermaid +flowchart TD + A[Client 요청] --> B[Controller] + B --> C{Request 검증} + C -- 실패 --> D[Validation Exception] + D --> E[GeneralExceptionAdvice] + E --> F[ApiResponse 실패 응답] + C -- 성공 --> G[Service] + G --> H[Pageable 또는 Cursor 조건 생성] + H --> I[Repository 조회] + I --> J[Page / Slice / List] + J --> K[DTO 변환] + K --> L[ApiResponse 성공 응답] +``` + +--- + +## 어떻게 쓰나 + +Repository 메서드에 `Pageable`을 넘기면 된다. + +```java +public interface MissionRepository extends JpaRepository { + + Page findByStoreId(Long storeId, Pageable pageable); + + Slice findSliceByStoreId(Long storeId, Pageable pageable); +} +``` + +Service에서는 `PageRequest`를 만든다. + +```java +Pageable pageable = PageRequest.of( + pageNumber, + pageSize, + Sort.by(Sort.Direction.DESC, "id") +); + +Page missionPage = missionRepository.findByStoreId(storeId, pageable); +``` + +SQL 관점에서는 대략 다음과 비슷하다. + +```sql +select * +from mission +where store_id = ? +order by id desc +limit 10 offset 0; +``` + +--- + +## Pageable, PageRequest, Sort + +세 개념은 역할이 조금씩 다르다. + +| 개념 | 역할 | +|---|---| +| `Pageable` | 페이지 번호, 크기, 정렬 정보를 담는 인터페이스 | +| `PageRequest` | `Pageable`의 대표 구현체 | +| `Sort` | 정렬 기준과 방향을 표현 | + +실사용에서 중요한 것은 **정렬 기준을 명확히 두는 것**이다. + +```text +정렬 없는 페이지네이션 +→ DB가 어떤 순서로 줄지 명확하지 않음 + +id desc +→ 최신 데이터 먼저 조회 + +createdAt desc, id desc +→ 생성일이 같은 경우까지 안정적으로 정렬 +``` + +페이지네이션은 `pageNumber`, `pageSize`만 정하는 것이 아니라 **정렬 기준까지 함께 정하는 작업**이다. + +--- + +## Page와 Slice 비교 + +| 구분 | Page | Slice | +|---|---|---| +| 현재 페이지 데이터 | O | O | +| 전체 데이터 개수 | O | X | +| 전체 페이지 수 | O | X | +| 다음 페이지 여부 | O | O | +| count 쿼리 | 보통 발생 | 보통 불필요 | +| 어울리는 화면 | 관리자 페이지, 검색 결과 | 더보기, 무한 스크롤 | + +정리하면 다음과 같다. + +```text +전체 개수와 페이지 번호가 필요하다 +→ Page + +다음 데이터가 있는지만 알면 된다 +→ Slice +``` + +--- + +## 실사용 팁 + +화면 형태를 먼저 보고 선택하면 좋다. + +```text +관리자 테이블 / 검색 결과 +→ Page 기반 오프셋 페이지네이션 + +모바일 피드 / 리뷰 목록 / 알림 목록 +→ Slice 또는 커서 기반 페이지네이션 + +응답 스펙이 중요한 API +→ Page/Slice 직접 노출 대신 커스텀 DTO 사용 +``` + +`Page` 객체를 그대로 응답으로 내려줄 수도 있지만, 필요하지 않은 내부 정보가 섞일 수 있다. + +그래서 보통은 커스텀 응답 DTO를 만든다. + +```java +public record MissionPageResponse( + List missions, + int pageNumber, + int pageSize, + long totalElements, + int totalPages, + boolean isFirst, + boolean isLast +) { +} +``` + +--- + +## 공부하면서 얻어갈 점 + +> **Page와 Slice는 우열이 아니라 화면 요구사항으로 고르는 도구다.** +> [Spring Data Commons 문서](https://docs.spring.io/spring-data/commons/reference/repositories/query-return-types-reference.html)를 보면 `Page`는 전체 개수와 전체 페이지 수까지 포함하고, `Slice`는 다음 조각이 있는지에 집중한다. 그래서 관리자 목록처럼 전체 개수가 필요한 화면은 `Page`, 모바일 더보기처럼 다음 데이터 여부만 중요한 화면은 `Slice`나 커스텀 페이징 응답이 더 자연스럽다고 이해하면 좋다. + +> **요즘은 Page 객체를 그대로 노출하기보다 응답 DTO로 한 번 감싸는 편이 더 안정적이다.** +> `Page`는 Spring Data가 제공하는 편리한 타입이지만, API 응답으로 그대로 내리면 내부 필드 구조가 클라이언트 계약이 되어버릴 수 있다. 그래서 프로젝트에서는 `pageNumber`, `pageSize`, `totalElements`, `isFirst`, `isLast`처럼 화면에 필요한 값만 골라 DTO로 내려주는 방향을 잡았다. + +> **자료를 볼 때는 "Page/Slice의 기능"보다 "어떤 화면에 맞는가"를 중심으로 보면 좋다.** +> 공식 문서에서 반환 타입 차이를 확인하고, 실제 구현에서는 화면이 페이지 번호를 요구하는지, 무한 스크롤을 요구하는지, 전체 개수가 필요한지를 먼저 질문하면 선택이 쉬워진다. + +--- + +## Offset Pagination과 Cursor Pagination + +### 한 줄 정의 + +오프셋 기반은 “몇 페이지인가”를 기준으로 조회하고, 커서 기반은 “어디부터 이어서 가져올 것인가”를 기준으로 조회한다. + +```text +Offset Pagination +→ pageNumber, pageSize + +Cursor Pagination +→ cursor, size +``` + +--- + +### 왜 필요한가 + +같은 페이지네이션이어도 화면과 데이터 규모에 따라 적합한 방식이 다르다. + +오프셋 기반은 페이지 번호 UI에 좋다. + +```http +GET /missions?pageNumber=0&pageSize=10 +GET /missions?pageNumber=1&pageSize=10 +``` + +커서 기반은 무한 스크롤이나 더보기 UI에 좋다. + +```http +GET /reviews?size=10 +GET /reviews?cursor=100&size=10 +``` + +--- + +### 오프셋 기반 페이지네이션 + +SQL 관점에서는 `limit`와 `offset`을 사용한다. + +```sql +select * +from mission +order by id desc +limit 10 offset 20; +``` + +장점: + +```text +페이지 번호 UI 만들기 쉬움 +전체 페이지 수와 잘 어울림 +구현이 직관적 +``` + +주의할 점: + +```text +뒤쪽 페이지로 갈수록 느려질 수 있음 +중간에 데이터가 추가/삭제되면 중복이나 누락 가능 +``` + +--- + +### 커서 기반 페이지네이션 + +ID 기준 커서라면 다음처럼 조회한다. + +```sql +select * +from review +where review_id < :cursor +order by review_id desc +limit :size; +``` + +첫 요청에는 cursor가 없을 수 있다. + +```sql +select * +from review +order by review_id desc +limit :size; +``` + +응답에서는 다음 요청에 사용할 커서를 내려준다. + +```json +{ + "reviews": [], + "nextCursor": "91", + "hasNext": true +} +``` + +--- + +### 커서 설계에서 중요한 점 + +커서는 정렬 기준과 함께 설계해야 한다. + +ID 정렬은 ID가 유니크하므로 cursor 하나로 충분할 수 있다. + +```sql +where review_id < :cursor +order by review_id desc +``` + +하지만 별점은 중복될 수 있다. + +```text +5.0점 리뷰 A +5.0점 리뷰 B +5.0점 리뷰 C +``` + +이때 별점만 cursor로 쓰면 중복이나 누락이 생길 수 있다. +그래서 별점과 ID를 함께 사용한다. + +```sql +where rating < :rating + or (rating = :rating and review_id < :reviewId) +order by rating desc, review_id desc +limit :size; +``` + +커서는 다음처럼 만들 수 있다. + +```text +rating:reviewId +4.5:120 +``` + +--- + +### 실사용 팁 + +선택 기준은 이렇게 잡으면 쉽다. + +```text +페이지 번호가 중요하다 +→ Offset + Page + +무한 스크롤이 중요하다 +→ Cursor + hasNext + +전체 개수가 꼭 필요하다 +→ Page + +전체 개수보다 다음 데이터 여부가 중요하다 +→ Slice 또는 size + 1 조회 +``` + +--- + +### 공부하면서 얻어갈 점 + +> **커서 기반 페이지네이션은 단순히 `cursor` 파라미터 하나를 추가하는 일이 아니다.** +> [GitHub GraphQL Pagination 문서](https://docs.github.com/graphql/guides/using-pagination-in-the-graphql-api)를 보면 `endCursor`, `hasNextPage`를 함께 내려준다. 여기서 커서는 다음 조회를 이어가기 위한 "정렬 기준의 위치값"에 가깝다. 그래서 `id desc`인지, `rating desc + id desc`인지처럼 정렬 기준을 먼저 고정해야 커서도 안정적으로 설계할 수 있다. + +> **해외 커뮤니티에서 커서 기반 페이지네이션 질문이 계속 나오는 이유는 처음 감각이 직관적이지 않기 때문이다.** +> 페이지 번호는 `1페이지`, `2페이지`처럼 사람이 이해하기 쉽지만, 커서는 서버가 정렬 기준을 바탕으로 다음 조회 위치를 계산한다. [Reddit의 cursor pagination 질문](https://www.reddit.com/r/shopifyDev/comments/1r9blbx/struggling_to_understand_cursorbased_pagination/)처럼 처음에는 `cursor`가 무엇을 의미하는지 헷갈리기 쉽다. 이때 "마지막으로 본 데이터의 정렬 위치"라고 이해하면 훨씬 편하다. + +> **이번 미션에서는 두 방식을 모두 써본 것이 학습 포인트다.** +> 진행중 미션 조회는 워크북 흐름에 맞춰 `Page` 기반 오프셋 페이징으로 구현했고, 내가 작성한 리뷰 목록은 커서 기반으로 구현했다. 덕분에 `Page`가 주는 전체 개수 정보와, 커서 방식의 `hasNext`, `nextCursor` 감각을 비교할 수 있었다. + +--- + +# 2. Java Stream API + +## 한 줄 정의 + +Stream API는 컬렉션 데이터를 선언형으로 변환, 필터링, 집계하기 위한 도구이다. + +이번 워크북에서는 주로 **Entity 목록을 Response DTO 목록으로 변환**할 때 사용한다. + +```text +List +→ stream() +→ map() +→ toList() +``` + +--- + +## 왜 필요한가 + +Repository에서 조회한 결과는 보통 Entity다. + +```java +List missions = missionRepository.findAll(); +``` + +하지만 API 응답으로 Entity를 그대로 내려주지는 않는다. + +```text +Mission Entity +→ MissionResponse DTO +``` + +Stream을 사용하면 이 변환 의도가 잘 보인다. + +```java +List responses = missions.stream() + .map(MissionConverter::toMissionResponse) + .toList(); +``` + +핵심은 “반복한다”가 아니라 **Mission을 MissionResponse로 매핑한다**는 점이다. + +--- + +## filter, map, toList + +이번 주차에서 가장 중요한 것은 `map`이다. + +| 메서드 | 역할 | +|---|---| +| `filter` | 조건에 맞는 데이터만 남김 | +| `map` | 데이터 형태를 변환 | +| `toList` | Stream 결과를 List로 수집 | + +예시: + +```java +List responses = missions.stream() + .filter(Mission::isActive) + .map(MissionConverter::toMissionResponse) + .toList(); +``` + +`stream().toList()`는 Java 16부터 사용할 수 있고, 반환 리스트는 변경 불가능하다. +변환 후 요소를 추가해야 한다면 명시적으로 새 리스트를 만들면 된다. + +```java +List responses = new ArrayList<>( + missions.stream() + .map(MissionConverter::toMissionResponse) + .toList() +); +``` + +--- + +## Page와 Stream + +`Page`는 content 변환용 `map`을 제공한다. + +```java +Page responsePage = + missionPage.map(MissionConverter::toMissionResponse); +``` + +이 방식은 페이지 메타데이터는 유지하고 content 타입만 바꿀 수 있다. + +```text +Page +→ Page +``` + +--- + +## 주의할 점 + +Stream은 조회 최적화 도구가 아니다. + +```text +Stream +→ 조회된 데이터를 가공하는 도구 + +성능 +→ Repository 쿼리 설계, fetch 전략, 인덱스 설계가 더 중요 +``` + +예를 들어 다음 코드는 깔끔해 보인다. + +```java +missions.stream() + .map(mission -> mission.getStore().getName()) + .toList(); +``` + +하지만 `store`가 LAZY이고 미리 조회되지 않았다면 N+1이 생길 수 있다. + +```text +Mission 목록 조회 1번 ++ 각 Mission의 Store 조회 N번 +``` + +따라서 Stream보다 먼저 확인할 질문은 이것이다. + +```text +DTO 변환에 필요한 연관 객체가 이미 조회되어 있는가? +``` + +--- + +## 실사용 팁 + +Stream 안에는 단순 변환만 두는 편이 좋다. + +```text +좋은 사용 +→ Entity를 DTO로 변환 + +주의할 사용 +→ Stream 안에서 Entity 상태 변경 +→ Stream 안에서 외부 서비스 호출 +→ Stream 안에서 복잡한 비즈니스 로직 처리 +``` + +복잡한 조건이 많아지면 Stream보다 for문이 더 읽기 쉬울 수도 있다. + +--- + +## 공부하면서 얻어갈 점 + +> **Stream은 DB 조회를 빠르게 만드는 도구가 아니라, 조회된 데이터를 읽기 좋게 변환하는 도구다.** +> [Java Stream API 문서](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/Stream.html)를 보면 Stream은 컬렉션 데이터를 `filter`, `map`, `reduce` 같은 연산으로 처리하는 추상화다. 이번 미션에서는 특히 `map()`을 통해 Entity 목록을 DTO 목록으로 바꾸는 데 집중하면 된다. + +> **Stream을 쓰면 코드가 짧아지지만, 책임이 섞이면 오히려 읽기 어려워진다.** +> `stream().map(converter::toDto).toList()`처럼 단순 변환은 깔끔하다. 반대로 Stream 안에서 DB 조회, 외부 API 호출, Entity 상태 변경이 섞이면 디버깅이 어려워진다. 그래서 Stream은 "이미 조회된 데이터를 응답 형태로 바꾸는 구간"에 두는 것이 가장 안전하다. + +> **해외 자료를 볼 때는 병렬 Stream보다 기본 Stream의 의도를 먼저 보는 편이 좋다.** +> 초반 학습에서는 `parallelStream()`보다 `map`, `filter`, `toList`의 흐름을 익히는 게 중요하다. 실제 웹 애플리케이션에서는 병렬 처리보다 트랜잭션, 영속성 컨텍스트, 쿼리 수가 더 큰 영향을 주는 경우가 많다. + +--- + +# 3. 객체 그래프 탐색 + +## 한 줄 정의 + +객체 그래프 탐색은 한 Entity에서 연관된 다른 Entity로 객체 참조를 따라 이동하는 것이다. + +```java +memberMission.getMission().getStore().getName() +``` + +이 코드는 `MemberMission → Mission → Store`로 이동해서 가게 이름을 읽는다. + +--- + +## 왜 필요한가 + +RDB에서는 테이블 간 관계를 join으로 탐색한다. + +```sql +select s.name +from member_mission mm +join mission m on mm.mission_id = m.mission_id +join store s on m.store_id = s.store_id +where mm.member_id = ?; +``` + +JPA에서는 객체 참조를 따라 탐색할 수 있다. + +```java +String storeName = memberMission.getMission().getStore().getName(); +``` + +객체처럼 읽을 수 있다는 점이 JPA의 장점이다. + +--- + +## 어떻게 쓰나 + +DTO 변환 과정에서 자주 사용된다. + +```java +MemberMissionResponse response = new MemberMissionResponse( + memberMission.getId(), + memberMission.getMission().getId(), + memberMission.getMission().getStore().getName(), + memberMission.getStatus() +); +``` + +하지만 목록 조회에서는 다음을 확인해야 한다. + +```text +memberMission은 영속 상태인가? +mission은 이미 조회되어 있는가? +store는 이미 조회되어 있는가? +반복문 안에서 탐색이 수행되는가? +``` + +--- + +## LAZY와 N+1 + +연관관계가 LAZY라면 처음에는 실제 객체가 아니라 프록시가 들어있을 수 있다. + +```java +@ManyToOne(fetch = FetchType.LAZY) +private Mission mission; +``` + +프록시의 실제 필드에 접근하는 순간 추가 SELECT가 발생할 수 있다. + +문제는 목록에서 반복될 때다. + +```java +memberMissions.stream() + .map(mm -> mm.getMission().getStore().getName()) + .toList(); +``` + +필요한 연관 객체가 미리 조회되지 않았다면 다음처럼 쿼리가 늘 수 있다. + +```text +MemberMission 목록 조회 1번 ++ Mission 조회 N번 ++ Store 조회 N번 +``` + +이를 N+1 문제라고 한다. + +--- + +## 해결 키워드 + +객체 그래프 탐색을 안전하게 쓰려면 필요한 연관 데이터를 미리 가져오거나, 처음부터 DTO로 조회할 수 있다. + +### Fetch Join + +```java +@Query(""" + select mm + from MemberMission mm + join fetch mm.mission m + join fetch m.store s + where mm.member.id = :memberId +""") +List findWithMissionAndStore(Long memberId); +``` + +### EntityGraph + +```java +@EntityGraph(attributePaths = {"mission", "mission.store"}) +List findByMemberId(Long memberId); +``` + +### DTO Projection + +```java +@Query(""" + select new com.example.MemberMissionResponse( + mm.id, + m.id, + s.name, + mm.status + ) + from MemberMission mm + join mm.mission m + join m.store s + where mm.member.id = :memberId +""") +Page findMissionResponses(Long memberId, Pageable pageable); +``` + +--- + +## 실사용 팁 + +기준은 다음처럼 잡을 수 있다. + +```text +도메인 로직 처리 +→ Entity 그래프 탐색 활용 + +목록 화면 응답 +→ 필요한 연관 데이터를 fetch join / EntityGraph / DTO Projection으로 명시 + +API 응답 +→ Entity 직접 반환 금지, DTO로 끊기 +``` + +객체 그래프 탐색은 “마음껏 접근해도 된다”가 아니라, **객체 관계를 따라 읽을 수 있지만 그 뒤에서 SQL이 발생할 수 있다**는 뜻으로 이해해야 한다. + +--- + +## 공부하면서 얻어갈 점 + +> **객체 그래프 탐색은 JPA를 편하게 쓰게 해주지만, SQL이 사라졌다는 뜻은 아니다.** +> [Hibernate User Guide의 fetching 관련 내용](https://docs.hibernate.org/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#fetching)을 보면 연관 객체를 언제, 어떻게 가져올지가 성능에 큰 영향을 준다. `memberMission.getMission().getStore().getName()`은 Java 코드로는 자연스럽지만, 연관관계가 `LAZY`라면 필요한 순간 추가 SQL이 실행될 수 있다. + +> **목록 API에서는 "응답에 필요한 연관 데이터"를 먼저 확인해야 한다.** +> 단건 조회에서는 객체 그래프 탐색이 편하지만, 목록 조회에서 반복적으로 연관 객체를 접근하면 N+1 문제가 생길 수 있다. 그래서 목록 화면은 fetch join, EntityGraph, DTO projection, 명시적 repository query 중 하나를 선택해 조회 의도를 드러내는 편이 좋다. + +> **오픈소스 Spring 프로젝트를 볼 때는 Entity를 그대로 반환하지 않는 흐름을 보면 좋다.** +> [Spring PetClinic](https://github.com/spring-projects/spring-petclinic) 같은 예제 프로젝트는 Spring 계층 구조와 검증, 조회 흐름을 보기 좋다. 여기서 얻어갈 점은 "JPA Entity는 DB와 도메인 모델을 표현하고, API 응답은 DTO로 끊는다"는 감각이다. + +--- + +# 4. @Valid와 @Validated + +## 한 줄 정의 + +`@Valid`와 `@Validated`는 요청 데이터가 정해진 조건을 만족하는지 검증하기 위해 사용하는 어노테이션이다. + +```text +@Valid +→ Jakarta Bean Validation 표준 검증 트리거 + +@Validated +→ Spring이 제공하는 검증 확장 기능 +``` + +--- + +## 왜 필요한가 + +클라이언트 요청은 항상 올바르다고 가정할 수 없다. + +```json +{ + "deadline": null, + "point": -100, + "conditional": "" +} +``` + +이런 값을 서비스 로직에서 뒤늦게 처리하면 500 에러가 나거나, 클라이언트가 무엇을 잘못 보냈는지 알기 어렵다. + +그래서 Controller 진입 시점에서 Request DTO를 먼저 검증한다. + +```text +Client 요청 +→ Controller에서 @Valid 검증 +→ 실패 시 Validation Exception +→ GeneralExceptionAdvice +→ ApiResponse 실패 응답 +--- + +## validation 의존성 + +검증 어노테이션을 사용하려면 Bean Validation 의존성이 필요하다. + +Spring Boot 3 계열에서는 보통 다음 의존성을 추가한다. + +```gradle +implementation 'org.springframework.boot:spring-boot-starter-validation' +``` + +그리고 Spring Boot 3에서는 `javax.validation.*`이 아니라 `jakarta.validation.*` 패키지를 사용한다. + +```java +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +``` + +--- + +## 어떻게 쓰나 + +Request DTO에 검증 어노테이션을 붙인다. + +```java +public record MissionCreateRequest( + @NotNull(message = "마감일은 필수입니다.") + @Future(message = "마감일은 현재보다 미래여야 합니다.") + LocalDate deadline, + + @NotNull(message = "포인트는 필수입니다.") + @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") + Integer point, + + @NotBlank(message = "미션 조건은 필수입니다.") + @Size(max = 255, message = "미션 조건은 최대 255자까지 입력할 수 있습니다.") + String conditional +) { +} +``` + +Controller 파라미터에 `@Valid`를 붙여야 검증이 실행된다. + +```java +@PostMapping("/missions") +public ApiResponse createMission( + @Valid @RequestBody MissionCreateRequest request +) { + missionService.createMission(request); + return ApiResponse.onSuccess(...); +} +``` + +검증 실패는 전역 예외 처리에서 잡아 응답을 통일한다. + +```java +@ExceptionHandler(MethodArgumentNotValidException.class) +public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException e +) { + Map errors = new HashMap<>(); + + e.getBindingResult().getFieldErrors().forEach(error -> { + errors.put(error.getField(), error.getDefaultMessage()); + }); + + return ResponseEntity.badRequest() + .body(ApiResponse.onFailure(GeneralErrorCode.BAD_REQUEST, errors)); +} +``` + +--- + +## 자주 쓰는 검증 어노테이션 + +| 어노테이션 | 대상 | 의미 | +|---|---|---| +| `@NotNull` | 모든 참조 타입 | null 불가 | +| `@NotBlank` | String | null, 빈 문자열, 공백 불가 | +| `@NotEmpty` | String, Collection | null 또는 empty 불가 | +| `@Size` | String, Collection | 길이 또는 크기 제한 | +| `@Min` / `@Max` | 숫자 | 최소/최대값 제한 | +| `@Positive` | 숫자 | 양수 | +| `@Future` | 날짜 | 미래 날짜 | +| `@Past` | 날짜 | 과거 날짜 | +| `@Pattern` | String | 정규식 패턴 검증 | +| `@Email` | String | 이메일 형식 검증 | + +문자열 필수 입력에는 보통 `@NotBlank`가 적합하다. + +```text +@NotNull +→ null만 막음 + +@NotEmpty +→ null과 빈 값 막음 + +@NotBlank +→ null, 빈 문자열, 공백 문자열까지 막음 +``` + +--- + +## @Valid와 @Validated 비교 + +| 구분 | @Valid | @Validated | +|---|---|---| +| 소속 | Jakarta Bean Validation 표준 | Spring 제공 | +| 주 용도 | Request Body DTO 검증 | DTO 검증 + 그룹 검증 + 메서드 파라미터 검증 | +| 그룹 검증 | 직접 지원하지 않음 | 지원 | +| 자주 쓰는 위치 | `@RequestBody` 앞 | Controller 클래스, 그룹 검증 대상 | + +일반적인 기준은 다음과 같다. + +```text +Request Body DTO 단순 검증 +→ @Valid + +검증 그룹이 필요하다 +→ @Validated + +@RequestParam, @PathVariable 검증 +→ Controller 클래스에 @Validated 고려 +``` + +--- + +## Request Body 검증과 Query Parameter 검증 + +Request Body DTO 검증 실패는 보통 `MethodArgumentNotValidException`으로 처리한다. + +```text +@Valid @RequestBody +→ DTO 필드 검증 실패 +→ MethodArgumentNotValidException +``` + +하지만 `@RequestParam`, `@PathVariable` 검증은 다른 예외 흐름을 탈 수 있다. + +```text +필수 Query Parameter 누락 +→ MissingServletRequestParameterException + +타입 변환 실패 +→ MethodArgumentTypeMismatchException + +@Min, @Positive 같은 제약조건 실패 +→ Spring 버전에 따라 ConstraintViolationException 또는 HandlerMethodValidationException 계열 +``` + +이번 워크북은 Request Body 검증이 핵심이므로 `MethodArgumentNotValidException`을 먼저 처리하면 충분하다. +이후 Query Parameter 검증까지 넓히면 전역 예외 처리 범위를 한 단계 더 확장하는 학습이 된다. + +--- + +## 실사용 팁 + +검증은 한 곳에서 모든 것을 막으려 하기보다 층을 나누면 좋다. + +```text +Controller DTO 검증 +→ 요청 형식, 필수값, 길이, 범위 검증 + +Service 도메인 검증 +→ 권한, 상태 전이, 중복 여부, 소유자 검증 + +DB 제약조건 +→ unique, not null, foreign key 등 최종 데이터 무결성 보장 +``` + +`@Valid`는 요청 형식을 검증하는 도구이지, 모든 비즈니스 규칙을 대체하지 않는다. + +--- + +## 공부하면서 얻어갈 점 + +> **검증은 Controller에서 끝나는 일이 아니라 실패 응답까지 포함한 API 설계다.** +> [Spring MVC Validation 문서](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-validation.html)를 보면 Request Body 검증 실패와 메서드 파라미터 검증 실패는 다른 예외 흐름으로 처리될 수 있다. 이번 워크북에서는 `@Valid @RequestBody`와 `MethodArgumentNotValidException`을 먼저 잡았고, 이후 Query Parameter 검증까지 넓히면 `HandlerMethodValidationException` 같은 흐름도 함께 공부할 수 있다. + +> **Spring Boot 3 이후에는 `javax.validation`보다 `jakarta.validation`을 보는 습관이 필요하다.** +> [Hibernate Validator Reference Guide](https://docs.hibernate.org/validator/8.0/reference/en-US/html_single/)는 Jakarta Bean Validation 기반 설명을 제공한다. 예전 블로그 글에는 `javax.validation.Valid`가 많이 나오지만, 현재 Spring Boot 3 계열에서는 `jakarta.validation.Valid`를 사용하는 것이 자연스럽다. + +> **`@Valid`는 요청 형식 검증, Service 검증은 비즈니스 규칙 검증으로 나누면 이해가 쉽다.** +> `memberId`가 null인지, 문자열 길이가 맞는지, 별점 범위가 맞는지는 DTO 검증에서 처리한다. 반면 이미 리뷰를 작성한 회원 미션인지, 완료된 미션인지, 본인의 미션인지 같은 규칙은 Service나 도메인 로직에서 검증하는 것이 좋다. From a993eb4c1c5b804e2e7d2bae4162337c5df70ee3 Mon Sep 17 00:00:00 2001 From: chazicer Date: Thu, 14 May 2026 14:36:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?mission:=207=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/chapter07/mission.md | 24 +++ mission/chapter07/mock_seed.sql | 263 ++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 mission/chapter07/mission.md create mode 100644 mission/chapter07/mock_seed.sql diff --git a/mission/chapter07/mission.md b/mission/chapter07/mission.md new file mode 100644 index 0000000..cf234b8 --- /dev/null +++ b/mission/chapter07/mission.md @@ -0,0 +1,24 @@ +# 미션 + +우선 테스트용 db를 구성하기위해서 mock_seed.sql 파일을 이용해서 데이터를 삽입하였습니다. + +## 1. 내가 진행중인 미션 조회하기 + +![](https://img.boostad.site/2026/05/927d739ca0751cb4c98aea50423e25fd.png) + +## 2. 내가 생성한 리뷰들 조회하기 + +### 1. ID 순 커서 조회 + +![](https://img.boostad.site/2026/05/ac0b33e9d2b22b523986f39c8f0612a7.png) + +### 2. 별점 순 커서 조회 + +![](https://img.boostad.site/2026/05/5e93dbef2298c74c395fea8eda868e4e.png) + +## 3. Request Body가 있는 API에 검증 어노테이션 붙혀 검증하기 + +![](https://img.boostad.site/2026/05/0cddbb0832c597d665e4e8869c643b95.png) + +--- +# 피어리뷰 diff --git a/mission/chapter07/mock_seed.sql b/mission/chapter07/mock_seed.sql new file mode 100644 index 0000000..582611e --- /dev/null +++ b/mission/chapter07/mock_seed.sql @@ -0,0 +1,263 @@ +-- DROP DATABASE IF EXISTS umc10th; +-- CREATE DATABASE umc10th CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +-- USE umc10th; +-- 위는 처음 세팅시나 초기화시에 실행해줘야함! + +SET FOREIGN_KEY_CHECKS = 0; + +TRUNCATE TABLE review_photo; +TRUNCATE TABLE review_reply; +TRUNCATE TABLE review; +TRUNCATE TABLE inquiry_photo; +TRUNCATE TABLE inquiry_reply; +TRUNCATE TABLE inquiry; +TRUNCATE TABLE notification_setting; +TRUNCATE TABLE notification; +TRUNCATE TABLE point_history; +TRUNCATE TABLE member_mission_verification; +TRUNCATE TABLE member_mission; +TRUNCATE TABLE mission; +TRUNCATE TABLE store_owner; +TRUNCATE TABLE store_business_hour; +TRUNCATE TABLE store_food_category; +TRUNCATE TABLE store; +TRUNCATE TABLE member_food_category; +TRUNCATE TABLE member_region_goal; +TRUNCATE TABLE member_address; +TRUNCATE TABLE region_goal; +TRUNCATE TABLE food_category; +TRUNCATE TABLE member_term_agreement; +TRUNCATE TABLE term; +TRUNCATE TABLE region; +TRUNCATE TABLE member; + +SET FOREIGN_KEY_CHECKS = 1; + +SET @now = NOW(); +SET @started_at = TIMESTAMP('2026-05-01 00:00:00'); +SET @ended_at = TIMESTAMP('2026-05-31 23:59:59'); + +INSERT INTO member ( + member_id, + name, + nickname, + gender, + birth, + social_provider, + point, + email, + phone_number, + profile_image_url, + created_at, + updated_at +) VALUES + (1, '차그린', 'nickname012', 'MALE', '2001-07-10', 'LOCAL', 2500, 'green@example.com', '01011112222', 'https://example.com/profile/green.png', @now, @now), + (2, '김블루', 'blueuser', 'FEMALE', '2000-03-15', 'LOCAL', 1200, 'blue@example.com', '01033334444', NULL, @now, @now); + +INSERT INTO region ( + region_id, + name, + created_at, + updated_at +) VALUES + (1, '안암동', @now, @now), + (2, '오금동', @now, @now); + +INSERT INTO food_category ( + food_category_id, + name, + created_at, + updated_at +) VALUES + (1, '중식', @now, @now), + (2, '한식', @now, @now), + (3, '일식', @now, @now); + +INSERT INTO member_address ( + member_address_id, + address_name, + address, + detail_address, + is_default, + is_current, + last_selected_at, + member_id, + region_id, + created_at, + updated_at +) VALUES + (1, '학교 근처', '서울 성북구 안암동 1', '101동 202호', TRUE, TRUE, @now, 1, 1, @now, @now), + (2, '집', '서울 송파구 오금동 1', '303동 404호', TRUE, TRUE, @now, 2, 2, @now, @now); + +INSERT INTO region_goal ( + region_goal_id, + target_month, + goal_mission_count, + reward_point, + started_at, + ended_at, + region_id, + created_at, + updated_at +) VALUES + (1, '2026-05', 10, 1000, @started_at, @ended_at, 1, @now, @now), + (2, '2026-05', 8, 800, @started_at, @ended_at, 2, @now, @now); + +INSERT INTO member_region_goal ( + member_region_goal_id, + completed_mission_count, + is_reward_received, + reward_received_at, + member_id, + region_goal_id, + created_at, + updated_at +) VALUES + (1, 7, FALSE, NULL, 1, 1, @now, @now), + (2, 2, FALSE, NULL, 2, 2, @now, @now); + +INSERT INTO member_food_category ( + member_food_category_id, + member_id, + food_category_id, + created_at, + updated_at +) VALUES + (1, 1, 1, @now, @now), + (2, 1, 2, @now, @now), + (3, 2, 3, @now, @now); + +INSERT INTO store ( + store_id, + name, + address, + phone_number, + average_rating, + region_id, + primary_food_category_id, + created_at, + updated_at +) VALUES + (1, '반이학생마라탕', '서울 성북구 안암동 10', '02-111-2222', 4.5, 1, 1, @now, @now), + (2, '안암김치찌개', '서울 성북구 안암동 20', '02-333-4444', 4.2, 1, 2, @now, @now), + (3, '오금초밥', '서울 송파구 오금동 30', '02-555-6666', 4.8, 2, 3, @now, @now); + +INSERT INTO store_food_category ( + store_food_category_id, + store_id, + food_category_id, + created_at, + updated_at +) VALUES + (1, 1, 1, @now, @now), + (2, 2, 2, @now, @now), + (3, 3, 3, @now, @now); + +INSERT INTO store_owner ( + store_owner_id, + name, + login_id, + password_hash, + store_id, + created_at, + updated_at +) VALUES + (1, '마라탕사장', 'owner_mara', 'mock-password-hash', 1, @now, @now), + (2, '김치찌개사장', 'owner_kimchi', 'mock-password-hash', 2, @now, @now); + +INSERT INTO mission ( + mission_id, + title, + content, + reward_point, + verification_type, + is_active, + started_at, + ended_at, + store_id, + created_at, + updated_at +) VALUES + (1, '마라탕 식사 미션', '10,000원 이상의 식사 시', 500, 'OWNER_CONFIRM', TRUE, @started_at, @ended_at, 1, @now, @now), + (2, '김치찌개 식사 미션', '8,000원 이상의 식사 시', 300, 'OWNER_CONFIRM', TRUE, @started_at, @ended_at, 2, @now, @now), + (3, '꿔바로우 추가 미션', '15,000원 이상의 식사 시', 700, 'OWNER_CONFIRM', TRUE, @started_at, @ended_at, 1, @now, @now), + (4, '초밥 식사 미션', '12,000원 이상의 식사 시', 600, 'OWNER_CONFIRM', TRUE, @started_at, @ended_at, 3, @now, @now), + (5, '진행 가능한 추가 미션', '20,000원 이상의 식사 시', 900, 'OWNER_CONFIRM', TRUE, @started_at, @ended_at, 2, @now, @now); + +INSERT INTO member_mission ( + member_mission_id, + status, + started_at, + success_requested_at, + completed_at, + rejected_at, + canceled_at, + mission_id, + member_id, + created_at, + updated_at +) VALUES + (1, 'IN_PROGRESS', '2026-05-02 10:00:00', NULL, NULL, NULL, NULL, 1, 1, @now, @now), + (2, 'SUCCESS_REQUESTED', '2026-05-03 11:00:00', '2026-05-03 12:00:00', NULL, NULL, NULL, 2, 1, @now, @now), + (3, 'COMPLETED', '2026-05-04 12:00:00', '2026-05-04 13:00:00', '2026-05-04 14:00:00', NULL, NULL, 3, 1, @now, @now), + (4, 'COMPLETED', '2026-05-05 12:00:00', '2026-05-05 13:00:00', '2026-05-05 14:00:00', NULL, NULL, 4, 1, @now, @now), + (6, 'COMPLETED', '2026-05-07 12:00:00', '2026-05-07 13:00:00', '2026-05-07 14:00:00', NULL, NULL, 5, 1, @now, @now), + (7, 'COMPLETED', '2026-05-08 12:00:00', '2026-05-08 13:00:00', '2026-05-08 14:00:00', NULL, NULL, 1, 1, @now, @now), + (8, 'COMPLETED', '2026-05-09 12:00:00', '2026-05-09 13:00:00', '2026-05-09 14:00:00', NULL, NULL, 2, 1, @now, @now), + (9, 'COMPLETED', '2026-05-10 12:00:00', '2026-05-10 13:00:00', '2026-05-10 14:00:00', NULL, NULL, 3, 1, @now, @now), + (5, 'IN_PROGRESS', '2026-05-06 10:00:00', NULL, NULL, NULL, NULL, 4, 2, @now, @now); + +INSERT INTO member_mission_verification ( + verification_id, + verification_code, + status, + expires_at, + requested_at, + verified_at, + rejected_at, + member_mission_id, + verified_by_store_owner_id, + created_at, + updated_at +) VALUES + (1, '920394810', 'REQUESTED', '2026-05-03 12:10:00', '2026-05-03 12:00:00', NULL, NULL, 2, 1, @now, @now), + (2, '123456789', 'VERIFIED', '2026-05-04 13:10:00', '2026-05-04 13:00:00', '2026-05-04 14:00:00', NULL, 3, 1, @now, @now); + +INSERT INTO review ( + review_id, + content, + rating, + store_id, + member_id, + member_mission_id, + created_at, + updated_at +) VALUES + (1, '마라탕 국물이 진하고 맛있었습니다.', 4.5, 1, 1, 3, '2026-05-04 15:00:00', '2026-05-04 15:00:00'), + (2, '초밥이 신선하고 포인트도 받아서 좋았습니다.', 5.0, 3, 1, 4, '2026-05-05 15:00:00', '2026-05-05 15:00:00'), + (3, '무난했지만 다음에는 다른 메뉴를 먹어볼 것 같습니다.', 3.0, 1, 1, 7, '2026-05-08 15:00:00', '2026-05-08 15:00:00'), + (4, '김치찌개가 깔끔하고 양도 충분했습니다.', 4.8, 2, 1, 8, '2026-05-09 15:00:00', '2026-05-09 15:00:00'), + (5, '꿔바로우가 바삭해서 만족스러웠습니다.', 4.8, 1, 1, 9, '2026-05-10 15:00:00', '2026-05-10 15:00:00'); + +INSERT INTO review_photo ( + review_photo_id, + photo_url, + review_id, + created_at, + updated_at +) VALUES + (1, 'https://example.com/reviews/1-1.png', 1, @now, @now), + (2, 'https://example.com/reviews/1-2.png', 1, @now, @now), + (3, 'https://example.com/reviews/2-1.png', 2, @now, @now), + (4, 'https://example.com/reviews/4-1.png', 4, @now, @now), + (5, 'https://example.com/reviews/5-1.png', 5, @now, @now); + +INSERT INTO review_reply ( + review_reply_id, + content, + review_id, + store_owner_id, + created_at, + updated_at +) VALUES + (1, '방문해주셔서 감사합니다!', 1, 1, @now, @now); \ No newline at end of file From bbda9247b07b0a1be4366bf3e7782ea4bed77b47 Mon Sep 17 00:00:00 2001 From: chazicer Date: Thu, 14 May 2026 14:49:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?mission:=207=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=84=A4=EB=AA=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/chapter07/mission.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mission/chapter07/mission.md b/mission/chapter07/mission.md index cf234b8..c6ca079 100644 --- a/mission/chapter07/mission.md +++ b/mission/chapter07/mission.md @@ -1,23 +1,37 @@ # 미션 -우선 테스트용 db를 구성하기위해서 mock_seed.sql 파일을 이용해서 데이터를 삽입하였습니다. +우선 테스트용 DB를 구성하기 위해 `mock_seed.sql` 파일로 회원, 가게, 미션, 회원 미션, 리뷰 데이터를 삽입했습니다. ## 1. 내가 진행중인 미션 조회하기 +진행중인 회원 미션을 페이지 번호 기반으로 조회하도록 구현했습니다. +워크북 요구사항에 맞춰 Request Body로 `memberId`를 받고, `pageNumber`, `pageSize`를 Query Parameter로 받아 `PageRequest` 기반 페이징을 적용했습니다. + ![](https://img.boostad.site/2026/05/927d739ca0751cb4c98aea50423e25fd.png) ## 2. 내가 생성한 리뷰들 조회하기 +내가 작성한 리뷰 목록은 커서 기반으로 조회하도록 구현했습니다. +정렬 기준에 따라 ID 순 조회와 별점 순 조회를 나누었고, 다음 페이지 조회를 위해 `nextCursor`, `hasNext` 값을 응답에 포함했습니다. + ### 1. ID 순 커서 조회 +ID 기준 조회에서는 최신 리뷰가 먼저 보이도록 `reviewId` 내림차순으로 정렬했습니다. + ![](https://img.boostad.site/2026/05/ac0b33e9d2b22b523986f39c8f0612a7.png) ### 2. 별점 순 커서 조회 +별점 기준 조회에서는 `starRating` 내림차순으로 정렬하고, 같은 별점이 있을 경우 `reviewId`를 보조 정렬 기준으로 사용했습니다. +이를 통해 별점이 같은 리뷰가 있어도 커서 조회에서 중복이나 누락이 생기지 않도록 했습니다. + ![](https://img.boostad.site/2026/05/5e93dbef2298c74c395fea8eda868e4e.png) ## 3. Request Body가 있는 API에 검증 어노테이션 붙혀 검증하기 +Request Body DTO에 검증 어노테이션을 적용하고, Controller에서 `@Valid`를 사용해 요청값을 검증했습니다. +검증 실패 시 `GeneralExceptionAdvice`에서 `MethodArgumentNotValidException`을 처리해 공통 응답 형식으로 에러 메시지가 내려가도록 구현했습니다. + ![](https://img.boostad.site/2026/05/0cddbb0832c597d665e4e8869c643b95.png) --- From e6d2badb42e6caf8d675b6bbc2ccf7f4ff583643 Mon Sep 17 00:00:00 2001 From: chazicer Date: Mon, 18 May 2026 20:17:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?mission:=207=EC=A3=BC=EC=B0=A8=20=ED=94=BC?= =?UTF-8?q?=EC=96=B4=EB=A6=AC=EB=B7=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/chapter07/mission.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mission/chapter07/mission.md b/mission/chapter07/mission.md index c6ca079..d0bd334 100644 --- a/mission/chapter07/mission.md +++ b/mission/chapter07/mission.md @@ -36,3 +36,14 @@ Request Body DTO에 검증 어노테이션을 적용하고, Controller에서 `@V --- # 피어리뷰 + +https://github.com/UMC-Inha/10th_Spring_Practice_Mission/pull/41 + +--- + +![](https://img.boostad.site/{year}/{month}/{md5}.{extName}/20260518201541402.png) + +![](https://img.boostad.site/{year}/{month}/{md5}.{extName}/20260518201605686.png) + +![](https://img.boostad.site/{year}/{month}/{md5}.{extName}/20260518201624493.png) +