From 5b1e4be46704d7c0b1ba69be0f06890c8c9118bb Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 10 May 2026 21:48:57 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat=20:=20MissionReqDTO=20=EB=82=B4=20Cr?= =?UTF-8?q?eateMission=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/mission/dto/MissionReqDTO.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java index e33508b9..22461e17 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java @@ -1,4 +1,13 @@ package com.example.umc10th.domain.mission.dto; +import java.time.LocalDate; + public class MissionReqDTO { + + // 가게 미션 생성 + public record CreateMission( + String title, + LocalDate deadline, + Integer reward + ) {} } From f3d178d972260bb0974856f144c4d2ee130219f2 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 10 May 2026 22:02:57 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat=20:=20MissionResDTO=20=EB=82=B4=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Pagination=20=EB=A0=88=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/mission/dto/MissionResDTO.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java index 8d6ee79e..d581eb32 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java @@ -3,9 +3,11 @@ import lombok.Builder; import java.time.LocalDate; +import java.util.List; public class MissionResDTO { + // 가게 내 미션 조회 @Builder public record MissionInfo( Long id, @@ -13,6 +15,13 @@ public record MissionInfo( String title, LocalDate deadline, Integer reward - ) { - } + ) {} + + // 페이지네이션 툴 + @Builder + public record Pagination( + List data, + Integer pageNumber, + Integer pageSize + ){} } \ No newline at end of file From 2640c2971090527fec8b1cf140995feb791526bb Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 17:41:09 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20RequestBody=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mission/dto/MissionReqDTO.java | 12 ++++++++++- .../handler/GeneralExceptionAdvice.java | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java index 22461e17..22959316 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java @@ -1,13 +1,23 @@ package com.example.umc10th.domain.mission.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + import java.time.LocalDate; public class MissionReqDTO { // 가게 미션 생성 public record CreateMission( + @NotBlank(message = "미션 제목은 필수입니다.") String title, + + @NotNull(message = "마감기한은 필수입니다.") LocalDate deadline, + + @NotNull(message = "보상 포인트는 필수입니다.") + @PositiveOrZero(message = "보상 포인트는 0 이상이어야 합니다.") Integer reward ) {} -} +} \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java b/Jinyong/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java index e8fbbd3e..fe6c6723 100644 --- a/Jinyong/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/Jinyong/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -5,9 +5,13 @@ import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; import com.example.umc10th.global.apiPayload.exception.ProjectException; 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,23 @@ public ResponseEntity> handleMemberException( .body(ApiResponse.onFailure(errorCode, null)); } + // @Valid 검증 실패 예외 처리 + @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( From b8b1f3679731caa52f8aa7b103e9549ed3004678 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 17:41:59 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EA=B0=80=EA=B2=8C=EB=B3=84=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mission/code/MissionSuccessCode.java | 5 +- .../mission/controller/MissionController.java | 46 ++++++-- .../mission/converter/MissionConverter.java | 57 ++++++++- .../domain/mission/dto/MissionResDTO.java | 16 ++- .../mission/repository/MissionRepository.java | 8 +- .../mission/service/MissionService.java | 108 ++++++++++++++++-- 6 files changed, 217 insertions(+), 23 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/code/MissionSuccessCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/code/MissionSuccessCode.java index bf61f50c..0413dbcb 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/code/MissionSuccessCode.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/code/MissionSuccessCode.java @@ -9,8 +9,11 @@ @AllArgsConstructor public enum MissionSuccessCode implements BaseSuccessCode { - OK(HttpStatus.OK, + CREATED(HttpStatus.OK, "MISSION200_1", + "성공적으로 미션을 생성했습니다."), + OK(HttpStatus.OK, + "MISSION200_2", "성공적으로 미션을 조회했습니다."); private final HttpStatus status; diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index 5c232fd2..5a4a4186 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -1,15 +1,15 @@ package com.example.umc10th.domain.mission.controller; import com.example.umc10th.domain.mission.code.MissionSuccessCode; +import com.example.umc10th.domain.mission.dto.MissionReqDTO; import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.mission.service.MissionService; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api") @@ -17,11 +17,43 @@ public class MissionController { private final MissionService missionService; - @GetMapping("/v1/missions") - public ApiResponse> getMissionList( - @RequestParam Long storeId + // 가게 미션 생성(POST) + @PostMapping("/v1/stores/{storeId}/missions") + public ApiResponse createMission( + @PathVariable Long storeId, + @RequestBody @Valid MissionReqDTO.CreateMission dto + ) { + missionService.createMission(storeId, dto); + + BaseSuccessCode code = MissionSuccessCode.CREATED; + return ApiResponse.onSuccess(code, null); + } + + // 가게 내 미션 목록 조회 조회(GET) + @GetMapping("/v1/stores/{storeId}/missions") + public ApiResponse> getMissionList( + @PathVariable Long storeId, + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam(required = false) String sort ) { BaseSuccessCode code = MissionSuccessCode.OK; - return ApiResponse.onSuccess(code, missionService.getMissionList(storeId)); + return ApiResponse.onSuccess(code, missionService.getMissionList(storeId, pageSize, pageNumber, sort)); + } + + // 내가 진행 중인 미션 조회 + @GetMapping("/v1/users/{memberId}/missions/ongoing") + public ApiResponse> getMyOngoingMissions( + @PathVariable Long memberId, + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam(required = false) String sort + ) { + BaseSuccessCode code = MissionSuccessCode.OK; + + return ApiResponse.onSuccess( + code, + missionService.getMyOngoingMissions(memberId, pageSize, pageNumber, sort) + ); } -} \ No newline at end of file +} diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java index 60da9f6f..1140acd9 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java @@ -1,17 +1,68 @@ package com.example.umc10th.domain.mission.converter; +import com.example.umc10th.domain.mission.dto.MissionReqDTO; import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.mission.entity.Mission; +import com.example.umc10th.domain.mission.entity.mapping.MemberMission; +import com.example.umc10th.domain.store.entity.Store; + +import java.util.List; public class MissionConverter { - public static MissionResDTO.MissionInfo toMissionInfo(Mission mission) { - return MissionResDTO.MissionInfo.builder() + // 가게 미션 생성 + public static Mission toMission( + Store store, + MissionReqDTO.CreateMission dto + ) { + return Mission.builder() + .store(store) + .title(dto.title()) + .reward(dto.reward()) + .deadline(dto.deadline()) + .build(); + } + + // 가격 내 미션 조회 + public static MissionResDTO.GetMission toGetMission( + Mission mission + ){ + return MissionResDTO.GetMission.builder() .id(mission.getId()) .storeId(mission.getStore().getId()) .title(mission.getTitle()) + .reward(mission.getReward()) + .deadline(mission.getDeadline()) + .build(); + } + + // 내가 진행 중인 미션 조회 + public static MissionResDTO.MyOngoingMission toMyOngoingMission( + MemberMission memberMission + ) { + Mission mission = memberMission.getMission(); + + return MissionResDTO.MyOngoingMission.builder() + .memberMissionId(memberMission.getId()) + .missionId(mission.getId()) + .storeId(mission.getStore().getId()) + .title(mission.getTitle()) .deadline(mission.getDeadline()) .reward(mission.getReward()) + .status(memberMission.getStatus()) + .build(); + } + + // 페이지네이션 툴 생성 + public static MissionResDTO.Pagination toPagination( + List data, + Integer pageNumber, + Integer pageSize + ){ + return MissionResDTO.Pagination.builder() + .data(data) + .pageNumber(pageNumber) + .pageSize(pageSize) .build(); } -} \ No newline at end of file +} diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java index d581eb32..d81c1236 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java @@ -1,5 +1,6 @@ package com.example.umc10th.domain.mission.dto; +import com.example.umc10th.domain.mission.entity.MemberMissionStatus; import lombok.Builder; import java.time.LocalDate; @@ -9,7 +10,7 @@ public class MissionResDTO { // 가게 내 미션 조회 @Builder - public record MissionInfo( + public record GetMission( Long id, Long storeId, String title, @@ -17,6 +18,18 @@ public record MissionInfo( Integer reward ) {} + // 내가 진행 중인 미션 조회 + @Builder + public record MyOngoingMission( + Long memberMissionId, + Long missionId, + Long storeId, + String title, + LocalDate deadline, + Integer reward, + MemberMissionStatus status + ) {} + // 페이지네이션 툴 @Builder public record Pagination( @@ -24,4 +37,5 @@ public record Pagination( Integer pageNumber, Integer pageSize ){} + } \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java index 5a5eceec..49df0c5d 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java @@ -1,14 +1,14 @@ package com.example.umc10th.domain.mission.repository; import com.example.umc10th.domain.mission.entity.Mission; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - public interface MissionRepository extends JpaRepository { @Query("SELECT m FROM Mission m WHERE m.store.id = :storeId") - List findMissionByStoreId(@Param("storeId") Long storeId); -} \ No newline at end of file + Page findMissionByStoreId(@Param("storeId") Long storeId, Pageable pageable); +} diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java index f2f1ea94..5f4e3e16 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java @@ -1,22 +1,116 @@ package com.example.umc10th.domain.mission.service; import com.example.umc10th.domain.mission.converter.MissionConverter; +import com.example.umc10th.domain.mission.dto.MissionReqDTO; import com.example.umc10th.domain.mission.dto.MissionResDTO; +import com.example.umc10th.domain.mission.entity.MemberMissionStatus; +import com.example.umc10th.domain.mission.entity.Mission; +import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.repository.MissionRepository; +import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.store.exception.StoreException; +import com.example.umc10th.domain.store.exception.code.StoreErrorCode; +import com.example.umc10th.domain.store.repository.StoreRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; - -import java.util.List; +import com.example.umc10th.domain.mission.repository.MemberMissionRepository; @Service @RequiredArgsConstructor public class MissionService { private final MissionRepository missionRepository; + private final StoreRepository storeRepository; + private final MemberMissionRepository memberMissionRepository; + + @Transactional + public void createMission( + Long storeId, + MissionReqDTO.CreateMission dto + ) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); + + Mission mission = MissionConverter.toMission(store, dto); + missionRepository.save(mission); + } + + @Transactional + public MissionResDTO.Pagination getMissionList( + Long storeId, + Integer pageNumber, + Integer pageSize, + String sort + ) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); + + return getMissions(storeId, pageSize, pageNumber, sort); + } + + // 내가 진행 중인 미션 조회 + @Transactional + public MissionResDTO.Pagination getMyOngoingMissions( + Long memberId, + Integer pageSize, + Integer pageNumber, + String sort + ) { + Sort sortInfo; + + if (sort != null) { + sortInfo = Sort.by(sort).descending(); + } else { + sortInfo = Sort.by("id").descending(); + } + + PageRequest pageRequest = PageRequest.of(pageNumber, pageSize, sortInfo); + + Page memberMissionPage = + memberMissionRepository.findMyOngoingMissions( + memberId, + MemberMissionStatus.ONGOING, + pageRequest + ); + + return MissionConverter.toPagination( + memberMissionPage.map(MissionConverter::toMyOngoingMission).toList(), + memberMissionPage.getNumber(), + memberMissionPage.getSize() + ); + } + + // 가게 내 미션들 조회 + public MissionResDTO.Pagination getMissions( + Long storeId, + Integer pageSize, + Integer pageNumber, + String sort + ) { + + // 정렬 정보 생성 + Sort sortInfo; + if (sort != null) { + sortInfo = Sort.by(sort); + } else { + sortInfo = Sort.by("id").descending(); + } + + // 페이지 정보들을 PageRequest로 만들기 + PageRequest pageRequest = PageRequest.of(pageNumber, pageSize, sortInfo); + + // 가게 내 미션들 조회 + Page missionList = missionRepository.findMissionByStoreId(storeId, pageRequest); - public List getMissionList(Long storeId) { - return missionRepository.findMissionByStoreId(storeId).stream() - .map(MissionConverter::toMissionInfo) - .toList(); + // 미션들 응답 DTO로 포장하기 + return MissionConverter.toPagination( + missionList.map(MissionConverter::toGetMission).toList(), + missionList.getNumber(), + missionList.getSize() + ); } -} \ No newline at end of file +} From 71cc4a90326559176900d677c0f8529248fc6e9e Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 17:43:32 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=AF=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MemberMissionRepository.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java index 26230097..1b9103c2 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java @@ -1,7 +1,21 @@ package com.example.umc10th.domain.mission.repository; +import com.example.umc10th.domain.mission.entity.MemberMissionStatus; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface MemberMissionRepository extends JpaRepository { -} +public interface MemberMissionRepository extends JpaRepository { + + @Query("SELECT mm FROM MemberMission mm " + + "WHERE mm.member.id = :memberId " + + "AND mm.status = :status") + Page findMyOngoingMissions( + @Param("memberId") Long memberId, + @Param("status") MemberMissionStatus status, + Pageable pageable + ); +} \ No newline at end of file From 13f40d452807b415faff4a500eb01d6612de6c4b Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 17:46:44 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=EB=82=B4=EA=B0=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=ED=95=9C=20=EB=A6=AC=EB=B7=B0=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/controller/ReviewController.java | 17 +++ .../review/converter/ReviewConverter.java | 28 +++++ .../domain/review/dto/ReviewResDTO.java | 22 ++++ .../review/repository/ReviewRepository.java | 48 ++++++++- .../domain/review/service/ReviewService.java | 101 ++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/Jinyong/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index dc48f9ff..62b34462 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -17,9 +17,26 @@ public class ReviewController { private final ReviewService reviewService; + // 리뷰 전체 조회 API @GetMapping("/v1/reviews") public ApiResponse> getReviewList() { BaseSuccessCode code = ReviewSuccessCode.OK; return ApiResponse.onSuccess(code, reviewService.getReviewList()); } + + // 내가 작성한 리뷰 조회 API - 커서 기반 페이지네이션 + @GetMapping("/v1/users/{memberId}/reviews") + public ApiResponse> getMyReviews( + @PathVariable Long memberId, + @RequestParam Integer pageSize, + @RequestParam String cursor, + @RequestParam String query + ) { + BaseSuccessCode code = ReviewSuccessCode.OK; + + return ApiResponse.onSuccess( + code, + reviewService.getMyReviews(memberId, pageSize, cursor, query) + ); + } } \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java b/Jinyong/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java index fe2f34ac..a3a6b1fa 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java @@ -1,8 +1,11 @@ package com.example.umc10th.domain.review.converter; +import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.review.dto.ReviewResDTO; import com.example.umc10th.domain.review.entity.Review; +import java.util.List; + public class ReviewConverter { public static ReviewResDTO.ReviewInfo toReviewInfo(Review review) { @@ -15,4 +18,29 @@ public static ReviewResDTO.ReviewInfo toReviewInfo(Review review) { .score(review.getScore()) .build(); } + + public static ReviewResDTO.MyReview toMyReview(Review review) { + return ReviewResDTO.MyReview.builder() + .reviewId(review.getId()) + .userId(review.getMember().getId()) + .storeId(review.getStore().getId()) + .userMissionId(review.getMemberMission().getId()) + .content(review.getContent()) + .score(review.getScore()) + .build(); + } + + public static ReviewResDTO.CursorPagination toCursorPagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + ) { + return ReviewResDTO.CursorPagination.builder() + .data(data) + .hasNext(hasNext) + .nextCursor(nextCursor) + .pageSize(pageSize) + .build(); + } } \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java index 8d5a18db..4a52b829 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java @@ -2,6 +2,8 @@ import lombok.Builder; +import java.util.List; + public class ReviewResDTO { @Builder @@ -13,4 +15,24 @@ public record ReviewInfo( String content, Integer score ) {} + + // 내가 작성한 리뷰 조회 + @Builder + public record MyReview( + Long reviewId, + Long userId, + Long storeId, + Long userMissionId, + String content, + Integer score + ) {} + + // 커서 기반 페이지네이션 + @Builder + public record CursorPagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + ) {} } \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java index d754a107..3550f3f8 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java @@ -1,7 +1,53 @@ package com.example.umc10th.domain.review.repository; import com.example.umc10th.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.member.id = :memberId " + + "ORDER BY r.id DESC") + Slice findMyReviewsByIdFirst( + @Param("memberId") Long memberId, + Pageable pageable + ); + + // ID 순 커서 조회 + @Query("SELECT r FROM Review r " + + "WHERE r.member.id = :memberId " + + "AND r.id < :cursorId " + + "ORDER BY r.id DESC") + Slice findMyReviewsByIdCursor( + @Param("memberId") Long memberId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + // 별점 순 첫 조회 + @Query("SELECT r FROM Review r " + + "WHERE r.member.id = :memberId " + + "ORDER BY r.score DESC, r.id DESC") + Slice findMyReviewsByScoreFirst( + @Param("memberId") Long memberId, + Pageable pageable + ); + + // 별점 순 커서 조회 + @Query("SELECT r FROM Review r " + + "WHERE r.member.id = :memberId " + + "AND (r.score < :cursorScore " + + "OR (r.score = :cursorScore AND r.id < :cursorId)) " + + "ORDER BY r.score DESC, r.id DESC") + Slice findMyReviewsByScoreCursor( + @Param("memberId") Long memberId, + @Param("cursorScore") Integer cursorScore, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} \ No newline at end of file diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/Jinyong/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index 7959b683..ec4d09a6 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -2,8 +2,12 @@ import com.example.umc10th.domain.review.converter.ReviewConverter; import com.example.umc10th.domain.review.dto.ReviewResDTO; +import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.domain.review.repository.ReviewRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import java.util.List; @@ -19,4 +23,101 @@ public List getReviewList() { .map(ReviewConverter::toReviewInfo) .toList(); } + + // 가게 내 미션들 조회 + @Transactional + public ReviewResDTO.CursorPagination getMyReviews( + Long memberId, + Integer pageSize, + String cursor, + String query + ) { + // 페이지 정보들을 PageRequest로 만들기 + PageRequest pageRequest = PageRequest.of(0, pageSize); + + Slice reviewList; + String nextCursor; + + // 커서가 있는 경우 + if (!cursor.equals("-1")) { + + String[] cursorSplit = cursor.split(":"); + + switch (query.toLowerCase()) { + + // ID 순 조회 + case "id" -> { + Long idCursor = Long.parseLong(cursorSplit[0]); + + reviewList = reviewRepository.findMyReviewsByIdCursor( + memberId, + idCursor, + pageRequest + ); + } + + // 별점 순 조회 + case "score" -> { + Integer scoreCursor = Integer.parseInt(cursorSplit[0]); + Long idCursor = Long.parseLong(cursorSplit[1]); + + // 실제 DB 조회 + reviewList = reviewRepository.findMyReviewsByScoreCursor( + memberId, + scoreCursor, + idCursor, + pageRequest + ); + } + + // 예외 처리 + default -> throw new IllegalArgumentException("query는 id 또는 score만 가능합니다."); + } + + } else { // cursor = -1인 경우의 시작 (첫 조회) + // 커서 없이 첫 조회 + switch (query.toLowerCase()) { + + // ID 순 첫 조회 + case "id" -> reviewList = reviewRepository.findMyReviewsByIdFirst( + memberId, + pageRequest + ); + + // 별점 순 첫 조회 + case "score" -> reviewList = reviewRepository.findMyReviewsByScoreFirst( + memberId, + pageRequest + ); + + default -> throw new IllegalArgumentException("query는 id 또는 score만 가능합니다."); + } + } + + // 다음 커서 계산 + if (reviewList.getContent().isEmpty()) { + nextCursor = null; // 조회결과가 비어 있으면 다음 커서를 만들 수 없음 + } else { + Review lastReview = reviewList.getContent() + .get(reviewList.getContent().size() - 1); // 마지막 데이터 꺼내오기 + + + // ID 기준이면 다음 커서는 마지막 리뷰의 ID + if (query.equals("id")) { + nextCursor = String.valueOf(lastReview.getId()); + } else { // 별점 기준이면 다음 커서는 score:id 형태 + nextCursor = lastReview.getScore() + ":" + lastReview.getId(); + } + } + + // 리뷰 응답 DTO로 포장하기 + return ReviewConverter.toCursorPagination( + reviewList.getContent().stream() + .map(ReviewConverter::toMyReview) + .toList(), + reviewList.hasNext(), + nextCursor, + reviewList.getSize() + ); + } } \ No newline at end of file From bb39467ddb4fb8ab51ac21608175e0d0ae604ded Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 17:47:01 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20=EA=B0=80=EA=B2=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/store/exception/code/StoreErrorCode.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java index 335dbbb8..91e8b1ec 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java @@ -10,10 +10,10 @@ public enum StoreErrorCode implements BaseErrorCode { STORE_NOT_FOUND(HttpStatus.NOT_FOUND, - "STORE404", + "STORE404_1", "해당 가게를 찾을 수 없습니다."), REGION_NOT_FOUND(HttpStatus.NOT_FOUND, - "REGION404", + "REGION404_1", "해당 지역을 찾을 수 없습니다."); private final HttpStatus httpStatus; From 8dceb60320c8d526befc633fb6076c941ea0a7f3 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 12 May 2026 19:46:55 +0900 Subject: [PATCH 08/27] =?UTF-8?q?docs:=207=EC=A3=BC=EC=B0=A8=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=ED=82=A4=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 --- ...heckout_at_2026-04-30__1_57__Changes_1.xml | 4 - .../shelved.patch" | 206 ------------------ .idea/workspace.xml | 149 ++++++++----- Jinyong/keyword_summary/ch07.md | 111 ++++++++++ 4 files changed, 205 insertions(+), 265 deletions(-) delete mode 100644 .idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30__1_57__Changes_1.xml delete mode 100644 ".idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30_\354\230\244\355\233\204_1_57_[Changes]1/shelved.patch" create mode 100644 Jinyong/keyword_summary/ch07.md diff --git a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30__1_57__Changes_1.xml b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30__1_57__Changes_1.xml deleted file mode 100644 index 2646ec10..00000000 --- a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30__1_57__Changes_1.xml +++ /dev/null @@ -1,4 +0,0 @@ - - \ No newline at end of file diff --git "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30_\354\230\244\355\233\204_1_57_[Changes]1/shelved.patch" "b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30_\354\230\244\355\233\204_1_57_[Changes]1/shelved.patch" deleted file mode 100644 index e4e70c9a..00000000 --- "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2026-04-30_\354\230\244\355\233\204_1_57_[Changes]1/shelved.patch" +++ /dev/null @@ -1,206 +0,0 @@ -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>package com.example.umc10th.domain.member.service;\r\n\r\npublic class MemberService {\r\n}\r\n -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java ---- a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java (revision fdc37513896120be201c90f5927b89f8037b3aa3) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java (date 1777524542470) -@@ -1,4 +1,30 @@ - package com.example.umc10th.domain.member.service; - -+import com.example.umc10th.domain.member.code.MemberErrorCode; -+import com.example.umc10th.domain.member.dto.MemberReqDTO; -+import com.example.umc10th.domain.member.dto.MemberResDTO; -+import com.example.umc10th.domain.member.entity.Member; -+import com.example.umc10th.domain.member.exception.MemberException; -+import com.example.umc10th.domain.member.repository.MemberRepository; -+ - public class MemberService { -+ -+ private final MemberRepository memberRepository; -+ -+ public MemberService(MemberRepository memberRepository) { -+ this.memberRepository = memberRepository; -+ } -+ -+ public MemberResDTO.GetInfo getInfo( -+ MemberReqDTO.GetInfo dto -+ ) { -+ // DTO에서 유저 ID를 추출 -+ Long memebrId = dto.id(); -+ // DB에서 해당 유저 ID로 데이터 조회 -+ Member member = memberRepository.findById(memberId) -+ .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); -+ } -+ -+ public Object getInfo(MemberReqDTO.GetInfo dto) { -+ } - } -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>package com.example.umc10th.domain.member.converter;\r\n\r\npublic class MemberConverter {\r\n}\r\n -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java ---- a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java (revision fdc37513896120be201c90f5927b89f8037b3aa3) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java (date 1777523938149) -@@ -1,4 +1,20 @@ - package com.example.umc10th.domain.member.converter; - -+import com.example.umc10th.domain.member.dto.MemberResDTO; -+import com.example.umc10th.domain.member.entity.Member; -+ - public class MemberConverter { --} -+ -+ // 마이페이지 정보 조회를 위한 DTO 변환 -+ public static MemberResDTO.GetInfo toGetInfo(Member member) { -+ -+ // 빌더 패턴을 사용하여 엔티티의 데이터를 DTO에 매핑합니다. -+ return MemberResDTO.GetInfo.builder() -+ .email(member.getEmail()) -+ .name(member.getName()) -+ .point(member.getPoint()) -+ .phoneNumber(member.getPhoneNumber()) -+ .profileUrl(member.getProfileUrl()) -+ .build(); -+ } -+} -\ No newline at end of file -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>package com.example.umc10th.domain.member.controller;\r\n\r\nimport com.example.umc10th.domain.member.dto.MemberReqDTO;\r\nimport com.example.umc10th.domain.member.dto.MemberResDTO;\r\nimport com.example.umc10th.global.apiPayload.ApiResponse;\r\nimport org.springframework.web.bind.annotation.*;\r\n\r\n@RestController\r\n@RequestMapping(\"/api\")\r\npublic class MemberController {\r\n\r\n // 마이페이지\r\n @PostMapping(\"/v1/users/me\")\r\n public ApiResponse getInfo(@RequestBody MemberReqDTO.GetInfo dto) {\r\n\r\n\r\n MemberResDTO.GetInfo result = MemberResDTO.GetInfo.builder()\r\n .name(\"철수\")\r\n .profileUrl(\"https://example.com/profile.jpg\")\r\n .email(\"patrick@hansung.ac.kr\")\r\n .phoneNumber(\"010-1234-5678\")\r\n .point(22500)\r\n .build();\r\n\r\n return ApiResponse.onSuccess(result);\r\n }\r\n} -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java ---- a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java (revision fdc37513896120be201c90f5927b89f8037b3aa3) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java (date 1777524675505) -@@ -1,27 +1,27 @@ - package com.example.umc10th.domain.member.controller; - -+import com.example.umc10th.domain.member.code.MemberSuccessCode; - import com.example.umc10th.domain.member.dto.MemberReqDTO; - import com.example.umc10th.domain.member.dto.MemberResDTO; -+import com.example.umc10th.domain.member.service.MemberService; - import com.example.umc10th.global.apiPayload.ApiResponse; -+import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; -+import lombok.RequiredArgsConstructor; - import org.springframework.web.bind.annotation.*; - - @RestController -+@RequiredArgsConstructor - @RequestMapping("/api") - public class MemberController { - -+ private final MemberService memberService; -+ - // 마이페이지 - @PostMapping("/v1/users/me") -- public ApiResponse getInfo(@RequestBody MemberReqDTO.GetInfo dto) { -- -- -- MemberResDTO.GetInfo result = MemberResDTO.GetInfo.builder() -- .name("철수") -- .profileUrl("https://example.com/profile.jpg") -- .email("patrick@hansung.ac.kr") -- .phoneNumber("010-1234-5678") -- .point(22500) -- .build(); -- -- return ApiResponse.onSuccess(result); -+ public ApiResponse getInfo( -+ @RequestBody MemberReqDTO.GetInfo dto -+ ){ -+ BaseSuccessCode code = MemberSuccessCode.OK; -+ return ApiResponse.onSuccess(code, memberService.getInfo(dto)); - } - } -\ No newline at end of file -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>package com.example.umc10th.domain.member.repository;\r\n\r\npublic class MemberRepository {\r\n}\r\n -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java ---- a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java (revision fdc37513896120be201c90f5927b89f8037b3aa3) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java (date 1777524765052) -@@ -1,4 +1,9 @@ - package com.example.umc10th.domain.member.repository; - --public class MemberRepository { --} -+import com.example.umc10th.domain.member.entity.Member; -+import org.springframework.data.jpa.repository.JpaRepository; -+ -+// JpaRepository<엔티티 타입, ID 타입>을 상속받아야 findById를 사용할 수 있어요! -+public interface MemberRepository extends JpaRepository { -+ -+} -\ No newline at end of file -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>package com.example.umc10th.domain.member.dto;\r\n\r\npublic class MemberReqDTO {\r\n\r\n public record GetInfo(\r\n Long id\r\n ) {}\r\n} -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java ---- a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java (revision fdc37513896120be201c90f5927b89f8037b3aa3) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java (date 1777386765775) -@@ -5,4 +5,4 @@ - public record GetInfo( - Long id - ) {} --} -\ No newline at end of file -+} -Index: Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberErrorCode.java -=================================================================== -diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberErrorCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberErrorCode.java -new file mode 100644 ---- /dev/null (date 1777523463270) -+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberErrorCode.java (date 1777523463270) -@@ -0,0 +1,4 @@ -+package com.example.umc10th.domain.member.code; -+ -+public enum MemberErrorCode { -+} -Index: .idea/workspace.xml -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n diff --git a/Jinyong/keyword_summary/ch07.md b/Jinyong/keyword_summary/ch07.md new file mode 100644 index 00000000..ae10a571 --- /dev/null +++ b/Jinyong/keyword_summary/ch07.md @@ -0,0 +1,111 @@ +- Page와 Slice + + Page와 Slice는 Spring Data JPA에서 페이징 처리를 할 때 사용하는 반환 타입이다. + + 여기서 **페이징**이란? → 전체 데이터를 한번에 가져오는 게 아니라, 사용자가 요청한 만큼만 나누어 가져오는 방식이다. + + ex) 리뷰가 10000개인데 한번에 가져오면 무거움 → 10개씩 나눠서 조회하는 방식 + + Slice는 다음 또는 이전 Slice가 있는지 알 수 있는 데이터 조각이고, Page는 전체 페이지 수나 전체 데이터 수 같은 추가 정보를 포함 + + Page는 전체 데이터 개수, 전체 페이지 수, 현재 페이지 번호, 다음 페이지 존재 여부 등 많은 것을 한꺼번에 제공한다. → 전체 조회 시 유리 + + Slice는 전체 개수까진 알 필요 없고, 다음 페이지가 있는지 여부 정도만 알려준다. + + ex) 무한 스크롤에서는 굳이 전체 페이지가 필요하지 않다. + + → Page보다 가볍게 사용 가능하다. + + 차이점 (표) + + | 구분 | Page | Slice | + | --- | --- | --- | + | 전체 데이터 개수 | 알 수 있음 | 알 수 없음 | + | 전체 페이지 수 | 알 수 있음 | 알 수 없음 | + | 다음 페이지 여부 | 알 수 있음 | 알 수 있음 | + | count 쿼리 | 보통 실행됨 | 보통 필요 없음 | + | 적합한 상황 | 게시판, 관리자 페이지 | 무한 스크롤, 더보기 버튼 | + + Page는 전체 페이지 수와 전체 데이터 개수가 필요한 경우에 사용하고, Slice는 다음 데이터가 있는지만 알면 되는 무한 스크롤 방식에 적합하다. + +- Java stream API + + Java stream API는 컬렉션, 배열 같은 데이터 흐름처럼 처리할 수 있게 해주는 기능이다. + + ex) List에 여러 개의 데이터가 있을 때, 반복문을 작성하지 않고도 filter, map, collect 같이 메서드를 이용해 데이터를 다룰 수 있다. + + **why? 왜 사용할까?** + + 1. 조건에 맞는 데이터만 걸러내기 위해서 + 2. 객체에서 필요한 값만 뽑아낼려고 + 3. 데이터를 다른 형태로 바꾸기 위해서 + 4. 결과를 다시 List로 만들기 위해 + + 기존 for문, if문으로 쓰던 방식에서 반복문을 줄이고, 데이터 처리 의도를 더 명확하게 표현하기 위해서 쓴다 + + Stream API는 Spring Boot에서 **조회한 Entity 목록을 Response DTO 목록으로 바꿀 때** 많이 사용되기 때문에 Spring Boot에서 자주 사용된다. + + 한줄 요약 + + : Stream API는 컬렉션 데이터를 반복문 없이 깔끔하게 처리하기 위한 Java 기능입니다. 특히 `filter`, `map`, `toList`를 많이 사용하며, Spring Boot에서는 Entity를 DTO로 변환할 때 자주 사용된다. + +- 객체 그래프 탐색 + + 객체 그래프 탐색이란 객체가 가진 연관관계를 따라가며 다른 객체에 접근하는 것을 말한다. + + (객체 지향 언어에서 참조를 사용하여 연관된 객체를 타고 들어가 데이터를 조회하는 방식) + 예를 들어 `Review`가 `Member`와 연결되어 있다고 가정할 때, + + ```java + Review review = reviewRepository.findById(1L).get(); + + String nickname = review.getMember().getNickname(); + ``` + + 여기서 review.getMember()를 통해 Review 객체에서 연결된 Member 객체로 이동한다. + 이처럼 객체의 연관관계를 따라가며 접근하는 것을 객체 그래프 탐색이라고 볼 수 있다. + + 객체 그래프 탐색은 JPA의 핵심 장점 중 하나이다. + + JPA는 DB 테이블을 Java 객체처럼 다룰 수 있게 해주는데, SQL 직접 작성 시 필요한 조인(Join)의 제약에서 벗어나, 논리적인 도메인 모델 구조에 따라 데이터를 조회 가능하게 해준다. + + 주의할 점으로는, 지연 로딩과 N + 1문제가 있는데, 연관 객체에 접근하다가 추가 쿼리가 발생할 수 있는 것을 알아야 한다. + + ex) 리뷰 목록 조회 후 리뷰 작성자 닉네임을 가져올려고 하는 때에, + + 리뷰 목록 조회 1번 + + 리뷰 10개의 작성자 조회 10번 + = 총 11번 쿼리 ⇒ 이런 문제 발생 할 수도 있음 + + 해결 방향으로는, 연관된 객체를 언제 함께 가져올지 신경 써야 한다. + + `@EntityGraph`를 사용하여 조회 시 연관 엔티티를 함께 가져오는 방식으로 사용할 수 있다. + + 한줄요약 + + :객체 그래프 탐색은 객체의 연관관계를 따라 다른 객체에 접근하는 방식이다. JPA에서는 매우 자연스러운 방식이지만, 연관 객체 접근 시 추가 쿼리가 발생할 수 있으므로 N+1 문제를 주의해야 한다. + +- @Valid vs @Validated + + `@Valid`와 `@Validated`는 모두 **요청 데이터의 유효성 검증**을 위해 사용한다. + + ex) 회원가입 요청에서 이메일이 비어 있거나 형식이 잘못된 경우, 컨트롤러까지 들어온 데이터를 검증해서 잘못된 요청을 막을 수 있다.(DB까지 가지 않고 controller에서 조기 진압) + + `@Valid`는 필드, 메서드 파라미터, 반환값 등에 붙여서 해당 객체와 내부 속성에 정의된 제약 조건을 검증하게 해준다. + + 즉, `@Valid`는 주로 **Request Body DTO 검증**에 많이 사용된다. + + `@Validated`는 Spring 기반 메서드 검증을 활성화하거나, 검증 그룹을 지정할 때 사용할 수 있다. + + `@RequestParam`이나 `@PathVariable`에 붙은 `@Min`, `@NotNull` 같은 검증을 제대로 적용하려면 컨트롤러 클래스에 `@Validated`를 붙이는 경우가 많다. + + @Valid와 @Validated 차이점(표) + + | 구분 | @Valid | @Validated | + | --- | --- | --- | + | 주 사용 위치 | RequestBody DTO 검증 | 클래스, 메서드, 파라미터 검증 | + | 검증 그룹 | 기본적으로 그룹 지정 어려움 | 그룹 지정 가능 | + | 자주 쓰는 상황 | `@RequestBody @Valid DTO` | `@RequestParam`, `@PathVariable`, Service 메서드 검증 | + + `@Valid`는 DTO 내부 필드 검증에 주로 사용되고, `@Validated`는 메서드 파라미터 검증이나 검증 그룹이 필요할 때 사용한다. \ No newline at end of file From 7a2b04ef6f6fbc314decca0ea7a1d0436d8d2875 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 17 May 2026 22:30:40 +0900 Subject: [PATCH 09/27] =?UTF-8?q?Spring=20Security=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jinyong/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Jinyong/build.gradle b/Jinyong/build.gradle index fbe47215..295d6219 100644 --- a/Jinyong/build.gradle +++ b/Jinyong/build.gradle @@ -38,3 +38,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +// Security +implementation 'org.springframework.boot:spring-boot-starter-security' +testImplementation 'org.springframework.security:spring-security-test' From 1685a6de252655e5fb81ff8775738ccad06e091b Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 17 May 2026 22:50:48 +0900 Subject: [PATCH 10/27] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=A7=81=20?= =?UTF-8?q?=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20dependencies=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jinyong/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Jinyong/build.gradle b/Jinyong/build.gradle index 295d6219..ade596e8 100644 --- a/Jinyong/build.gradle +++ b/Jinyong/build.gradle @@ -33,12 +33,12 @@ dependencies { //Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { useJUnitPlatform() } - -// Security -implementation 'org.springframework.boot:spring-boot-starter-security' -testImplementation 'org.springframework.security:spring-security-test' From fd66fef64f34e07f3e135d4e82fe84b5d9e7e857 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 17 May 2026 22:53:08 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat:=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=81=B4=EB=9E=98=EC=8A=A4(SecurityConfig)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/config/SecurityConfig.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java diff --git a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 00000000..5b3ee5e2 --- /dev/null +++ b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,49 @@ +package com.example.umc10th.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +public class SecurityConfig { + + private final String[] allowUris = { + // Swagger 허용 + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/auth/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} From 5294005cae06c2f429f2042280201d33b0ac41dd Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Sun, 17 May 2026 23:43:50 +0900 Subject: [PATCH 12/27] =?UTF-8?q?docs:=20SecurityConfig=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=82=B4=20=EC=A3=BC=EC=9A=94=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/config/SecurityConfig.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 5b3ee5e2..1c6c9b08 100644 --- a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -9,39 +9,42 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -@EnableWebSecurity +@EnableWebSecurity // Spring Security 설정을 활성화시키는 역할을 함 @Configuration public class SecurityConfig { - private final String[] allowUris = { + private final String[] allowUris = { // 허용할 URI, Public API를 따로 빼서 관리함 // Swagger 허용 "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", + + // 로그인 "/auth/**" }; - @Bean + @Bean // SecurityFilterChain을 정의함. HttpSecurity객체를 통해 다양한 보안 설정 구성 가능 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(requests -> requests - .requestMatchers(allowUris).permitAll() - .anyRequest().authenticated() + .authorizeHttpRequests(requests -> requests // HTTP요청에 대한 접근 제어 설정 + .requestMatchers(allowUris).permitAll() // permitAll()은 인증 없이 접근 가능한 경로 지정 + .anyRequest().authenticated() // 그 외 모든 요청에 대한 인증 요구 ) - .formLogin(form -> form + .formLogin(form -> form // 폼 기반 로그인에 대한 설정 .defaultSuccessUrl("/swagger-ui/index.html", true) .permitAll() ) - .logout(logout -> logout + .logout(logout -> logout // /logout 경로로 로그아웃 처리 .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") + .logoutSuccessUrl("/login?logout") // 로그아웃 성공 시 /login?logout으로 리다이렉트 .permitAll() ); return http.build(); } + // 비밀번호 솔트를 위한 BCrypt를 PasswordEncoder로 설정 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); From 25d8aa5658a31324a3c1502b8f73edb75a0e9f9b Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:35:18 +0900 Subject: [PATCH 13/27] =?UTF-8?q?feat:=20Spring=20Security=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/entity/AuthMember.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java new file mode 100644 index 00000000..61a32f48 --- /dev/null +++ b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -0,0 +1,33 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import java.awt.*; +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public @Nullable String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} From f9a8ed35e6c039caaab78164bbe4cdd07102464b Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:36:28 +0900 Subject: [PATCH 14/27] =?UTF-8?q?setting:=20H2=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20DB=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jinyong/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/Jinyong/build.gradle b/Jinyong/build.gradle index ade596e8..d4404db0 100644 --- a/Jinyong/build.gradle +++ b/Jinyong/build.gradle @@ -27,6 +27,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' From 44bbec0f1a7c7837e20a0d38cb7656809cf07f22 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:37:39 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat:=20403=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/handler/CustomAccessDenied.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java new file mode 100644 index 00000000..28ec5e20 --- /dev/null +++ b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +public class CustomAccessDenied implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.FORBIDDEN; + + // 인증은 되었지만 권한이 부족한 요청은 403 상태와 JSON 에러 응답으로 내려준다. + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} From 1f9cf38faf4768cde28448b8397e15fe397d0214 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:38:59 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20401=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/handler/CustomEntryPoint.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java new file mode 100644 index 00000000..22fb1f27 --- /dev/null +++ b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class CustomEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + // 인증되지 않은 요청은 401 상태와 JSON 에러 응답으로 내려준다. + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} From 5397e2f6beef4f7a293d645fe94c15fb47f1342d Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:39:48 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CustomUserDetailsService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..7789bfc1 --- /dev/null +++ b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.entity.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return new AuthMember(member); + } +} \ No newline at end of file From 0f4c97f65223da941d75dcc4969a642df9e39116 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:40:36 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20public=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/MemberController.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 144dc0e5..a9a39488 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -24,4 +24,13 @@ public ApiResponse getInfo( BaseSuccessCode code = MemberSuccessCode.OK; return ApiResponse.onSuccess(code, memberService.getInfo(memberId)); } -} \ No newline at end of file + + // 회원가입 + @PostMapping("/v1/auth/signup") + public ApiResponse signUp( + @RequestBody MemberReqDTO.SignUp request + ) { + BaseSuccessCode code = MemberSuccessCode.CREATED; + return ApiResponse.onSuccess(code, memberService.signUp(request)); + } +} From 0b293fe12752b7c699f7d7bd79e0c1c9a4d8e337 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:41:33 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=20=EB=B3=80=ED=99=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/converter/MemberConverter.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 3b40376a..3745b129 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -1,8 +1,12 @@ package com.example.umc10th.domain.member.converter; +import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.entity.Gender; import com.example.umc10th.domain.member.entity.Member; +import java.time.LocalDate; + public class MemberConverter { // 마이페이지 정보 조회를 위한 DTO 변환 @@ -18,4 +22,23 @@ public static MemberResDTO.GetInfo toGetInfo(Member member) { .phoneNumber(null) .build(); } + + public static Member toMember(MemberReqDTO.SignUp request, String encodedPassword) { + return Member.builder() + .email(request.email()) + .password(encodedPassword) + .name(request.email()) + .nickname(request.email()) + .gender(Gender.NONE) + .point(0) + .birth(LocalDate.now()) + .build(); + } + + public static MemberResDTO.SignUp toSignUp(Member member) { + return MemberResDTO.SignUp.builder() + .memberId(member.getId()) + .email(member.getEmail()) + .build(); + } } From 6d414798cd86cb2d993901e5cc487097d5252a5b Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:42:45 +0900 Subject: [PATCH 20/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=98=88=EC=99=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/exception/code/MemberErrorCode.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index ab02b778..c5a98d5f 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -11,7 +11,10 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", - "해당 사용자를 찾을 수 없습니다."); + "해당 사용자를 찾을 수 없습니다."), + MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, + "MEMBER409_1", + "이미 존재하는 이메일입니다."); private final HttpStatus httpStatus; private final String code; @@ -21,4 +24,4 @@ public enum MemberErrorCode implements BaseErrorCode { public HttpStatus getStatus() { return httpStatus; } -} \ No newline at end of file +} From 99d61cfbc4fdc0ac6503396213b2ebaf22cae0d2 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:44:15 +0900 Subject: [PATCH 21/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20Repository=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/member/repository/MemberRepository.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index e878bbb5..0b676788 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -3,5 +3,8 @@ import com.example.umc10th.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { -} \ No newline at end of file + Optional findByEmail(String email); +} From fcc453383bc7bca1175f1216fb818f7458fb3696 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:45:09 +0900 Subject: [PATCH 22/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20SignUp=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/umc10th/domain/member/dto/MemberReqDTO.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index de6ba086..1aaead74 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -5,4 +5,9 @@ public class MemberReqDTO { public record GetInfo( Long id ){} + + public record SignUp( + String email, + String password + ){} } From b81c784bd6ce67dd926a4d6cda891df44ef1c234 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:46:18 +0900 Subject: [PATCH 23/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20SignUp=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/umc10th/domain/member/dto/MemberResDTO.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index 833e1ee3..f183d7cc 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -12,4 +12,10 @@ public record GetInfo( String nickname, String gender ) {} -} \ No newline at end of file + + @Builder + public record SignUp( + Long memberId, + String email + ) {} +} From 12e5d3c73b755e6936844e3d5d09eb48ed8a2ae0 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:47:19 +0900 Subject: [PATCH 24/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index a049245f..e1645ddc 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -8,6 +8,7 @@ import com.example.umc10th.domain.member.exception.MemberException; import com.example.umc10th.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @@ -15,6 +16,7 @@ public class MemberService { private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; public MemberResDTO.GetInfo getInfo(Long memberId) { Member member = memberRepository.findById(memberId) @@ -22,4 +24,17 @@ public MemberResDTO.GetInfo getInfo(Long memberId) { return MemberConverter.toGetInfo(member); } + + public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) { + memberRepository.findByEmail(request.email()) + .ifPresent(member -> { + throw new MemberException(MemberErrorCode.MEMBER_ALREADY_EXISTS); + }); + + String encodedPassword = passwordEncoder.encode(request.password()); + Member member = MemberConverter.toMember(request, encodedPassword); + Member savedMember = memberRepository.save(member); + + return MemberConverter.toSignUp(savedMember); + } } From 0059c0abf7278e71f2d6dae603e58c398f2b842e Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:48:01 +0900 Subject: [PATCH 25/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=B1=EA=B3=B5=20=EC=9D=91=EB=8B=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/member/code/MemberSuccessCode.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberSuccessCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberSuccessCode.java index f6ec9859..0f1265a6 100644 --- a/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberSuccessCode.java +++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/code/MemberSuccessCode.java @@ -11,9 +11,12 @@ public enum MemberSuccessCode implements BaseSuccessCode { OK(HttpStatus.OK, "MEMBER200_1", - "성공적으로 유저를 조회했습니다."); + "성공적으로 유저를 조회했습니다."), + CREATED(HttpStatus.CREATED, + "MEMBER201_1", + "회원가입이 완료되었습니다."); private final HttpStatus status; private final String code; private final String message; -} \ No newline at end of file +} From cb9de212c2fc72c2dfb5c27a21f35fd9e4a5a2b3 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Mon, 18 May 2026 20:49:10 +0900 Subject: [PATCH 26/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20public=20API=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/config/SecurityConfig.java | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 1c6c9b08..9d8e60dc 100644 --- a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -1,5 +1,7 @@ package com.example.umc10th.global.config; +import com.example.umc10th.global.security.handler.CustomAccessDenied; +import com.example.umc10th.global.security.handler.CustomEntryPoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -9,44 +11,57 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -@EnableWebSecurity // Spring Security 설정을 활성화시키는 역할을 함 +@EnableWebSecurity @Configuration public class SecurityConfig { - private final String[] allowUris = { // 허용할 URI, Public API를 따로 빼서 관리함 - // Swagger 허용 + private final String[] allowUris = { + // Swagger "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - // 로그인 - "/auth/**" + // 회원가입 + "/api/v1/auth/signup" }; - @Bean // SecurityFilterChain을 정의함. HttpSecurity객체를 통해 다양한 보안 설정 구성 가능 + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(requests -> requests // HTTP요청에 대한 접근 제어 설정 - .requestMatchers(allowUris).permitAll() // permitAll()은 인증 없이 접근 가능한 경로 지정 - .anyRequest().authenticated() // 그 외 모든 요청에 대한 인증 요구 + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() ) - .formLogin(form -> form // 폼 기반 로그인에 대한 설정 + .formLogin(form -> form .defaultSuccessUrl("/swagger-ui/index.html", true) .permitAll() ) - .logout(logout -> logout // /logout 경로로 로그아웃 처리 + .logout(logout -> logout .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") // 로그아웃 성공 시 /login?logout으로 리다이렉트 + .logoutSuccessUrl("/login?logout") .permitAll() + ) + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDenied()) + .authenticationEntryPoint(customEntryPoint()) ); return http.build(); } - // 비밀번호 솔트를 위한 BCrypt를 PasswordEncoder로 설정 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CustomAccessDenied customAccessDenied() { + return new CustomAccessDenied(); + } + + @Bean + public CustomEntryPoint customEntryPoint() { + return new CustomEntryPoint(); + } } From 78640e265d0cc2bfbf310c818d072d57e8e57a48 Mon Sep 17 00:00:00 2001 From: JinYoung Shin Date: Tue, 19 May 2026 13:59:36 +0900 Subject: [PATCH 27/27] =?UTF-8?q?docs:=208=EC=A3=BC=EC=B0=A8=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jinyong/keyword_summary/ch08.md | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Jinyong/keyword_summary/ch08.md diff --git a/Jinyong/keyword_summary/ch08.md b/Jinyong/keyword_summary/ch08.md new file mode 100644 index 00000000..ba23e712 --- /dev/null +++ b/Jinyong/keyword_summary/ch08.md @@ -0,0 +1,57 @@ +- Spring Security가 무엇인가? + + 스프링 시큐리티(Spring Security)는 **자바 기반 웹 애플리케이션의 인증과 인가, 그리고 보안 위협으로부터의 보호를 처리하는 강력한 보안 프레임워크** + + 즉, 사용자가 어떤 API에 접근할 수 있는지 판단하며, CSRF같은 웹 보안 위협에 대응할 수 있도록 도와주고, 인증과 인가또한 처리해주는 도구이다. + + 핵심 동작은 Filter Chain으로 클라이언트의 요청이 Controller에 바로 도달하는 것이 아니라, 먼저 여러 보안 필터를 순서대로 통과한다. + + 로그인 여부 확인, 인증 객체 생성, 권한 검사, 예외처리 등을 필터들이 담당한다. + + 순서: + + 요청 → 필터 체인 → 인증 확인 → 권한 확인 → Controller 도달 또는 예외 응답 + +- 인증(Authentication)vs 인가(Authorization) + + 인증(Authentication)은 **사용자가 누구인지 확인하는 과정**이다. 예를 들어 사용자가 메일과 비밀 번호를 입력했을 때, 실제로 가입된 사용자인지 확인하는 것이 “인증”이다. → “당신은 누구….?” + + 인가(Authorization)는 **인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정**이다. 예를 들어 로그인은 성공했지만 일반 사용자가 관리자 페이지에 접근하려고 할 때 접근을 허용할지 거부할지 판단하는 것이 “인가”이다. → “이 사용자가 해당 리소스에 접근할 권한이 있는가??” + + 인증: 로그인해서 신원을 확인하는 단계 + + 인가: 로그인한 사용자가 어디까지 접근할 수 있는지 정하는 단계 + +- Stateful vs Stateless + + Stateful은 서버가 사용자의 로그인 상태를 기억하는 방식이다. + + 대표적인 예시는 세션 기반 로그인으로 사용자가 로그인하면 서버는 세션에 인증 정보를 저장하고, 클라이언트는 세션 ID를 쿠키로 들고 다닌다. + + 이후 요청이 들어오면 서버는 세션 ID를 보고 사용자가 로그인한 상태인지 확인한다. + + Stateless는 서버가 사용자의 로그인 상태를 저장하지 않는 방식이다. 대표적인 예시는 JWT 기반 인증이다. + + 사용자가 로그인하면 서버는 Access Token 같은 토큰을 발급하고, 이후 클라이언트는 요청마다 토큰을 함께 보낸다. + + 서버는 세션을 조회하는 대신, 요청에 포함된 토큰을 검증해서 사용자를 판단한다. + + Stateful 방식은 서버가 로그인 상태를 기억하기 때문에 흐름이 직관적이다. 로그인 성공 후 서버 세션에 인증 정보를 저장하고, 이후 요청마다 세션을 통해 로그인 여부르 판단 할 수 있다. Spring Security의 폼 로그인 방식은 기본적으로 세션을 활용하는 Stateful 방식에 가깝다 + + 이와 다르게 Stateless는 서버가 로그인 상태를 기억하지 않는다. 따라서 서버 확장에 유리하지만, 클라이언트가 매 요청마다 토큰을 보내야 한다. + + JWT를 사용하는 REST API 서버에서는 보통 `SessionCreationPolicy.STATELESS`를 설정하고, 서버가 세션을 만들지 않도록 구성한다. + + Stateful의 장/단점 + + 장점 :구현 흐름이 쉽다. 서버가 세션을 직접 관리하기에 로그아웃이나 세션 만료처리가 명확함. + + 단점: 사용자가 많아질수록 서버가 세션 정보를 관리해야 하므로 부담이 커질 수 있고, 서버가 여러 대일 경우 세션 공유 문제를 고려해야 함 + + Stateless의 장/단점 + + 장점: 서버가 세션을 저장하지 않기 때문에 확장성에 유리하다. 여러 서버로 트래픽을 분산해도 각 서버가 토큰만 검증하면 되므로 REST API, 모바일 앱, MSA 구조에서 자주 사용함 + + 단점: 토큰 탈취, 만료 시간, Refresh Token 관리, 로그아웃 처리 등을 신중하게 설계해야 함 + + **차이점은 결국, 서버가 로그인 상태를 기억하냐 못 하냐** \ No newline at end of file