diff --git a/week09/keyword/keyword_09.md b/week09/keyword/keyword_09.md new file mode 100644 index 0000000..4509ca6 --- /dev/null +++ b/week09/keyword/keyword_09.md @@ -0,0 +1,189 @@ +- 객체 그래프 탐색 + + ### 1. 객체 그래프(Object Graph)란? + + 프로그램에서 객체들이 서로를 참조하며 형성하는 구조 전체를 말함. + + 예시: + + ```java + class Member { + Profile profile; + List orders; + } + + class Order { + Delivery delivery; + } + ``` + + `Member → orders → Order → delivery` + + 이렇게 연결된 모든 객체들의 네트워크가 객체 그래프. + + --- + + ### 2. 객체 그래프 탐색(Object Graph Traversal) + + 객체 그래프 탐색은 최초 객체에서 시작해 참조를 타고 들어가며 필요한 데이터를 ‘깊이’ 탐색하는 과정이다. + + 예: + + ```java + Member member = findMember(); + Delivery d = member.getOrders().get(0).getDelivery(); + ``` + + → Member에서 Orders로 + + → Orders에서 Order로 + + → Order에서 Delivery로 + + 이렇게 객체를 “따라 들어가는” 것이 그래프 탐색. + + --- + + ### 3 . JPA에서의 객체 그래프 탐색 핵심 개념 + + - 프록시 초기화 + - `member.getOrders()` 호출 시 Hibernate는 **프록시를 통해 필요할 때 로딩**함. + - FetchType.LAZY / EAGER + - **LAZY**: 접근하는 순간 쿼리 실행 + - **EAGER**: Member 로딩 시 Order도 같이 로딩 + + → 결국 탐색 과정이 *쿼리 실행 흐름*에 직접 영향. + + - N+1 문제 + + 객체 그래프를 잘못 순회하면 반복적으로 DB 조회 발생. + + ### 4. 한 줄 요약 + JPA에서 객체 그래프 탐색이란, 엔티티 간 연관관계를 통해 객체 필드를 따라가면서 데이터를 접근하는 것이며, 이 과정에서 로딩 전략(LAZY/EAGER)에 따라 SQL 쿼리가 실행되고 이는 성능, 순환 참조(무한 루프), N+1 문제 등 다양한 결과를 낳는다. + + +- RESTful API + + ## 1. RESTful API + + REST(Representational State Transfer) 는 웹 API 설계 스타일이고, 핵심은: + + 1. 리소스(Resource) 중심 + - “동사(행위)”가 아니라 “명사(대상)” 위주 설계 + - 예: + - ❌ `/createReview` + - ✅ `POST /reviews` (리뷰라는 리소스에 ‘행위’를 HTTP Method로 표현) + 2. HTTP Method로 의도 표현 + - `GET` : 조회 + - `POST` : 생성 + - `PUT` : 전체 수정 + - `PATCH` : 부분 수정 + - `DELETE`: 삭제 + 3. URI에는 ‘리소스’만, 행위는 넣지 말기 + - `/stores/{id}/reviews` ✅ + - `/getStoreReviews` ❌ + - `/stores/{id}/reviews/listAll` ❌ + 4. Stateless + - 서버는 클라이언트 상태를 세션으로 들고 있지 않고, + + 각 요청이 필요한 정보를 다 들고 오게 설계. + + ## 2. RESTful URI 설계 기본 원칙 + + ### 2-1. 명사, 복수형 + + - 보통 컬렉션은 복수형으로: + - `/stores` + - `/reviews` + - `/members` + + ### 2-2. 계층 구조 (계층 관계가 분명할 때만) + + - A에 종속된 B가 명확하면 중첩 리소스: + - `/stores/{storeId}/reviews` + - `/members/{memberId}/orders` + - 반대로, 리소스가 독립적이면 최상단으로: + - `/reviews/{reviewId}` + - `/members/{memberId}` + + ### 2-3. 필터링/검색은 쿼리 스트링 + + - `/reviews?storeId=1&rating=5&sort=latest` + + ## 3. 두 가지 URI 비교 + + > 가게의 리뷰 목록 조회 API + > + + ### 1) `GET /reviews` + + “리뷰 전체 컬렉션” 에 대한 엔드포인트. + + - 의미: “시스템에 있는 리뷰 전체를 조회하거나, 조건으로 필터링” + - 조건 넣고 싶으면: + - `GET /reviews?storeId=1` + - `GET /reviews?memberId=3` + - `GET /reviews?storeId=1&rating=5` + + ✔ 장점 + + - 리뷰라는 리소스를 최상위 객체로 취급 (독립성↑) + - 나중에 “유저가 쓴 모든 리뷰 조회” 같은 것도 같은 엔드포인트에서 해결 가능 + - 필터 조합이 많을수록 유연함 + + ✖ 단점 + + - “가게라는 컨텍스트 하의 리뷰”가 URI에서 바로 안 보임 + + (`/stores/{storeId}/reviews`에 비해 의미가 덜 직관적) + + --- + + ### 2) `GET /stores/{storeId}/reviews` + + 이건 “특정 가게에 소속된 리뷰들” 이라는 의미가 아주 명확한 중첩 리소스 패턴. + + - 의미: “이 가게의 리뷰 컬렉션” + - 예: + - `GET /stores/1/reviews` → 1번 가게의 리뷰 목록 + - 보통 같이 쓰는 패턴: + - `POST /stores/{storeId}/reviews` → 이 가게에 리뷰 하나 작성 + - `GET /stores/{storeId}/reviews/{reviewId}` → 필요하면 특정 리뷰에 접근 + + ✔ 장점 + + - 비즈니스 관점에서 “가게-리뷰” 관계가 URI에 드러남 + - 프론트 코드를 볼 때도: + + `/stores/1/reviews` ← “아 이건 1번 가게에 대한 리뷰들” 바로 이해 가능 + + - 권한/검증에서도 직관적: + - 예: `storeId`가 path에 항상 있으니까, 그 가게가 존재하는지 체크 후 리뷰 로직 처리. + + ✖ 단점 + + - 리뷰를 “전역적으로 다루는” API (예: 관리자 페이지)에서는 + + 또 다른 엔드포인트(`/reviews`)를 만들어야 할 수도 있음 + + --- + + ### 3) 선택 팁 + + 단순히 “RESTful이냐 / 아니냐” 문제보다는, + + > 리뷰라는 리소스를 “가게에 종속된 애”로 보느냐, + 아니면 “독립된 1급 리소스”로 보느냐 의 차이. + > + + 실전에서는 둘 다 같이 쓰는 경우가 많음: + + 1. 전역 컬렉션 + - `GET /reviews` + - admin이 전체 리뷰 관리할 때 + - 필터: `?storeId=1&memberId=3&rating=4` + 2. 가게 하위 리소스 + - `GET /stores/{storeId}/reviews` + - 유저가 “특정 가게 상세” 들어갔을 때 보여줄 리뷰 목록 + - `POST /stores/{storeId}/reviews` + - 가게 상세 페이지에서 “리뷰 작성하기” 눌렀을 때 \ No newline at end of file diff --git a/week09/mission/mission_09(1).png b/week09/mission/mission_09(1).png new file mode 100644 index 0000000..3dbc262 Binary files /dev/null and b/week09/mission/mission_09(1).png differ diff --git a/week09/mission/mission_09(2).png b/week09/mission/mission_09(2).png new file mode 100644 index 0000000..9c53e48 Binary files /dev/null and b/week09/mission/mission_09(2).png differ diff --git a/week09/mission/mission_09(3).png b/week09/mission/mission_09(3).png new file mode 100644 index 0000000..2bc8e00 Binary files /dev/null and b/week09/mission/mission_09(3).png differ diff --git a/week09/mission/mission_09(4).png b/week09/mission/mission_09(4).png new file mode 100644 index 0000000..706aa07 Binary files /dev/null and b/week09/mission/mission_09(4).png differ diff --git a/week09/mission/mission_09(5).png b/week09/mission/mission_09(5).png new file mode 100644 index 0000000..236d839 Binary files /dev/null and b/week09/mission/mission_09(5).png differ diff --git a/week09/mission/mission_09.md b/week09/mission/mission_09.md new file mode 100644 index 0000000..b9e701d --- /dev/null +++ b/week09/mission/mission_09.md @@ -0,0 +1,136 @@ +> **github 링크**: +> [Feat/Chapter8](https://github.com/choehyeonjin/UMC-9th-spring-study/commits/Feat/Chapter9) + +1. 가게의 리뷰 목록 조회 API (워크북 본문) + - 리뷰에 사용자 본명 말고 닉네임도 필요해 member 테이블에 nickname 컬럼을 추가했다. + - Swagger 결과 + + ![image.png](./mission_09(1).png) + +2. 내가 작성한 리뷰 목록 API + - 9주차 워크북 본문에서 실습한 내용이랑, 6주차 미션에서 “내가 작성한 리뷰 보기 API, QueryDSL로 구현하기” 내용이 겹쳤다. 화면은 똑같고 필터링만 다르며, 6주차 때 구현한 DTO가 화면 구성에 더 맞는 것 같아 미션 기록 1번 부분을 공통 리뷰 목록 조회 DTO, Converter로 리팩토링하였다. + + ```java + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public class ReviewResDTO { + + // 단건 조회용 + private Long reviewId; + private String storeName; + private Float rating; + private String content; + private LocalDateTime createdAt; + private String replyContent; + + // 공통 리뷰 목록 응답 DTO + @Builder + public record ReviewListDTO( + List reviewList, + Integer listSize, + Integer totalPage, + Long totalElements, + Boolean isFirst, + Boolean isLast + ) {} + + @Builder + public record ReviewItemDTO( + Long reviewId, + String storeName, + Float rating, + String content, + LocalDateTime createdAt, + String replyContent + ) {} + } + ``` + + - Swagger 결과 + + ![image.png](./mission_09(2).png) + +3. 특정 가게의 미션 목록 API + - Swagger 결과 + + ![image.png](./mission_09(3).png) + +4. 내가 진행 중인 미션 목록 API + - Swagger 결과 + + ![image.png](./mission_09(4).png) + + +5. 커스텀 어노테이션 + +- page를 쿼리 스트링으로 받음 → 커스텀 어노테이션 `@ValidPage`을 설정해 `PageValidator`가 내부에서 1 이상인지 검사 → 실패 시 INVALID_PAGE (COMMON400_3) 응답 + + ```java + // ReviewQueryController + @GetMapping("/reviews") + public ApiResponse<...> getReviews( + @RequestParam String storeName, + @RequestParam(defaultValue = "1") @ValidPage Integer page + ) { } + + // PageValidator + public class PageValidator implements ConstraintValidator { + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + + boolean isValid = value != null && value >= 1; + + if (!isValid) { + // 기본 메시지 제거 + context.disableDefaultConstraintViolation(); + // GeneralErrorCode의 메시지로 덮어쓰기 + context.buildConstraintViolationWithTemplate( + GeneralErrorCode.INVALID_PAGE.getMessage() + ) + .addConstraintViolation(); + } + + return isValid; + } + } + + // GeneralErrorCode + public enum GeneralErrorCode implements BaseErrorCode{ + + ... + INVALID_PAGE(HttpStatus.BAD_REQUEST, + "COMMON400_3", + "page는 1 이상이어야 합니다."), + ... + } + + // GeneralExceptionAdvice + + // 메서드 파라미터 검증 + @ExceptionHandler(jakarta.validation.ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + jakarta.validation.ConstraintViolationException ex + ) { + String message = ex.getConstraintViolations() + .iterator() + .next() + .getMessage(); + + // 기본: 일반 유효성 검증 실패 (VALID_FAIL) + GeneralErrorCode code = GeneralErrorCode.VALID_FAIL; + + // @ValidPage: INVALID_PAGE + if (message.equals(GeneralErrorCode.INVALID_PAGE.getMessage())) { + code = GeneralErrorCode.INVALID_PAGE; + } + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure(code, message)); + } + ``` + +- Swagger 결과 + - page가 0일 때 + + ![image.png](./mission_09(5).png) \ No newline at end of file