diff --git a/keyword/chapter08/keyword.md b/keyword/chapter08/keyword.md new file mode 100644 index 0000000..2f81900 --- /dev/null +++ b/keyword/chapter08/keyword.md @@ -0,0 +1,95 @@ +- java의 Exception 종류들 + + ### Error와 Exception 정리 + + --- + + 1. **Error** + - **JVM 내부 오류**를 나타냄 + - 메모리 부족 + - 스택 오버플로우 등 + - **회복 불가능**한 심각한 문제 + - 애플리케이션 코드에서 **처리하거나 잡지 않음** + + 1. **Exception** + - java.lang.Exception를 상속 + - 두 가지 종류로 나뉨: + + ### 2.1. Checked Exception + + - RuntimeException 을 제외한 Exception + - **컴파일 타임**에 처리(try–catch) 또는 선언(throws) 필수 + - 외부 I/O, 네트워크, 파일 입출력 등 **회복 가능**한 예외 상황에 사용 + - 예시: `IOException`, `ClassNotFoundException` + + ### 2.2. Unchecked Exception (RuntimeException) + + - RuntimeException을 상속 + - **처리나 선언 강제 없음** + - 프로그래밍 버그(널 참조, 잘못된 타입 변환 등) 나타냄 + - 스프링에서는 **기본 롤백 대상** + - 예시: `NullPointerException`, `IllegalArgumentException` + + + --- + + **정리** + + - **Error**: JVM 수준의 심각한 오류, 애플리케이션 코드에서 건드리지 않음 + - **Checked Exception**: 반드시 처리·선언, 회복 가능한 상황 + - **Unchecked Exception**: 자유롭게 던지고, 스프링 트랜잭션·글로벌 핸들러에서 처리 +- Custom 어노테이션(ExistCategory) + + ### 과정 + + 1. @ExistCategory 가 붙어 있으면, 클라이언트 요청이 컨트롤러에 도달하자마자 **Bean Validation** 단계에서 걸리고, + 2. 이때 발생하는 **MethodArgumentNotValidException**은 프로젝트의 @ControllAdvice 가 공통 400 에러로 매핑하고 + + ![image.png](attachment:03dfd103-058c-4522-9e75-63a989d44e89:image.png) + + 1. 실패한 필드의 메시지 텔플릿(FOOD_CATEGORY_NOT_FOUND)을 result 객체 안에 담아 응답한다. + + ![image.png](attachment:43dc953d-e146-4ec3-b7ba-9b0f869a2e00:image.png) + + ![image.png](attachment:924c1195-90e3-4597-a566-f356190e5157:image.png) + + ### Custom 어노테이션을 사용하는 이유 + + 1. **중복 로직 제거** + + 서비스나 컨트롤러에서 매번 try-catch 등의 에러 처리를 사용하는 대신 어노테이션으로 한 번에 처리하도록 해준다. + + 2. **표현의 일관성** + 3. **유지보수 편의성** + + 검증 로직을 한 곳에 모아 두면, 나중에 검사 기준이 바뀌어도 validator 클래스에서 로직을 수정하면 된다 + +- @Valid + - **@Valid** + - **용도**: @RequestBody의 DTO 바인딩 직후 Bean Validation을 실행 + - **사용처**: 파라미터 선언부(@RequestBody MyDTO myDTO) 또는 Custom 어노테이션 + - **예외 타입**: 검증 실패 시 → **MethodArgumentNotValidException** + - **@Validated** + - **용도**: 메서드 진입 전 AOP 방식으로 파라미터(경로·쿼리·폼 등) 제약조건을 검사 + - PathVariable, RequestParam 조사 + - **사용처:** Custom 어노테이션 + - **어디에 붙이나**: 컨트롤러(@RestController)나 서비스 클래스(@Service) 위 + - **예외 타입**: 검증 실패 시 → **ConstraintViolationException** + + ### 정리 + + | 대상 파라미터 | 바인딩 검증 어노테이션 | 필요 설정 | 예외 타입 | + | --- | --- | --- | --- | + | @RequestBody | @Valid | (컨트롤러에 @Valid만) | MethodArgumentNotValidException | + | @PathVariable, @RequestParam | (표준·커스텀) | 컨트롤러 또는 메서드에 @Validated | ConstraintViolationException | + + + ### 에러 비교 + + ![image.png](attachment:763e408a-9390-4468-b48c-5cd24fc8da37:image.png) + + ![image.png](attachment:6070a608-da52-4639-843a-5fe222cd0638:image.png) + + → 실제로 각각 ConstraintViolationException, MethodArgumentNotValidException 에러 부분이고 에러가 달라서 이와 같이 요청 결과도 다르게 처리된다. + + **notion에** 각 image 원본이 저장되어있습니다! diff --git a/keyword/chapter09/keyword.md b/keyword/chapter09/keyword.md new file mode 100644 index 0000000..de61852 --- /dev/null +++ b/keyword/chapter09/keyword.md @@ -0,0 +1,76 @@ +- Spring Data JPA의 Paging + + *Spring Data JPA는 **페이징**을 위해 두 가지 객체를 제공하는데 이것이 바로 Slice와 Page* + + **페이징** + + : 사용자가 어떠한 데이터를 요청했을 때, 전체 데이터 중 일부를 원하는 정렬 방식으로 보여주는 방식 + + **페이징은 적용 방법** + + 페이징 기법 구현을 위해 기본적으로 알아야 하는 파라미터들이 있습니다. + + - **page** : 페이징 기법이 적용되었을 때, 원하는 페이지 + - **size** : 해당 페이지에 담을 데이터 개수 + - **sort** : 정렬 기준 + + 이 파라미터들을 Pageable 구현체에 담아 페이징을 설정 + + - Page + + Pageble을 파라미터로하여 가져온 결과물은 `Page` 형태로 반환 되며, Page를 사용한다면 대부분 다수의 row를 가져오기 때문에 `Page>`의 형태로 반환을 한다. 이 페이지 객체에는 Pagination을 구현할 때 사용하면 좋은 메서드가 있으며 이는 다음과 같다. + + ### getTotalElements() + + 쿼리 결과물의 전체 데이터 개수이다. 즉, Pageable에 의해 `limit`키워드가 조건으로 들어가지 않는 쿼리 **결과의 수** 인데, 주의해야 할 점은 쿼리 **결과의 갯수만** 가져오지 **전체 데이터를 가져오지 않는다**는 점이다. + + 이 메서드는 게시판 기능 사용자에게 전체 데이터 개수를 알려주는 등에 사용하기 좋다. + + ### getTotalPages() + + 쿼리를 통해 가져온 요소들을 size크기에 맞춰 페이징하였을 때 나오는 총 페이지의 갯수이다. + + 이를 활용해 쉽게 페이지 버튼의 생성이 가능하다. + + ### getSize() + + 쿼리를 수행한 전체 데이터에 대해 일정 수 만큼 나눠 페이지를 구성하는데, 이 일정 수의 크기이다. + + ### getNumber() + + 요소를 가져온 페이지의 번호를 의미한다. + + ### getNumberOfElements() + + 페이지에 존재하는 요소의 개수이다. 최대 size의 수 만큼 나올 수 있다. + + - Slice + - **다음 슬라이스 존재 여부** + - 요청한 페이지 크기보다 `+1` 만큼 더 조회해 보고, 그 결과로 `hasNext()` 판단 + - **메서드** + - `List getContent()` + - `boolean hasNext()` + - `boolean hasPrevious()` + - `Pageable getPageable()`, `Pageable nextPageable()` + - **장점** + - **count 쿼리 생략** → 빠른 응답 + - **단점** + - 전체 건수·전체 페이지 수 정보가 필요할 때 사용 불가 +- 객체 그래프 탐색 + - **객체(node)**: JPA 엔티티 클래스 하나하나 + - **연관(edge)**: @OneToMany, @ManyToOne와 같은 필드 매핑 + - 이 둘이 연결되어 마치 **트리(tree)** 또는 **네트워크(graph)**처럼 이루어진 구조 + + ![image.png](attachment:7485b147-b701-419d-99b6-6da4e8077df9:image.png) + + **객체 그래프 탐색의 요소** + + - **로딩 전략 (FetchType)** + - **EAGER**: 연관된 엔티티를 즉시 함께 조회 + - **LAZY**: 실제 사용할 때(필드 호출 시) 조회 + - **영속성 컨텍스트 (Persistence Context)** + - 한번 로드된 엔티티는 1차 캐시에 보관 + - 동일 트랜잭션 내에 다시 조회하면 1차 캐시에서 반환 → 중복 쿼리 방지 + - **프록시 (Proxy)** + - LAZY 연관관계일 때, 실제 엔티티 대신 가짜 객체를 미리 만들어 두고 + - 접근 시점에 실제 데이터를 조회 diff --git a/keyword/chapter10/keyword.md b/keyword/chapter10/keyword.md new file mode 100644 index 0000000..3bb59cd --- /dev/null +++ b/keyword/chapter10/keyword.md @@ -0,0 +1,21 @@ +- **Spring Security** + + 애플리케이션의 보안(인증 및 인가)을 담당하는 프레임워크. 복잡한 보안 로직을 간편하게 구현할 수 있도록 돕는다. + +- **인증(Authentication)과 인가(Authorization)** + + 인증 : 누가 누구인지 확인하는 절차 + + 인가 : 인증된 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 확인하는 절차 + +- **세션과 토큰** + + 세션 : 클라이언트와 서버 간의 연결이 지속되는 동안 상태를 유지하는 방법, 주로 서버의 메모리에 저장된다 + + 토큰 : 사용자의 인증 정보를 담고 있는 문자열. 세션 방식과 달리 서버가 상태를 저장하지 않는(Stateless) 방식 + +- **액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)** + + 액세스 토큰 : 리소스에 접근하기 위한 권한을 부여하는 토큰. 만료 기간을 짧게 설정하여 탈취 시의 위험을 줄이는 것이 일반적이다. + + 리프레시 토큰 : 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급받기 위해 사용되는 토큰. 액세스 토큰보다 만료 기간을 길게 설정한다. diff --git a/mission/chapter08/mission.md b/mission/chapter08/mission.md new file mode 100644 index 0000000..8997b39 --- /dev/null +++ b/mission/chapter08/mission.md @@ -0,0 +1,406 @@ +**코드 양이 많아 핵심 코드만 기록했습니다.** + +1. **특정 가게 추가하기 API** + +url : /restaurants/location/{location_id} + +- RestaurantRestController + +```java +@RestController +@RequiredArgsConstructor +@RequestMapping("/restaurants") +@Validated +public class RestaurantRestController { + + private final RestaurantCommandService restaurantCommandService; + + @PostMapping("/location/{locationId}") + public ApiResponse createRestaurant( + @RequestBody @Valid RestaurantRequestDTO.createRestaurantDTO request, @ExistLocation @PathVariable Long locationId) { + + restaurantCommandService.createRestaurant(request, locationId); + return ApiResponse.onSuccess("식당이 등록되었습니다"); + + } + +} +``` + +- RestaurantCommandServiceImpl → 이미지, 메뉴를 받는 로직도 구현했습니다 + +```java +@Service +@RequiredArgsConstructor +@Transactional +public class RestaurantCommandServiceImpl implements RestaurantCommandService{ + + private final RestaurantRepository restaurantRepository; + private final LocationRepository locationRepository; + + private final RestaurantFoodRepository restaurantFoodRepository; + private final RestaurantImageRepository restaurantImageRepository; + + @Override + @Transactional + public void createRestaurant(RestaurantRequestDTO.createRestaurantDTO request, Long locationId) { + + Location location = locationRepository.findById(locationId).orElseThrow( + () -> new LocationHandler(ErrorStatus.LOCATION_NOT_FOUND) + ); + + Restaurant restaurant = RestaurantConverter.toRestaurant(request, location); + restaurantRepository.save(restaurant); + + // foodList → RestaurantFood 리스트 변환 + List foods = request.getFoodList().stream() + .map(foodName -> RestaurantFood.builder() + .restaurant(restaurant) + .foodName(foodName) + .build() + ) + .collect(Collectors.toList()); + + // 한 번에 저장 + restaurantFoodRepository.saveAll(foods); + + // imageList -> RestaurantImage 리스트 변환 + List images = request.getImageList().stream() + .map(image -> RestaurantImage.builder() + .restaurant(restaurant) + .image(image) + .build() + ) + .collect(Collectors.toList()); + + // 한 번에 저장 + restaurantImageRepository.saveAll(images); + + } +} +``` + +- ExistLotation + +```java +@Documented +@Constraint(validatedBy = LocationExistValidator.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistLocation { + + String message() default "해당하는 위치 정보가 존재하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} +``` + +- LocationExistValidator + +```java +@Component +@RequiredArgsConstructor +public class LocationExistValidator implements ConstraintValidator { + + private final LocationRepository locationRepository; + + @Override + public void initialize(ExistLocation constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Long aLong, ConstraintValidatorContext context) { + + boolean isValid; + + isValid = locationRepository.existsById(aLong); + + if (!isValid) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus.LOCATION_NOT_FOUND.toString()).addConstraintViolation(); + } + + return isValid; + } +} +``` + +1. **가게에 리뷰 추가하기** +- ReviewRestController + +```java +@RestController +@RequiredArgsConstructor +@RequestMapping("/reviews") +@Validated +public class ReviewRestController { + + private final ReviewCommandService reviewCommandService; + + @PostMapping("/{restaurantId}/reviews") + public ApiResponse createReview( + @RequestBody @Valid ReviewRequestDTO.createReviewDTO request, @ExistLocation @PathVariable Long restaurantId) { + + reviewCommandService.createReview(request, restaurantId); + return ApiResponse.onSuccess("리뷰가 등록되었습니다"); + + } + +``` + +- ReviewCommandSeviceImpl → 이미지를 받는 로직도 구현했습니다 + +```java +@Service +@RequiredArgsConstructor +@Transactional +public class ReviewCommandServiceImpl implements ReviewCommandService{ + + private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; + + private final RestaurantRepository restaurantRepository; + + private final UserRepository userRepository; + + @Override + public void createReview(ReviewRequestDTO.createReviewDTO request, Long restaurantId) { + + // @Validated로 앞에서 미리 유효성 검사 진행 + Restaurant restaurant = restaurantRepository.findById(restaurantId).orElseThrow( + () -> new GeneralException(ErrorStatus.RESTAURANT_NOT_FOUND) + ); + + // TODO 하드 코딩한 값으로 추후, 값 변경하기 + User user = userRepository.findById(1L).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + Review review = ReviewConverter.toReview(request, restaurant, user); + + reviewRepository.save(review); + + // imageList -> RestaurantImage 리스트 변환 + List images = request.getImageList().stream() + .map(image -> ReviewImage.builder() + .review(review) + .image(image) + .build() + ) + .collect(Collectors.toList()); + + // 한 번에 저장 + reviewImageRepository.saveAll(images); + } +} +``` + +- ExistRestaurant + +```java +@Documented +@Constraint(validatedBy = RestaurantExistValidator.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistRestaurant { + + String message() default "해당하는 식당이 존재하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} +``` + +- RestaurantExistValidator + +```java +@Component +@RequiredArgsConstructor +public class RestaurantExistValidator implements ConstraintValidator { + + private final RestaurantRepository restaurantRepository; + + @Override + public void initialize(ExistRestaurant constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Long aLong, ConstraintValidatorContext context) { + + boolean isValid; + + if(aLong == null) { + isValid = false; + }else{ + isValid = restaurantRepository.existsById(aLong); + } + + if (!isValid) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus.RESTAURANT_NOT_FOUND.toString()).addConstraintViolation(); + } + + return isValid; + } +} +``` + +1. 가게에 미션 추가하기 +- MissionRestController + +```java +@RestController +@RequiredArgsConstructor +@Validated +public class MissionRestController { + + private final MissionCommandService missionCommandService; + + @PostMapping("/restaurants/{restaurantId}/missions") + public ApiResponse createMission( + @RequestBody @Valid MissionRequestDTO.createMissionDTO request, @ExistRestaurant @PathVariable Long restaurantId) { + + missionCommandService.createMission(request, restaurantId); + return ApiResponse.onSuccess("미션이 등록되었습니다"); + + } + +} +``` + +- MissionCommandServiceImpl + +```java +@Service +@RequiredArgsConstructor +@Transactional +public class MissionCommandServiceImpl implements MissionCommandService { + + private final MissionRepository missionRepository; + private final RestaurantRepository restaurantRepository; + + @Override + public void createMission(MissionRequestDTO.createMissionDTO request, Long restaurantId) { + + // @Validated로 앞에서 미리 유효성 검사 진행 + Restaurant restaurant = restaurantRepository.findById(restaurantId).orElseThrow( + () -> new GeneralException(ErrorStatus.RESTAURANT_NOT_FOUND) + ); + + Mission mission = MissionConverter.toMission(request, restaurant); + + missionRepository.save(mission); + + } +} + +``` + +1. **가게의 미션을 도전 중인 미션에 추가(미션 도전하기)** + +- UserRestController + +```java + + // TODO 하드코딩 한 User 값을 받도록 바꿔주기 + @PostMapping("/missions/{missionId}") + public ApiResponse challengeMission( @ExistMission @PathVariable Long missionId){ + + userCommandService.challengeMission(missionId); + return ApiResponse.onSuccess("해당 미션의 도전을 시작하셨습니다"); + } +``` + +- UserCommandServiceImpl + +```java + // TODO 하드 코딩한 user 값 변경해주기 + @Override + public void challengeMission(Long missionId) { + + User user = userRepository.findById(1L).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + + Mission mission = missionRepository.findById(missionId).orElseThrow( + () -> new GeneralException(ErrorStatus.MISSION_NOT_FOUND) + ); + + UserMission userMission = UserMission.builder() + .isCompleted(Boolean.FALSE) + .user(user) + .mission(mission) + .build(); + + userMissionRepository.save(userMission); + + } +``` + +- ExsitMission + +```java +@Documented +@Constraint(validatedBy = MissionExistValidator.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistMission { + + String message() default "해당하는 미션이 존재하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} +``` + +- MissionExistValidator + +```java +@Component +@RequiredArgsConstructor +public class MissionExistValidator implements ConstraintValidator { + + private final MissionRepository missionRepository; + private final UserMissionRepository userMissionRepository; + private final UserRepository userRepository; + + @Override + public void initialize(ExistMission constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Long aLong, ConstraintValidatorContext context) { + + boolean isValid = false; + Mission mission = missionRepository.findById(aLong).orElse(null); + + // 미션이 존재하지 않을 때 + if (mission == null) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus.MISSION_NOT_FOUND.toString()).addConstraintViolation(); + return isValid; + } + + User user = userRepository.findById(1L).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + Boolean existsUserMissionByUserAndMission = userMissionRepository.existsUserMissionByUserAndMission(user, mission); + + // 이미 수행 중인 미션일 때 + if(existsUserMissionByUserAndMission){ + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus.MISSION_ALREADY_EXIST.toString()).addConstraintViolation(); + return isValid; + } + + isValid = true; + return isValid; + } +} +``` + +### 미션 수행 소감 + +@Valid 애노테이션은 기존에 NotBlank나 Min 같은 표준 검증 애노테이션을 거르는 용도로만 사용했지만, 이번에 커스텀 애노테이션을 도입하면서 중복 코드를 제거하고 유지보수성을 크게 높일 수 있다는 것을 깨달았습니다. validation 패키지를 구성하고 검증 로직이 어떻게 동작하는지 분석하는 데 시간이 걸렸지만, 그 과정을 통해 많은 것을 배우고 메서드 예외 처리와 제약 조건 예외 처리 방식도 다시 한 번 복습할 수 있었습니다. + +추가로 리팩터링이 필요한 부분으로는, 현재 사용자 식별자를 하드코딩해 두었다는 점이 있습니다. 이를 해결하기 위해 미리 구현해 둔 ExistUser 애노테이션을 활용해 사용자 식별 값을 외부에서 주입받도록 수정할 계획입니다. 또한 미션 유효성 검사 시 사용자 미션 데이터를 검증할 때는 토큰 기반 인증 방식을 도입하여 관련 유틸리티를 활용하는 방식으로 로직을 보완할 예정입니다. diff --git a/mission/chapter09/mission.md b/mission/chapter09/mission.md new file mode 100644 index 0000000..18929c6 --- /dev/null +++ b/mission/chapter09/mission.md @@ -0,0 +1,298 @@ +- **미션 기록** + 1. 내가 작성한 리뷰 목록 + + Controller + + ```java + @GetMapping("/{restaurantId}") + @Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "restaurantId", description = "가게의 아이디, path variable 입니다!") + }) + public ApiResponse getReviewList(@ExistRestaurant @PathVariable(name = "restaurantId") Long restaurantId, @CheckPage @RequestParam(name = "page") Integer page){ + Page reviewList = reviewQueryService.getReviewList(restaurantId,page); + return ApiResponse.onSuccess(ReviewConverter.toReviewPreViewListDTO(reviewList)); + } + ``` + + Service + + ```java + @Override + public Page getReviewListByUserId(Long userId, Integer page) { + + User user = userRepository.findById(userId).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + + Page reviewPage = reviewRepository.findAllByUser(user, PageRequest.of(page, 10)); + + // 시니어 미션을 위한 Slice 객체 생성 + Slice reviewSlice = reviewRepository.findReviewsByUser(user, PageRequest.of(page, 10)); + + System.out.println(reviewSlice); + System.out.println(reviewPage); + + return reviewPage; + + } + ``` + + Repository + + ```java + public interface ReviewRepository extends JpaRepository { + + Page findAllByRestaurant(Restaurant restaurant, Pageable pageable); + + Page findAllByUser(User user, Pageable pageable); + Slice findReviewsByUser(User user, Pageable pageable); + + } + + ``` + + 2. 특정 가게의 미션 목록 + + Controller + + ```java + @GetMapping("/restaurants/{restaurantId}/missions") + @Operation(summary = "특정 가게의 미션 목록 조회 API",description = "특정 가게의 미션들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "restaurantId", description = "가게의 아이디, path variable 입니다!") + }) + public ApiResponse getMissionListByRestaurantId( + @ExistRestaurant @PathVariable(name = "restaurantId") Long restaurantId, + @CheckPage @RequestParam(name = "page") Integer page) { + + Page missionListByRestaurantId = missionQueryService.getMissionListByRestaurantId(restaurantId, page); + return ApiResponse.onSuccess(MissionConverter.toMissionListDTO(missionListByRestaurantId)); + } + ``` + + + Service + + ```java + @Override + public Page getMissionListByRestaurantId(Long restaurantId, Integer page) { + + Restaurant restaurant = restaurantRepository.findById(restaurantId).orElseThrow( + () -> new GeneralException(ErrorStatus.RESTAURANT_NOT_FOUND) + ); + + Page missionPage = missionRepository.findAllByRestaurant(restaurant, PageRequest.of(page, 10)); + return missionPage; + } + ``` + + Repository + + ```java + public interface MissionRepository extends JpaRepository { + + Page findAllByRestaurant(Restaurant restaurant, Pageable pageable); + + } + ``` + + 1. 내가 진행 중인 미션 목록 + + Controller + + ```java + @GetMapping("/missions") + @Operation(summary = "내가 진행 중인 미션 목록 조회 API",description = "내가 진행 중인 미션들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + public ApiResponse getMyMissionList( + @CheckPage @RequestParam(name = "page") Integer page) { + + Long userId = 1L; // TODO. SecurityUtils 구현 후 토큰에서 userID 가져오기 + + Page userMissionList = missionQueryService.getMissionListByUserId(userId, page); + return ApiResponse.onSuccess(MissionConverter.toUserMissionListDTO(userMissionList)); + } + ``` + + Service + + ```java + @Override + public Page getMissionListByUserId(Long userId, Integer page) { + + User user = userRepository.findById(userId).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + + Page userMissionPage = userMissionRepository.findOngoingByUserId(userId, PageRequest.of(page, 10)); + + return userMissionPage; + } + ``` + + Repository + + ```java + @Query( + value = "SELECT um " + + "FROM UserMission um " + + "JOIN FETCH um.mission m " + + "WHERE um.user.id = :userId " + + " AND um.isCompleted = false", + countQuery = "SELECT COUNT(um) " + + "FROM UserMission um " + + "WHERE um.user.id = :userId " + + " AND um.isCompleted = false" + ) + Page findOngoingByUserId( + @Param("userId") Long userId, + Pageable pageable + ); + ``` + + 2. 진행중인 미션 진행 완료로 바꾸기 + + Controller + + ```java + @PatchMapping("/users/{userId}/missions/{missionId}") + @Operation(summary = "진행 중인 미션 진행 완료로 바꾸기 API",description = "해당 mission의 상태를 진행 완료로 변경하는 API이다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + public ApiResponse changeMissionStatus( + @ExistUser @PathVariable(name = "userId") Long userId, + @ExistMission @PathVariable(name = "missionId") Long missionId) { + + missionCommandService.changeMissionStatus(userId, missionId); + return ApiResponse.onSuccess("미션의 상태가 변경되었습니다."); + } + ``` + + Service + + ```java + @Override + public void changeMissionStatus(Long userId, Long missionId) { + + Mission mission = missionRepository.findById(missionId).orElseThrow( + () -> new GeneralException(ErrorStatus.MISSION_NOT_FOUND) + ); + + User user = userRepository.findById(userId).orElseThrow( + () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) + ); + + UserMission userMission = userMissionRepository.findByUserAndMission(user, mission).orElseThrow( + () -> new GeneralException(ErrorStatus.USERMISSION_NOT_FOUND) + ); + + if(userMission.getIsCompleted()){ + userMission.changeIsCompleted(false); + }else{ + userMission.changeIsCompleted(true); + } + + userMissionRepository.save(userMission); + } + ``` + + Repository + + ------ + - **시니어 미션** + + # 시니어 미션 + +**미션 목표:** + +- **Slice**와 **Page**의 구조적 차이와 적용 시점 파악하기 +- **for**와 **stream**의 **성능, 가독성, 유지보수성**을 직접 비교 + +**미션 상세 내용:** + +### 1️⃣ Page와 Slice가 각각 어떻게 출력값이 나오는 지 알아보기 + +- 기존 미션에 나왔던 pagination에서 Page와 Slice로 바꾸어 각각 출력값 비교 + - 다른 API를 하나 만들어도 됩니다. + + ![image.png](attachment:71a7fc10-eb51-4c81-879e-b095c8cd9ce7:image.png) + + +### 2️⃣ Page, Slice 각각 적용 시 장단점 파악하기 + +- 찾아본 원리를 토대로 서로의 장단점 적어보기 + +| 구분 | Page | Slice | +| --- | --- | --- | +| **전체 정보** | 전체 요소 수(totalElements), 전체 페이지 수(totalPages) 제공 | 전체 정보 제공 안 함 | +| **추가 쿼리** | 전체 개수(count) 조회 위한 추가 `COUNT` 쿼리 실행 | `COUNT` 쿼리 없이 size + 1만 조회해 “다음 페이지 존재 여부” 판단 | +| **성능** | 대용량 테이블에서 `COUNT` 쿼리로 인한 부하 발생 가능 | `COUNT` 쿼리 생략으로 상대적으로 빠름 | +| **필요 정보** | 사용자에게 “총 몇 건, 총 몇 페이지”를 보여줄 때 | 전체 개수는 필요 없고 “더 가져올 데이터가 있는지만” 알면 될 때 | + +### 3️⃣ 언제 적용하면 좋을 지 파악하기 + +- 장단점을 토대로 언제 Page, Slice를 언제 적용하면 좋을 지 적기 + - **page을 사용하면 좋은 경우** + - 화면이나 API 응답에 **전체 데이터 건수와 전체 페이지 수**를 반드시 표시해야 할 때 + - “첫 페이지 / 마지막 페이지” 네비게이션, 특정 페이지로 바로 이동 같은 **정교한 페이징 UI**를 구현할 때 + - 검색 결과 전체 개수를 활용해 **통계나 보고서**를 작성해야 할 때 + - **Slice를 사용하면 좋은 경우** + - **전체 개수 불필요**, 단순히 “다음 데이터가 더 있는지만” 판단하면 될 때 + - **쿼리 성능**이 중요하고 불필요한 count 쿼리를 줄여야 할 때 + +### 1️⃣ for과 stream이 어떻게 작동되는지 파악하기 + +- 동일한 연산(sum, filter 등)을 for문, stream 각각으로 구현해보기 + - 가능하다면 많은 양의 데이터(10만 건)를 넣고 실행하여 시간 재보기 (시니어 포함 필수 아님) +- 인터넷 검색을 통해 둘의 차이 파악하기 + + **for** + + for문은 개발자가 직접 인덱스나 이터레이터를 제어하면서 루프를 수행하는 **외부 반복(external iteration)** 방식 + + +**stream** + +Stream은 데이터 소스(Collection 등)에서 **“무엇을 할 것인가(what)”**만 선언하면, 내부에서 반복과 처리를 해 주는 **내부 반복(internal iteration)** 방식 + +https://nicednjsdud.github.io/java/java-java-language-stream-vs-for/?utm_source=chatgpt.com + +### 2️⃣ for, stream 각각 적용 시 장단점 파악하기 + +- 찾아본 원리를 토대로 서로의 장단점 적어보기 + + 속도의 경우에서는 for 문이 stream 보다 대체적으로 빠른 경우가 많다. + + 한편 stream의 장점은 병렬 처리이다. + + 데이터를 **병렬 처리**해야 할 경우, for문은 직접 스레드를 생성·관리해야 하지만, Stream API는 parallesStream 등의 호출만으로 내부에서 스레드를 분할·관리해 준다. + + 따라서 대규모 데이터셋을 여러 코어로 분할해 연산할 때는 stream이 효율적이다. + + +### 3️⃣ 언제 적용하면 좋을 지 파악하기 + +- 장단점을 토대로 언제 적용하면 좋을 지 파악하기 + - 가독성 측면, 성능 측면 등 diff --git a/mission/chapter10/.DS_Store b/mission/chapter10/.DS_Store new file mode 100644 index 0000000..aca9ede Binary files /dev/null and b/mission/chapter10/.DS_Store differ diff --git a/mission/chapter10/info.png b/mission/chapter10/info.png new file mode 100644 index 0000000..e7ef140 Binary files /dev/null and b/mission/chapter10/info.png differ diff --git a/mission/chapter10/join.png b/mission/chapter10/join.png new file mode 100644 index 0000000..a05592a Binary files /dev/null and b/mission/chapter10/join.png differ diff --git a/mission/chapter10/login.png b/mission/chapter10/login.png new file mode 100644 index 0000000..4a73503 Binary files /dev/null and b/mission/chapter10/login.png differ