From 7728d350b688fe98bb09c5b00fe6b2730a4a1a13 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Mon, 18 May 2026 23:53:48 +0900 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20=EB=B3=84=EC=A0=90=EC=88=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B3=B5=ED=95=A9=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/review/repository/ReviewRepository.java | 6 ++++-- .../umc10th/domain/review/service/ReviewService.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java index 38f44d9..4ec4418 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java @@ -27,9 +27,11 @@ Slice findByMemberIdAndIdCursorOrderByIdDesc(@Param("memberId") Long mem Slice findByMemberIdOrderByRatingDescIdDesc(@Param("memberId") Long memberId, Pageable pageable); @Query("SELECT r FROM Review r JOIN FETCH r.memberMission mm JOIN FETCH mm.mission m JOIN FETCH m.store s " + - "WHERE r.member.id = :memberId AND r.rating < :ratingCursor " + - "ORDER BY r.rating DESC") + "WHERE r.member.id = :memberId " + + "AND (r.rating < :ratingCursor OR (r.rating = :ratingCursor AND r.id < :cursor)) " + + "ORDER BY r.rating DESC, r.id DESC") Slice findByMemberIdWithRatingCursor(@Param("memberId") Long memberId, @Param("ratingCursor") Double ratingCursor, + @Param("cursor") Long cursor, Pageable pageable); } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/Sangwan/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index e90676e..66f7c42 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -33,8 +33,8 @@ public CursorPageRes getMyReviews(Long memberId, Long c Slice slice; if ("rating".equalsIgnoreCase(sort)) { - slice = (ratingCursor != null) - ? reviewRepository.findByMemberIdWithRatingCursor(memberId, ratingCursor, pageable) + slice = (ratingCursor != null && cursor != null) + ? reviewRepository.findByMemberIdWithRatingCursor(memberId, ratingCursor, cursor, pageable) : reviewRepository.findByMemberIdOrderByRatingDescIdDesc(memberId, pageable); } else { slice = (cursor != null) @@ -53,6 +53,7 @@ public CursorPageRes getMyReviews(Long memberId, Long c Review last = reviews.get(reviews.size() - 1); if ("rating".equalsIgnoreCase(sort)) { nextRatingCursor = last.getRating(); + nextCursor = last.getId(); } else { nextCursor = last.getId(); } From ff7e7ceda7cf1a0b8b8abe55d13117fafb1b99dd Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Mon, 18 May 2026 23:54:28 +0900 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=EC=A7=84=ED=96=89=EC=A4=91=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mission/repository/MemberMissionRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java index c95e86c..94cfb31 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java @@ -29,7 +29,8 @@ List findByMemberIdAndStatusWithCursor(@Param("memberId") Long me Optional findByMemberIdAndMissionId(Long memberId, Long missionId); @Query(value = "SELECT mm FROM MemberMission mm JOIN FETCH mm.mission m JOIN FETCH m.store s " + - "WHERE mm.member.id = :memberId AND mm.status = :status", + "WHERE mm.member.id = :memberId AND mm.status = :status " + + "ORDER BY mm.id DESC", countQuery = "SELECT COUNT(mm) FROM MemberMission mm WHERE mm.member.id = :memberId AND mm.status = :status") Page findPageByMemberIdAndStatus(@Param("memberId") Long memberId, @Param("status") MemberMissionStatus status, From 1e12455c13af92ef3d9e34486d7fac2875634517 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Mon, 18 May 2026 23:54:58 +0900 Subject: [PATCH 03/17] =?UTF-8?q?docs:=20Validated=20=EC=A4=91=EC=B2=A9=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=84=A4=EB=AA=85=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sangwan/keyword_summary/ch07.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sangwan/keyword_summary/ch07.md b/Sangwan/keyword_summary/ch07.md index b231f8f..a093af0 100644 --- a/Sangwan/keyword_summary/ch07.md +++ b/Sangwan/keyword_summary/ch07.md @@ -420,6 +420,8 @@ 또한 `@Validated`는 **클래스 레벨**에 붙여 메서드 파라미터 검증도 가능하다. + 단, 중첩 객체 필드는 `@Validated`만으로 자동 재귀 검증되지 않으며, 해당 필드에 `@Valid`를 함께 붙여야 검증된다. + ```java @Service @Validated // 클래스 레벨에 붙이면 메서드 파라미터도 검증 가능 @@ -439,7 +441,7 @@ | 출처 | Jakarta Bean Validation (Java 표준) | Spring Framework | | 패키지 | jakarta.validation.Valid | org.springframework.validation.annotation.Validated | | 그룹 검증 | 지원 안 함 | 지원 (groups 속성 사용) | - | 중첩 객체 검증 | 지원 (@Valid 재귀 적용) | 지원 안 함 | + | 중첩 객체 검증 | 지원 (@Valid 재귀 적용) | @Valid를 함께 붙이면 가능 | | 클래스 레벨 사용 | 불가 | 가능 (메서드 파라미터 검증) | | 검증 실패 예외 | MethodArgumentNotValidException | ConstraintViolationException | @@ -482,4 +484,4 @@ // @Validated 검증 실패 처리 } } - ``` \ No newline at end of file + ``` From cbd67969be5d05686f3a6c2afe33e860f5724700 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 00:55:29 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20Inquire=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/inquire/controller/InquireController.java | 4 ++++ .../umc10th/domain/inquire/converter/InquireConverter.java | 4 ++++ .../example/umc10th/domain/inquire/dto/InquireReqDTO.java | 4 ++++ .../example/umc10th/domain/inquire/dto/InquireResDTO.java | 4 ++++ .../umc10th/domain/{qna => inquire}/entity/Inquire.java | 6 +++--- .../domain/{qna => inquire}/entity/InquireAnswer.java | 2 +- .../domain/{qna => inquire}/entity/InquireImage.java | 2 +- .../domain/{qna => inquire}/enums/InquireStatus.java | 2 +- .../example/umc10th/domain/inquire/enums/InquireType.java | 5 +++++ .../exception/InquireException.java} | 6 +++--- .../exception/code/InquireErrorCode.java} | 6 +++--- .../exception/code/InquireSuccessCode.java} | 6 +++--- .../domain/inquire/repository/InquireRepository.java | 7 +++++++ .../umc10th/domain/inquire/service/InquireService.java | 4 ++++ .../com/example/umc10th/domain/member/entity/Member.java | 2 +- .../umc10th/domain/qna/controller/QnaController.java | 4 ---- .../example/umc10th/domain/qna/converter/QnaConverter.java | 4 ---- .../java/com/example/umc10th/domain/qna/dto/QnaReqDTO.java | 4 ---- .../java/com/example/umc10th/domain/qna/dto/QnaResDTO.java | 4 ---- .../com/example/umc10th/domain/qna/enums/InquireType.java | 5 ----- .../umc10th/domain/qna/repository/QnaRepository.java | 4 ---- .../com/example/umc10th/domain/qna/service/QnaService.java | 4 ---- 22 files changed, 48 insertions(+), 45 deletions(-) create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/controller/InquireController.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/converter/InquireConverter.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireReqDTO.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireResDTO.java rename Sangwan/src/main/java/com/example/umc10th/domain/{qna => inquire}/entity/Inquire.java (86%) rename Sangwan/src/main/java/com/example/umc10th/domain/{qna => inquire}/entity/InquireAnswer.java (93%) rename Sangwan/src/main/java/com/example/umc10th/domain/{qna => inquire}/entity/InquireImage.java (91%) rename Sangwan/src/main/java/com/example/umc10th/domain/{qna => inquire}/enums/InquireStatus.java (51%) create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireType.java rename Sangwan/src/main/java/com/example/umc10th/domain/{qna/exception/QnaException.java => inquire/exception/InquireException.java} (51%) rename Sangwan/src/main/java/com/example/umc10th/domain/{qna/exception/code/QnaErrorCode.java => inquire/exception/code/InquireErrorCode.java} (58%) rename Sangwan/src/main/java/com/example/umc10th/domain/{qna/exception/code/QnaSuccessCode.java => inquire/exception/code/InquireSuccessCode.java} (58%) create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/repository/InquireRepository.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/inquire/service/InquireService.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/controller/QnaController.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/converter/QnaConverter.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaReqDTO.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaResDTO.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireType.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/repository/QnaRepository.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/qna/service/QnaService.java diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/controller/InquireController.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/controller/InquireController.java new file mode 100644 index 0000000..dbe4095 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/controller/InquireController.java @@ -0,0 +1,4 @@ +package com.example.umc10th.domain.inquire.controller; + +public class InquireController { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/converter/InquireConverter.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/converter/InquireConverter.java new file mode 100644 index 0000000..b457bab --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/converter/InquireConverter.java @@ -0,0 +1,4 @@ +package com.example.umc10th.domain.inquire.converter; + +public class InquireConverter { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireReqDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireReqDTO.java new file mode 100644 index 0000000..c3b6cc2 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireReqDTO.java @@ -0,0 +1,4 @@ +package com.example.umc10th.domain.inquire.dto; + +public class InquireReqDTO { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireResDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireResDTO.java new file mode 100644 index 0000000..3fdf086 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/dto/InquireResDTO.java @@ -0,0 +1,4 @@ +package com.example.umc10th.domain.inquire.dto; + +public class InquireResDTO { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/Inquire.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/Inquire.java similarity index 86% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/Inquire.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/Inquire.java index 7d69704..1005b85 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/Inquire.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/Inquire.java @@ -1,8 +1,8 @@ -package com.example.umc10th.domain.qna.entity; +package com.example.umc10th.domain.inquire.entity; import com.example.umc10th.domain.member.entity.Member; -import com.example.umc10th.domain.qna.enums.InquireStatus; -import com.example.umc10th.domain.qna.enums.InquireType; +import com.example.umc10th.domain.inquire.enums.InquireStatus; +import com.example.umc10th.domain.inquire.enums.InquireType; import com.example.umc10th.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireAnswer.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireAnswer.java similarity index 93% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireAnswer.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireAnswer.java index e3ebaae..eb54b2f 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireAnswer.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireAnswer.java @@ -1,4 +1,4 @@ -package com.example.umc10th.domain.qna.entity; +package com.example.umc10th.domain.inquire.entity; import com.example.umc10th.domain.member.entity.Member; import com.example.umc10th.global.entity.BaseEntity; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireImage.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireImage.java similarity index 91% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireImage.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireImage.java index 94e98fe..c082290 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/entity/InquireImage.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/entity/InquireImage.java @@ -1,4 +1,4 @@ -package com.example.umc10th.domain.qna.entity; +package com.example.umc10th.domain.inquire.entity; import com.example.umc10th.global.entity.BaseEntity; import jakarta.persistence.*; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireStatus.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireStatus.java similarity index 51% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireStatus.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireStatus.java index 8c43eef..a8513a7 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireStatus.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireStatus.java @@ -1,4 +1,4 @@ -package com.example.umc10th.domain.qna.enums; +package com.example.umc10th.domain.inquire.enums; public enum InquireStatus { WAITING, COMPLETED diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireType.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireType.java new file mode 100644 index 0000000..ec56bc3 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/enums/InquireType.java @@ -0,0 +1,5 @@ +package com.example.umc10th.domain.inquire.enums; + +public enum InquireType { + SERVICE, STORE, PAYMENT, ACCOUNT, ETC +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/QnaException.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/InquireException.java similarity index 51% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/QnaException.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/InquireException.java index 4059027..c00ca46 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/QnaException.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/InquireException.java @@ -1,10 +1,10 @@ -package com.example.umc10th.domain.qna.exception; +package com.example.umc10th.domain.inquire.exception; import com.example.umc10th.global.apiPayload.code.BaseErrorCode; import com.example.umc10th.global.apiPayload.exception.ProjectException; -public class QnaException extends ProjectException { - public QnaException(BaseErrorCode errorCode) { +public class InquireException extends ProjectException { + public InquireException(BaseErrorCode errorCode) { super(errorCode); } } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaErrorCode.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireErrorCode.java similarity index 58% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaErrorCode.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireErrorCode.java index 9dba7de..91bf229 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaErrorCode.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireErrorCode.java @@ -1,4 +1,4 @@ -package com.example.umc10th.domain.qna.exception.code; +package com.example.umc10th.domain.inquire.exception.code; import com.example.umc10th.global.apiPayload.code.BaseErrorCode; import lombok.Getter; @@ -7,9 +7,9 @@ @Getter @RequiredArgsConstructor -public enum QnaErrorCode implements BaseErrorCode { +public enum InquireErrorCode implements BaseErrorCode { - QNA_NOT_FOUND(HttpStatus.NOT_FOUND, "QNA_404", "해당 문의를 찾을 수 없습니다."); + INQUIRE_NOT_FOUND(HttpStatus.NOT_FOUND, "INQUIRE_404", "해당 문의를 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaSuccessCode.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireSuccessCode.java similarity index 58% rename from Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaSuccessCode.java rename to Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireSuccessCode.java index 386c0e6..e26481d 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/exception/code/QnaSuccessCode.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/exception/code/InquireSuccessCode.java @@ -1,4 +1,4 @@ -package com.example.umc10th.domain.qna.exception.code; +package com.example.umc10th.domain.inquire.exception.code; import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import lombok.Getter; @@ -7,9 +7,9 @@ @Getter @RequiredArgsConstructor -public enum QnaSuccessCode implements BaseSuccessCode { +public enum InquireSuccessCode implements BaseSuccessCode { - QNA_CREATED(HttpStatus.CREATED, "QNA_201", "문의 등록이 완료되었습니다."); + INQUIRE_CREATED(HttpStatus.CREATED, "INQUIRE_201", "문의 등록이 완료되었습니다."); private final HttpStatus status; private final String code; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/repository/InquireRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/repository/InquireRepository.java new file mode 100644 index 0000000..e2c0f20 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/repository/InquireRepository.java @@ -0,0 +1,7 @@ +package com.example.umc10th.domain.inquire.repository; + +import com.example.umc10th.domain.inquire.entity.Inquire; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InquireRepository extends JpaRepository { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/inquire/service/InquireService.java b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/service/InquireService.java new file mode 100644 index 0000000..e7180c1 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/inquire/service/InquireService.java @@ -0,0 +1,4 @@ +package com.example.umc10th.domain.inquire.service; + +public class InquireService { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 6c4d828..a77a2cf 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -12,7 +12,7 @@ import com.example.umc10th.domain.notification.entity.NotificationSetting; import com.example.umc10th.domain.point.entity.PointHistory; import com.example.umc10th.domain.point.entity.PointWithdrawal; -import com.example.umc10th.domain.qna.entity.Inquire; +import com.example.umc10th.domain.inquire.entity.Inquire; import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.global.entity.BaseEntity; import jakarta.persistence.*; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/controller/QnaController.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/controller/QnaController.java deleted file mode 100644 index 6d960fa..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/controller/QnaController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.controller; - -public class QnaController { -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/converter/QnaConverter.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/converter/QnaConverter.java deleted file mode 100644 index 0ce251f..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/converter/QnaConverter.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.converter; - -public class QnaConverter { -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaReqDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaReqDTO.java deleted file mode 100644 index 95fdb87..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaReqDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.dto; - -public class QnaReqDTO { -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaResDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaResDTO.java deleted file mode 100644 index 826ca21..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/dto/QnaResDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.dto; - -public class QnaResDTO { -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireType.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireType.java deleted file mode 100644 index 46f67ff..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/enums/InquireType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.umc10th.domain.qna.enums; - -public enum InquireType { - // TODO: 문의 유형 추가 -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/repository/QnaRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/repository/QnaRepository.java deleted file mode 100644 index 2bebe09..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/repository/QnaRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.repository; - -public interface QnaRepository { -} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/qna/service/QnaService.java b/Sangwan/src/main/java/com/example/umc10th/domain/qna/service/QnaService.java deleted file mode 100644 index 2bf603f..0000000 --- a/Sangwan/src/main/java/com/example/umc10th/domain/qna/service/QnaService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.umc10th.domain.qna.service; - -public class QnaService { -} From c7fcdd46cf09a8ed97261177493f05cfaae6f22b Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:41:46 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20=EB=AF=B8=EC=85=98=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20enum=20=EB=88=84=EB=9D=BD=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/domain/mission/enums/MemberMissionStatus.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/mission/enums/MemberMissionStatus.java diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/mission/enums/MemberMissionStatus.java b/Sangwan/src/main/java/com/example/umc10th/domain/mission/enums/MemberMissionStatus.java new file mode 100644 index 0000000..84bb108 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/mission/enums/MemberMissionStatus.java @@ -0,0 +1,5 @@ +package com.example.umc10th.domain.mission.enums; + +public enum MemberMissionStatus { + CHALLENGING, SUCCESS, FAILED +} From e3ed0d82c113cde8152e430b3e1976fc09f99b74 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:41:56 +0900 Subject: [PATCH 06/17] =?UTF-8?q?build:=20Spring=20Security=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sangwan/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/Sangwan/build.gradle b/Sangwan/build.gradle index 9fd4155..91dc86f 100644 --- a/Sangwan/build.gradle +++ b/Sangwan/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webmvc' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' From 010a02e8edae7ab54ba9d832654c891b82e602aa Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:42:05 +0900 Subject: [PATCH 07/17] =?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=EC=9D=B8=EC=A6=9D=20=ED=95=84?= =?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 --- .../com/example/umc10th/domain/member/dto/MemberReqDTO.java | 5 +++++ .../com/example/umc10th/domain/member/entity/Member.java | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index 634c106..52f3b39 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -1,9 +1,11 @@ package com.example.umc10th.domain.member.dto; import com.example.umc10th.domain.member.enums.Gender; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; public class MemberReqDTO { @@ -13,6 +15,9 @@ public record MyInfoReq( public record SignupReq( @NotBlank String name, + @NotBlank @Email String email, + @NotBlank + @Size(min = 8, max = 16, message = "비밀번호는 8자 이상 16자 이하여야 합니다.") String password, @NotNull(message = "성별은 필수입니다. (MALE, FEMALE, NONE)") Gender gender, @NotBlank(message = "생년월일은 필수입니다.") @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)") String birthday, diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java index a77a2cf..2fcaec6 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -34,9 +34,12 @@ public class Member extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String email; + @Column(nullable = false, length = 100) + private String password; + @Enumerated(EnumType.STRING) private SocialType socialType; From dd95b565a6709d0b3ed90d7b4877d130a9ad9060 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:42:16 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0=ED=9A=8C=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/domain/member/repository/MemberRepository.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index 246b016..e7fc30a 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,9 +1,13 @@ package com.example.umc10th.domain.member.repository; -import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); } From ff7c810124f563d793188d52078ddde9f81d8525 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:42:26 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20BCrypt=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/converter/MemberConverter.java | 28 +++++++++++++++++++ .../domain/member/service/MemberService.java | 25 +++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index c43bab6..e7d3381 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -1,14 +1,18 @@ 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.Member; import com.example.umc10th.domain.member.entity.mapping.RegionProgress; +import com.example.umc10th.domain.member.enums.MemberStatus; +import com.example.umc10th.domain.member.enums.Role; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; import org.springframework.data.domain.Page; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; @@ -16,6 +20,30 @@ public class MemberConverter { + public static Member toMember(MemberReqDTO.SignupReq request, String encodedPassword, LocalDate birth) { + return Member.builder() + .email(request.email()) + .password(encodedPassword) + .name(request.name()) + .role(Role.USER) + .status(MemberStatus.ACTIVE) + .step(0) + .totalPoint(0) + .gender(request.gender()) + .birth(birth) + .baseAddress(request.address()) + .isVerified(false) + .build(); + } + + public static MemberResDTO.SignupRes toSignupRes(Member member) { + return MemberResDTO.SignupRes.builder() + .memberId(member.getId()) + .name(member.getName()) + .createdAt(member.getCreatedAt()) + .build(); + } + public static MemberResDTO.MyInfoRes toGetInfo(Member member) { return MemberResDTO.MyInfoRes.builder() .email(member.getEmail()) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 5d21c62..08bc025 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -21,9 +21,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.util.List; @Service @@ -35,10 +38,20 @@ public class MemberService { private final RegionProgressRepository regionProgressRepository; private final MissionRepository missionRepository; private final MemberMissionRepository memberMissionRepository; + private final PasswordEncoder passwordEncoder; + @Transactional public MemberResDTO.SignupRes signup(MemberReqDTO.SignupReq request) { - // TODO: 회원가입 로직 - return null; + if (memberRepository.existsByEmail(request.email())) { + throw new MemberException(MemberErrorCode.MEMBER_ALREADY_EXISTS); + } + + LocalDate birth = parseBirthday(request.birthday()); + String encodedPassword = passwordEncoder.encode(request.password()); + Member member = MemberConverter.toMember(request, encodedPassword, birth); + Member savedMember = memberRepository.save(member); + + return MemberConverter.toSignupRes(savedMember); } public MemberResDTO.HomeRes getHome(Long memberId) { @@ -103,4 +116,12 @@ public MemberResDTO.MyInfoRes requestMyInfo(MemberReqDTO.MyInfoReq myInfoReq) { return MemberConverter.toGetInfo(member); } + private LocalDate parseBirthday(String birthday) { + try { + return LocalDate.parse(birthday); + } catch (DateTimeParseException e) { + throw new MemberException(MemberErrorCode.INVALID_BIRTHDAY_FORMAT); + } + } + } From e8488f6f5684e5926d52d8461fdaa58dcbd1ab64 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:42:38 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20Security=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/security/AuthMember.java | 44 +++++++++++++++++++ .../security/CustomUserDetailsService.java | 27 ++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java b/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java new file mode 100644 index 0000000..827fab7 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java @@ -0,0 +1,44 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.MemberStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + public Long getId() { + return member.getId(); + } + + @Override + public @NonNull Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())); + } + + @Override + public @NonNull String getPassword() { + return member.getPassword(); + } + + @Override + public @NonNull String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isEnabled() { + return member.getStatus() == MemberStatus.ACTIVE; + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java b/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..0f6265a --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; +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; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public @NonNull UserDetails loadUserByUsername(@NonNull String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + + return new AuthMember(member); + } +} From 0b1822e5fce7cbac92174a76358c30b6d6f331c7 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:42:55 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=ED=8F=BC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=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 --- .../umc10th/global/config/SecurityConfig.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 0000000..4ae528a --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,111 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode; +import jakarta.servlet.http.HttpServletResponse; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // REST API와 Postman 실습 흐름에서는 CSRF 토큰을 함께 보내지 않으므로 비활성화한다. + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/members/signup", + "/api/v1/members/login", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" + ).permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .loginProcessingUrl("/api/v1/members/login") + .usernameParameter("email") + .passwordParameter("password") + .successHandler((request, response, authentication) -> + writeSuccessResponse(response, GeneralSuccessCode.OK)) + .failureHandler((request, response, exception) -> + writeErrorResponse(response, GeneralErrorCode.UNAUTHORIZED)) + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/api/v1/members/logout") + .logoutSuccessHandler((request, response, authentication) -> + writeSuccessResponse(response, GeneralSuccessCode.OK)) + .permitAll() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> + writeErrorResponse(response, GeneralErrorCode.UNAUTHORIZED)) + .accessDeniedHandler((request, response, accessDeniedException) -> + writeErrorResponse(response, GeneralErrorCode.FORBIDDEN)) + ); + + return http.build(); + } + + private void writeSuccessResponse(HttpServletResponse response, BaseSuccessCode code) throws IOException { + writeResponse(response, code.getStatus().value(), true, code.getCode(), code.getMessage()); + } + + private void writeErrorResponse(HttpServletResponse response, BaseErrorCode code) throws IOException { + writeResponse(response, code.getStatus().value(), false, code.getCode(), code.getMessage()); + } + + private void writeResponse(HttpServletResponse response, int status, boolean isSuccess, + String code, String message) throws IOException { + response.setStatus(status); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"isSuccess\":%s,\"code\":\"%s\",\"message\":\"%s\",\"result\":null}" + .formatted(isSuccess, escapeJson(code), escapeJson(message)) + ); + } + + private String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\b' -> escaped.append("\\b"); + case '\f' -> escaped.append("\\f"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (ch < 0x20) { + escaped.append(String.format("\\u%04x", (int) ch)); + } else { + escaped.append(ch); + } + } + } + } + return escaped.toString(); + } +} From 630c97068f723435a506a97f70ce073974c27d64 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 12:43:03 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=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 ++++++++--- .../domain/review/controller/ReviewController.java | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 9c5e75d..c3628f0 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -8,6 +8,8 @@ import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -42,7 +44,8 @@ public ApiResponse> getMissions( @RequestParam Long memberId, // TODO: 인증 구현 후 SecurityContext로 대체 @RequestParam String status, @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") + @Positive(message = "페이지 크기는 1 이상이어야 합니다.") int size ) { return ApiResponse.onSuccess(MemberSuccessCode.MISSION_LIST, memberService.getMissions(memberId, status, cursor, size)); @@ -61,8 +64,10 @@ public ApiResponse requestMissionSuccess( @PostMapping("/me/missions/inprogress") public ApiResponse> getInProgressMissions( @Valid @RequestBody MemberReqDTO.GetInProgressMissionsReq request, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "0") + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다.") int page, + @RequestParam(defaultValue = "10") + @Positive(message = "페이지 크기는 1 이상이어야 합니다.") int size ) { return ApiResponse.onSuccess(MemberSuccessCode.INPROGRESS_MISSIONS, memberService.getInProgressMissions(request, page, size)); diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/Sangwan/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index 6d6e5de..a659dd0 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -5,6 +5,7 @@ import com.example.umc10th.domain.review.service.ReviewService; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.dto.CursorPageRes; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -24,7 +25,8 @@ public ApiResponse> getMyReviews( @RequestParam(required = false) Long cursor, @RequestParam(required = false) Double ratingCursor, @RequestParam(defaultValue = "id") String sort, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") + @Positive(message = "페이지 크기는 1 이상이어야 합니다.") int size ) { return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_LIST, reviewService.getMyReviews(memberId, cursor, ratingCursor, sort, size)); From ade261feb9bd55e6fa84ff389181c991b5fbb942 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 13:54:07 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20Week08=20=ED=82=A4=EC=9B=8C?= =?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 --- Sangwan/keyword_summary/ch08.md | 348 ++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 Sangwan/keyword_summary/ch08.md diff --git a/Sangwan/keyword_summary/ch08.md b/Sangwan/keyword_summary/ch08.md new file mode 100644 index 0000000..ee36462 --- /dev/null +++ b/Sangwan/keyword_summary/ch08.md @@ -0,0 +1,348 @@ +- Spring Security가 무엇인가? + + --- + + > **Spring Security는 자바 기반 웹 애플리케이션의 인증(Authentication)과 인가(Authorization), 그리고 각종 보안 위협으로부터의 보호를 담당하는 강력한 보안 프레임워크이다. Spring Framework의 하위 프로젝트로, 서블릿 필터(Servlet Filter) 기반으로 동작한다.** + > + + --- + + ### Spring Security란? + + Spring Security는 Spring 애플리케이션에 보안 기능을 추가하기 위한 프레임워크이다. 개발자가 보안 로직을 직접 구현하지 않아도, 몇 가지 설정만으로 인증·인가·보안 위협 방어 기능을 손쉽게 적용할 수 있다. + + 핵심 기능은 크게 세 가지로 나뉜다. + + - **인증(Authentication):** 사용자가 누구인지 확인하는 과정 (로그인) + - **인가(Authorization):** 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정 + - **보안 위협 방어:** CSRF, XSS, CORS 등 다양한 공격으로부터 애플리케이션을 보호 + + --- + + ### Spring Security의 동작 원리 + + Spring Security는 **서블릿 필터 체인(Filter Chain)** 기반으로 동작한다. HTTP 요청이 들어오면 DispatcherServlet에 도달하기 전에 여러 보안 필터를 순차적으로 통과한다. + + ``` + HTTP 요청 + ↓ + [FilterChain] + ↓ SecurityContextPersistenceFilter (이전 인증 정보 복원) + ↓ UsernamePasswordAuthenticationFilter (폼 로그인 처리) + ↓ AnonymousAuthenticationFilter (익명 사용자 처리) + ↓ ExceptionTranslationFilter (예외 처리) + ↓ FilterSecurityInterceptor (인가 처리) + ↓ + DispatcherServlet → Controller + ``` + + --- + + ### 주요 구성 요소 + + | 구성 요소 | 역할 | + | --- | --- | + | SecurityContextHolder | 현재 인증된 사용자 정보를 ThreadLocal에 저장·관리 | + | SecurityContext | Authentication 객체를 보관하는 컨텍스트 | + | Authentication | 사용자의 인증 정보(principal, credentials, authorities) 표현 | + | AuthenticationManager | 인증 처리를 위임하는 중심 컴포넌트 (ProviderManager가 구현체) | + | AuthenticationProvider | 실제 인증 로직을 수행 (DB 조회, 비밀번호 검증 등) | + | UserDetailsService | DB에서 사용자 정보를 조회하여 UserDetails 객체로 반환 | + | PasswordEncoder | 비밀번호를 암호화하고 검증 (BCryptPasswordEncoder 주로 사용) | + | FilterSecurityInterceptor | 요청 URL에 대한 인가 처리 | + + --- + + ### Spring Security 설정 예시 + + ```java + @Configuration + @EnableWebSecurity + public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // CSRF 비활성화 (REST API의 경우) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/members/signup", "/api/v1/members/login").permitAll() // 인증 없이 접근 허용 + .anyRequest().authenticated() // 나머지는 인증 필요 + ) + .formLogin(form -> form + .loginProcessingUrl("/api/v1/members/login") + .usernameParameter("email") + .passwordParameter("password") + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); // 비밀번호 암호화 + } + } + ``` + + --- + + ### Spring Security가 방어하는 주요 보안 위협 + + | 보안 위협 | 설명 | Spring Security 대응 | + | --- | --- | --- | + | CSRF | 인증된 사용자가 의도치 않은 요청을 보내도록 유도하는 공격 | CSRF 토큰 검증으로 방어 | + | XSS | 악성 스크립트를 웹 페이지에 삽입하는 공격 | 응답 헤더 설정으로 방어 | + | CORS | 다른 출처(Origin)에서의 리소스 접근 제어 | CorsConfigurationSource 설정으로 허용 출처 관리 | + | 세션 고정 공격 | 공격자가 미리 설정한 세션 ID를 사용자가 사용하도록 유도 | 로그인 시 세션 ID 재발급 | + + --- + + ### 정리 + + Spring Security는 단순한 로그인 기능을 넘어, 웹 애플리케이션 전반의 보안을 책임지는 프레임워크이다. 필터 체인 기반의 유연한 구조 덕분에 다양한 인증 방식(폼 로그인, JWT, OAuth2 등)을 손쉽게 적용할 수 있으며, 스프링 생태계와의 높은 통합성이 가장 큰 장점이다. + +- 인증(Authentication)vs 인가(Authorization) + + --- + + > **인증(Authentication)은 "당신이 누구인가?"를 확인하는 과정이고, 인가(Authorization)는 "당신이 이것을 할 수 있는가?"를 확인하는 과정이다. 인증은 인가보다 반드시 먼저 수행되어야 한다.** + > + + --- + + ### 인증(Authentication)이란? + + 인증은 사용자의 신원을 확인하는 과정이다. 가장 일반적인 예는 아이디와 비밀번호를 통한 로그인이다. 시스템은 사용자가 제공한 자격 증명(Credentials)이 등록된 정보와 일치하는지 검증한다. + + ```java + // Spring Security에서 인증 흐름 + // 1. 사용자가 로그인 요청 (email + password) + // 2. UsernamePasswordAuthenticationFilter가 요청 가로채기 + // 3. AuthenticationManager → AuthenticationProvider 위임 + // 4. UserDetailsService로 DB에서 사용자 조회 + // 5. PasswordEncoder로 비밀번호 검증 + // 6. 성공 시 Authentication 객체를 SecurityContext에 저장 + + // UserDetailsService 구현 예시 + @Service + public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); + + return User.builder() + .username(member.getEmail()) + .password(member.getPassword()) // BCrypt 암호화된 비밀번호 + .roles(member.getRole().name()) + .build(); + } + } + ``` + + **인증 성공 후 SecurityContext에 저장된 정보 접근:** + + ```java + // 어디서든 현재 로그인한 사용자 정보 접근 가능 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String email = auth.getName(); // 사용자 이메일 + Collection roles = auth.getAuthorities(); // 권한 목록 + ``` + + --- + + ### 인가(Authorization)이란? + + 인가는 인증된 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 결정하는 과정이다. 예를 들어 일반 사용자는 관리자 페이지에 접근할 수 없도록 제한하는 것이 인가이다. + + ```java + // Spring Security에서 인가 설정 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // ADMIN 역할만 접근 + .requestMatchers("/api/v1/members/**").hasRole("USER") // USER 역할만 접근 + .requestMatchers("/api/v1/public/**").permitAll() // 모든 사용자 접근 허용 + .anyRequest().authenticated() // 나머지는 인증만 필요 + ); + return http.build(); + } + + // 메서드 레벨 인가 (어노테이션 방식) + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/api/v1/admin/members/{id}") + public ApiResponse deleteMember(@PathVariable Long id) { + // ADMIN 역할을 가진 사용자만 실행 가능 + } + ``` + + --- + + ### 인증 vs 인가 비교 + + | 구분 | 인증 (Authentication) | 인가 (Authorization) | + | --- | --- | --- | + | 핵심 질문 | 당신은 누구인가? | 당신은 이것을 할 수 있는가? | + | 목적 | 신원 확인 | 권한 확인 | + | 수행 순서 | 먼저 수행 | 인증 이후 수행 | + | 실패 시 HTTP 상태코드 | 401 Unauthorized | 403 Forbidden | + | Spring Security 처리 필터 | UsernamePasswordAuthenticationFilter | FilterSecurityInterceptor | + | 저장 위치 | SecurityContext (Authentication 객체) | GrantedAuthority (권한 목록) | + | 실생활 예시 | 사원증으로 회사 출입 | 사원증으로 특정 층만 출입 가능 | + + --- + + ### Spring Security에서의 권한(Role) 관리 + + Spring Security에서는 `GrantedAuthority` 인터페이스를 통해 권한을 표현한다. 일반적으로 `ROLE_` 접두사를 붙인 문자열로 관리한다. + + ```java + // 권한 Enum 정의 + public enum MemberRole { + ROLE_USER, + ROLE_ADMIN + } + + // Member 엔티티에 권한 필드 추가 + @Entity + public class Member { + // ... + @Enumerated(EnumType.STRING) + private MemberRole role; + } + + // UserDetails에 권한 부여 + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getRole().name())); + } + ``` + + --- + + ### 정리 + + 인증과 인가는 보안의 두 축이다. 인증 없이는 인가가 불가능하며, 인가 없이는 권한 관리가 불가능하다. Spring Security는 이 두 과정을 필터 체인을 통해 자동으로 처리해주므로, 개발자는 설정과 비즈니스 로직에만 집중할 수 있다. + +- Stateful vs Stateless + + --- + + > **Stateful(상태 유지)은 서버가 클라이언트의 상태 정보를 저장·유지하는 방식이고, Stateless(무상태)는 서버가 클라이언트의 상태를 저장하지 않고 매 요청이 독립적으로 처리되는 방식이다. Spring Security에서는 세션 기반 인증(Stateful)과 JWT 기반 인증(Stateless)이 대표적인 예이다.** + > + + --- + + ### Stateful(상태 유지)이란? + + Stateful 방식에서는 서버가 클라이언트의 상태(주로 세션)를 메모리나 DB에 저장한다. 클라이언트는 세션 ID를 쿠키에 담아 매 요청마다 전송하고, 서버는 이를 통해 사용자를 식별한다. + + **세션 기반 인증 흐름:** + + ``` + 1. 클라이언트 → 서버: 로그인 요청 (email + password) + 2. 서버: 인증 성공 → 세션 생성 (Session ID: abc123) + 3. 서버 → 클라이언트: Set-Cookie: JSESSIONID=abc123 + 4. 클라이언트 → 서버: 요청 + Cookie: JSESSIONID=abc123 + 5. 서버: 세션 저장소에서 abc123 조회 → 사용자 확인 + ``` + + ```java + // Spring Security 세션 기반 설정 (기본값) + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 필요 시 세션 생성 + ); + return http.build(); + } + ``` + + **Stateful의 특징:** + + - 서버 메모리에 세션 정보 저장 + - 로그아웃 시 서버에서 세션을 즉시 삭제하여 완전한 로그아웃 가능 + - 서버가 여러 대일 경우 세션 공유 문제 발생 (Sticky Session, Redis 세션 공유 등으로 해결) + + --- + + ### Stateless(무상태)란? + + Stateless 방식에서는 서버가 클라이언트의 상태를 저장하지 않는다. 대신 클라이언트가 인증 정보를 토큰(주로 JWT) 형태로 직접 보관하고, 매 요청마다 토큰을 함께 전송한다. 서버는 토큰의 서명을 검증하여 사용자를 식별한다. + + **JWT 기반 인증 흐름:** + + ``` + 1. 클라이언트 → 서버: 로그인 요청 (email + password) + 2. 서버: 인증 성공 → JWT 토큰 생성 및 반환 + 3. 클라이언트: JWT 토큰을 로컬 스토리지 또는 쿠키에 저장 + 4. 클라이언트 → 서버: 요청 + Authorization: Bearer + 5. 서버: JWT 서명 검증 → 사용자 확인 (DB 조회 없음) + ``` + + ```java + // Spring Security JWT(Stateless) 설정 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // REST API는 CSRF 불필요 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안 함 + ) + .addFilterBefore(jwtAuthenticationFilter, // JWT 필터 추가 + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + ``` + + **JWT(JSON Web Token) 구조:** + + ``` + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (알고리즘, 타입) + .eyJzdWIiOiJ1c2VyQGVtYWlsLmNvbSIsInJvbGUiOiJVU0VSIiwiZXhwIjoxNjk5OTk5OTk5fQ ← Payload (사용자 정보) + .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature (위변조 방지) + ``` + + --- + + ### Stateful vs Stateless 비교 + + | 구분 | Stateful (세션 기반) | Stateless (JWT 기반) | + | --- | --- | --- | + | 상태 저장 위치 | 서버 (세션 저장소) | 클라이언트 (토큰) | + | 서버 확장성 | 낮음 (세션 공유 문제) | 높음 (서버 간 공유 불필요) | + | 로그아웃 처리 | 즉시 가능 (서버에서 세션 삭제) | 어려움 (토큰 만료 전까지 유효) | + | 보안 | 세션 하이재킹 위험 | 토큰 탈취 시 만료 전까지 악용 가능 | + | 서버 부하 | 높음 (세션 저장소 유지) | 낮음 (DB 조회 없이 검증 가능) | + | 모바일/앱 지원 | 쿠키 의존으로 불편 | 헤더 기반으로 편리 | + | Spring Security 설정 | SessionCreationPolicy.IF_REQUIRED | SessionCreationPolicy.STATELESS | + | 주요 사용 사례 | 전통적인 웹 애플리케이션 | REST API, MSA, 모바일 앱 | + + --- + + ### Spring Security에서의 선택 기준 + + 현대 REST API 개발에서는 **Stateless(JWT)** 방식이 주로 사용된다. 그 이유는 다음과 같다. + + - 서버를 수평 확장(Scale-out)할 때 세션 공유 문제가 없다. + - 모바일 앱, SPA(Single Page Application)와의 연동이 쉽다. + - 마이크로서비스 아키텍처(MSA)에서 서비스 간 인증 전달이 용이하다. + + 단, JWT 방식은 토큰이 탈취되었을 때 즉시 무효화하기 어렵다는 단점이 있다. 이를 보완하기 위해 **Access Token(단기) + Refresh Token(장기)** 전략을 함께 사용하는 것이 일반적이다. + + ```java + // Access Token + Refresh Token 전략 + // - Access Token: 유효기간 짧게 (예: 30분) + // - Refresh Token: 유효기간 길게 (예: 7일), DB에 저장 + // → Access Token 만료 시 Refresh Token으로 재발급 + // → 로그아웃 시 DB의 Refresh Token 삭제 + ``` + + --- + + ### 정리 + + Stateful과 Stateless는 서버가 클라이언트 상태를 어디에 저장하느냐의 차이이다. 전통적인 웹 서비스는 Stateful(세션) 방식을 사용했지만, 현대의 REST API와 MSA 환경에서는 확장성이 뛰어난 Stateless(JWT) 방식이 표준으로 자리 잡고 있다. Spring Security는 두 방식 모두 지원하므로, 서비스 특성에 맞게 선택하면 된다. \ No newline at end of file From a7dcff422ff70076ae680c907ff8f66e17653a81 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 14:44:44 +0900 Subject: [PATCH 14/17] =?UTF-8?q?chore:=20bootRun=EC=97=90=EC=84=9C=20env?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=9D=BD=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sangwan/build.gradle | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sangwan/build.gradle b/Sangwan/build.gradle index 91dc86f..0bfa2b3 100644 --- a/Sangwan/build.gradle +++ b/Sangwan/build.gradle @@ -43,3 +43,15 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.named('bootRun') { + def envFile = file('.env') + if (envFile.exists()) { + envFile.readLines() + .findAll { line -> line.trim() && !line.trim().startsWith('#') && line.contains('=') } + .each { line -> + def (key, value) = line.split('=', 2) + environment key.trim(), value.trim() + } + } +} From 5ebae59d0df8ff1907edb1e52609179d937b64ff Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 14:45:00 +0900 Subject: [PATCH 15/17] =?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=EC=97=90=20=EC=95=BD=EA=B4=80?= =?UTF-8?q?=EA=B3=BC=20=EC=84=A0=ED=98=B8=20=EC=9D=8C=EC=8B=9D=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 --- .../umc10th/domain/member/dto/MemberReqDTO.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index 52f3b39..28590dc 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -1,12 +1,17 @@ package com.example.umc10th.domain.member.dto; import com.example.umc10th.domain.member.enums.Gender; +import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import java.util.List; + public class MemberReqDTO { public record MyInfoReq( @@ -21,7 +26,17 @@ public record SignupReq( @NotNull(message = "성별은 필수입니다. (MALE, FEMALE, NONE)") Gender gender, @NotBlank(message = "생년월일은 필수입니다.") @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)") String birthday, - @NotBlank String address + @NotBlank String address, + @NotEmpty(message = "약관 동의 정보는 필수입니다.") + List<@Valid TermAgreementReq> termAgreements, + @NotEmpty(message = "선호 음식 종류는 1개 이상 선택해야 합니다.") + List<@NotNull @Positive Long> foodCategoryIds + ) {} + + public record TermAgreementReq( + @NotNull(message = "약관 ID는 필수입니다.") + @Positive(message = "약관 ID는 양수여야 합니다.") Long termId, + @NotNull(message = "약관 동의 여부는 필수입니다.") Boolean isAgreed ) {} public record GetInProgressMissionsReq( From 4cba1f1d53b79808ca099752e3a5030fd6bc171e Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 14:45:26 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=97=B0=EA=B4=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=A0=80=EC=9E=A5=20Repository?= =?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 --- .../member/repository/MemberAgreementRepository.java | 7 +++++++ .../repository/MemberFoodCategoryRepository.java | 7 +++++++ .../store/repository/FoodCategoryRepository.java | 7 +++++++ .../domain/term/repository/TermRepository.java | 11 +++++++++++ 4 files changed, 32 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberAgreementRepository.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberFoodCategoryRepository.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/store/repository/FoodCategoryRepository.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/domain/term/repository/TermRepository.java diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberAgreementRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberAgreementRepository.java new file mode 100644 index 0000000..2c5d773 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberAgreementRepository.java @@ -0,0 +1,7 @@ +package com.example.umc10th.domain.member.repository; + +import com.example.umc10th.domain.member.entity.mapping.MemberAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberAgreementRepository extends JpaRepository { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberFoodCategoryRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberFoodCategoryRepository.java new file mode 100644 index 0000000..8e76b66 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberFoodCategoryRepository.java @@ -0,0 +1,7 @@ +package com.example.umc10th.domain.member.repository; + +import com.example.umc10th.domain.member.entity.mapping.MemberFoodCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberFoodCategoryRepository extends JpaRepository { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/store/repository/FoodCategoryRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/store/repository/FoodCategoryRepository.java new file mode 100644 index 0000000..bd3b47e --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/store/repository/FoodCategoryRepository.java @@ -0,0 +1,7 @@ +package com.example.umc10th.domain.store.repository; + +import com.example.umc10th.domain.store.entity.FoodCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FoodCategoryRepository extends JpaRepository { +} diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/term/repository/TermRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/term/repository/TermRepository.java new file mode 100644 index 0000000..fb0b5b0 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/domain/term/repository/TermRepository.java @@ -0,0 +1,11 @@ +package com.example.umc10th.domain.term.repository; + +import com.example.umc10th.domain.term.entity.Term; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TermRepository extends JpaRepository { + + List findByIsRequiredTrue(); +} From ccee0d5392b76329edd78a352d152666d8f7e81f Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 19 May 2026 14:54:46 +0900 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=95=BD=EA=B4=80=EA=B3=BC=20=EC=84=A0?= =?UTF-8?q?=ED=98=B8=20=EC=9D=8C=EC=8B=9D=EC=9D=84=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/converter/MemberConverter.java | 19 +++++ .../exception/code/MemberErrorCode.java | 5 +- .../domain/member/service/MemberService.java | 84 +++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index e7d3381..81262c9 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -3,11 +3,15 @@ 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.entity.mapping.MemberAgreement; +import com.example.umc10th.domain.member.entity.mapping.MemberFoodCategory; import com.example.umc10th.domain.member.entity.mapping.RegionProgress; import com.example.umc10th.domain.member.enums.MemberStatus; import com.example.umc10th.domain.member.enums.Role; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; +import com.example.umc10th.domain.store.entity.FoodCategory; +import com.example.umc10th.domain.term.entity.Term; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; import org.springframework.data.domain.Page; @@ -36,6 +40,21 @@ public static Member toMember(MemberReqDTO.SignupReq request, String encodedPass .build(); } + public static MemberAgreement toMemberAgreement(Member member, Term term, Boolean isAgreed) { + return MemberAgreement.builder() + .member(member) + .term(term) + .isAgreed(isAgreed) + .build(); + } + + public static MemberFoodCategory toMemberFoodCategory(Member member, FoodCategory foodCategory) { + return MemberFoodCategory.builder() + .member(member) + .foodCategory(foodCategory) + .build(); + } + public static MemberResDTO.SignupRes toSignupRes(Member member) { return MemberResDTO.SignupRes.builder() .memberId(member.getId()) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index ecf3242..b1f5b17 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/Sangwan/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, "MEMBER_404", "해당 사용자를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER_4001", "이미 가입된 회원 정보가 존재합니다."), - INVALID_BIRTHDAY_FORMAT(HttpStatus.BAD_REQUEST, "MEMBER_4002", "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)"); + INVALID_BIRTHDAY_FORMAT(HttpStatus.BAD_REQUEST, "MEMBER_4002", "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)"), + REQUIRED_TERM_NOT_AGREED(HttpStatus.BAD_REQUEST, "MEMBER_4003", "필수 약관에 동의해야 합니다."), + TERM_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_4041", "해당 약관을 찾을 수 없습니다."), + FOOD_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_4042", "해당 음식 카테고리를 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 08bc025..05be1d4 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -7,6 +7,8 @@ import com.example.umc10th.domain.member.entity.mapping.RegionProgress; import com.example.umc10th.domain.member.exception.MemberException; import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberAgreementRepository; +import com.example.umc10th.domain.member.repository.MemberFoodCategoryRepository; import com.example.umc10th.domain.member.repository.MemberRepository; import com.example.umc10th.domain.member.repository.RegionProgressRepository; import com.example.umc10th.domain.mission.entity.Mission; @@ -16,6 +18,10 @@ import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; import com.example.umc10th.domain.mission.repository.MemberMissionRepository; import com.example.umc10th.domain.mission.repository.MissionRepository; +import com.example.umc10th.domain.store.entity.FoodCategory; +import com.example.umc10th.domain.store.repository.FoodCategoryRepository; +import com.example.umc10th.domain.term.entity.Term; +import com.example.umc10th.domain.term.repository.TermRepository; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; import lombok.RequiredArgsConstructor; @@ -27,7 +33,13 @@ import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -38,6 +50,10 @@ public class MemberService { private final RegionProgressRepository regionProgressRepository; private final MissionRepository missionRepository; private final MemberMissionRepository memberMissionRepository; + private final TermRepository termRepository; + private final FoodCategoryRepository foodCategoryRepository; + private final MemberAgreementRepository memberAgreementRepository; + private final MemberFoodCategoryRepository memberFoodCategoryRepository; private final PasswordEncoder passwordEncoder; @Transactional @@ -46,11 +62,19 @@ public MemberResDTO.SignupRes signup(MemberReqDTO.SignupReq request) { throw new MemberException(MemberErrorCode.MEMBER_ALREADY_EXISTS); } + Map agreementByTermId = toAgreementMap(request.termAgreements()); + validateRequiredTerms(agreementByTermId); + List terms = findTerms(agreementByTermId.keySet()); + List foodCategories = findFoodCategories(request.foodCategoryIds()); + LocalDate birth = parseBirthday(request.birthday()); String encodedPassword = passwordEncoder.encode(request.password()); Member member = MemberConverter.toMember(request, encodedPassword, birth); Member savedMember = memberRepository.save(member); + saveMemberAgreements(savedMember, terms, agreementByTermId); + saveMemberFoodCategories(savedMember, foodCategories); + return MemberConverter.toSignupRes(savedMember); } @@ -124,4 +148,64 @@ private LocalDate parseBirthday(String birthday) { } } + private Map toAgreementMap(List termAgreements) { + return termAgreements.stream() + .collect(Collectors.toMap( + MemberReqDTO.TermAgreementReq::termId, + MemberReqDTO.TermAgreementReq::isAgreed, + (previous, current) -> current, + LinkedHashMap::new + )); + } + + private void validateRequiredTerms(Map agreementByTermId) { + boolean hasRequiredTermNotAgreed = termRepository.findByIsRequiredTrue().stream() + .anyMatch(term -> !Boolean.TRUE.equals(agreementByTermId.get(term.getId()))); + + if (hasRequiredTermNotAgreed) { + throw new MemberException(MemberErrorCode.REQUIRED_TERM_NOT_AGREED); + } + } + + private List findTerms(Set termIds) { + List terms = termRepository.findAllById(termIds); + if (terms.size() != termIds.size()) { + throw new MemberException(MemberErrorCode.TERM_NOT_FOUND); + } + + Map termById = terms.stream() + .collect(Collectors.toMap(Term::getId, Function.identity())); + + return termIds.stream() + .map(termById::get) + .toList(); + } + + private List findFoodCategories(List foodCategoryIds) { + Set distinctFoodCategoryIds = new LinkedHashSet<>(foodCategoryIds); + List foodCategories = foodCategoryRepository.findAllById(distinctFoodCategoryIds); + if (foodCategories.size() != distinctFoodCategoryIds.size()) { + throw new MemberException(MemberErrorCode.FOOD_CATEGORY_NOT_FOUND); + } + + Map foodCategoryById = foodCategories.stream() + .collect(Collectors.toMap(FoodCategory::getId, Function.identity())); + + return distinctFoodCategoryIds.stream() + .map(foodCategoryById::get) + .toList(); + } + + private void saveMemberAgreements(Member member, List terms, Map agreementByTermId) { + memberAgreementRepository.saveAll(terms.stream() + .map(term -> MemberConverter.toMemberAgreement(member, term, agreementByTermId.get(term.getId()))) + .toList()); + } + + private void saveMemberFoodCategories(Member member, List foodCategories) { + memberFoodCategoryRepository.saveAll(foodCategories.stream() + .map(foodCategory -> MemberConverter.toMemberFoodCategory(member, foodCategory)) + .toList()); + } + }