diff --git a/Seohui/build.gradle b/Seohui/build.gradle
index 6a7f11f8..676e3fd5 100644
--- a/Seohui/build.gradle
+++ b/Seohui/build.gradle
@@ -32,6 +32,10 @@ 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') {
diff --git a/Seohui/keyword_summary/ch07.md b/Seohui/keyword_summary/ch07.md
new file mode 100644
index 00000000..3f576205
--- /dev/null
+++ b/Seohui/keyword_summary/ch07.md
@@ -0,0 +1,246 @@
+# Page와 Slice
+
+> **목적 / 용도**
+> - **Page:** 게시판처럼 정확한 페이지 번호가 필요한 경우
+> - **Slice:** 무한 스크롤이나 더 보기 버튼이 필요한 경우
+
+| 구분 | Page | Slice |
+| --- | --- | --- |
+| **특징** | 전체 데이터 건수, 전체 페이지 수를 알 수 있음 | 전체 건수는 모르고 다음 페이지가 있는지의 여부만 알 수 있음 |
+| **동작 방식** | 데이터 조회 쿼리 + 전체 개수를 세는 `COUNT` 쿼리가 같이 실행됨 | • `COUNT` 쿼리 실행 안 함
• 요청한 개수보다 **1개 더 (Limit + 1)** 가져와서 다음 페이지 여부 확인 |
+| **성능** | 데이터가 많아질수록 `COUNT` 쿼리 때문에 성능이 저하될 수 있음 | `COUNT` 쿼리가 없어서 대용량 데이터 조회 시 성능이 훨씬 빠름 |
+| **상속 구조** | `Slice` 인터페이스를 상속받음 (Slice의 모든 기능 + COUNT 기능) | 부모 인터페이스 |
+| **UI** | [1] [2] [3] [4] [5][다음] | 스크롤을 맨 아래로 내리면 자동 로딩 |
+
+**e.g.**
+```java
+@Repository
+public interface ReviewRepository extends JpaRepository {
+
+ // Page 타입 반환
+ Page findPageBy(Pageable pageable);
+
+ // Slice 타입 반환
+ Slice findSliceBy(Pageable pageable);
+}
+
+@RestController
+// ...
+ /**
+ Page 방식
+ /reviews/page?page=0&size=5
+ **/
+ @GetMapping("/reviews/page")
+ public Page getReviewsByPage(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size
+ ) {
+ // id 기준 최신순 정렬 Pageable 객체 생성
+ Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
+
+ // 반환 시 전체 개수 정보가 응답됨
+ return reviewRepository.findPageBy(pageable);
+ }
+
+ /**
+ Slice 방식
+ /reviews/slice?page=0&size=5
+ **/
+ @GetMapping("/reviews/slice")
+ public Slice getReviewsBySlice(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size
+ ) {
+ Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
+
+ // 전체 개수는 안 나옴
+ // 데이터 내용, 다음 페이지 여부만 응답됨
+ return reviewRepository.findSliceBy(pageable);
+ }
+```
+
+# Java Stream API
+
+> **Stream API**
+> 자바 8에 추가된 기능으로 컬렉션(List, Set, Map)이나 배열에 저장된 데이터를 반복문 없이 깔끔하게 처리하는 기술.
+> 데이터의 흐름을 만듦 → 람다식과 함께 데이터를 필터링, 변환, 수집 작업을 연결해서 수행.
+
+### Stream API 동작 흐름
+1. **생성(Creation)** → 컬렉션이나 배열을 스트림 객체로 만듦 (`.stream()`)
+2. **중간 연산(Intermediate)** → 데이터를 가공함 (여러 번 연결 가능)
+3. **최종 연산(Terminal)** → 가공된 데이터를 결과로 만듦 (한 번만 사용 가능하며, 이때 실제 연산이 시작됨)
+
+---
+
+### 중간 연산
+
+* **특징:**
+ * 데이터를 가공하는 단계
+ * 연산 결과로 또 다른 `Stream`을 반환함 → 메서드 체이닝 가능
+
+| 메서드 | 설명 | 예시 / 비고 |
+| --- | --- | --- |
+| `filter(조건)` | 조건에 맞는 데이터만 걸러냄 | 짝수만 추출 |
+| `map(변환 규칙)` | 데이터를 다른 형태로 변환 | User 객체에서 이름만 추출 |
+| `sorted()` | 데이터 정렬 | 기본 오름차순, 사용자 지정 정렬 |
+| `distinct()` | 중복된 데이터 제거 | |
+| `limit(n)` | 데이터의 흐름에서 앞에서부터 n개의 데이터만 잘라냄 | |
+
+---
+
+### 최종 연산
+
+* **특징:**
+ * 가공된 데이터에서 최종 결과를 도출하는 마지막 단계
+ * 실제 값이나 컬렉션을 반환하며, 최종 연산이 호출되어야 비로소 전체 스트림 연산이 실행됨 (지연 연산)
+ * 한 번 실행되면 스트림이 닫힘
+
+| 메서드 | 설명 | 예시 / 비고 |
+| --- | --- | --- |
+| `collect()` | 가공된 스트림 데이터를 List, Set, Map 등으로 묶어서 반환 | |
+| `forEach()` | 스트림의 각 요소를 순회하며 출력하거나 작업을 수행함 | 반환값 없음 |
+| `count()` | 스트림에 남은 데이터의 **총 개수** 반환 | |
+| `reduce()` | 데이터를 하나씩 누적 계산 | 모든 숫자 합 계산 |
+| `anyMatch(조건)` | 조건을 만족하는 데이터가 **하나라도 존재하는지** 확인 | boolean 반환 |
+
+---
+
+### 주의 사항
+* **스트림은 일회용임** → 한 번 최종 연산(`collect`, `count` 등)을 수행해서 결과를 얻으면 스트림은 닫혀서 다시 쓸 수 없다! 필요 시 재생성해야 함.
+* **지연 연산(Lazy Evaluation)을 함** → 중간 연산(`filter`, `map`)을 많이 적어두어도 맨 끝에 최종 연산(`collect` 등)을 호출하지 않으면 계산이 아예 실행되지 않음.
+
+# 객체 그래프 탐색
+
+> **객체 그래프 탐색**
+> JPA나 ORM을 사용할 때 자주 등장하는 개념으로, 객체들 사이의 **연관 관계를 연쇄적으로 따라가며 조회하는 것** → 객체 안에 또 다른 객체가 있을 때 점(`.`)을 통해 타고 들어가면 된다.
+> 실제 DB 조회 시점과 관련이 있기 때문에 중요함.
+> 참조 변수를 통해 연관된 객체로 이동하는 행위를 말함.
+
+**e.g.**
+`comment.getUser().getName()`
+* `comment`: 댓글 객체
+* `comment.getUser()`: 댓글을 작성한 사용자 (User)
+* `comment.getUser().getName()`: 사용자의 이름 (Name)
+
+---
+
+### N+1 문제
+객체 그래프 탐색은 필요할 때 가져온다는 지연 로딩(Lazy Loading) 특성을 가지기에 **N+1 문제**가 발생될 우려가 있음 → `fetch join`으로 해결 (한 번에 가져와서 객체 그래프 탐색 시 추가 쿼리 X)
+
+* **현상:** 최초에 목록을 조회하는 쿼리를 한 번 날림
+* **문제:** 그 목록에 담긴 객체들을 하나씩 탐색할 때마다 연관된 데이터를 가져오기 위해 추가적인 쿼리가 N번 더 발생
+* **원인:** JPA 입장에서는 어디까지 탐색할지 미리 알 수 없음 → 처음에는 딱 요청한 것만 가져오고(프록시) 나중에 탐색을 시도할 때마다 쿼리를 날리기 때문에 발생하는 문제
+
+# @Valid vs @Validated
+
+### 차이점
+
+| 구분 | @Valid (자바 표준) | @Validated (스프링용) |
+| --- | --- | --- |
+| **소속** | Java/Jakarta EE 표준 규격(JSR-380) | Spring Framework |
+| **동작 위치** | 주로 Controller의 파라미터 변환 시 동작 | Controller, Service, Repository 등 스프링 빈 어디서나 동작 |
+| **동작 원리** | 스프링의 ArgumentResolver가 개입하여 검증 | 스프링의 AOP를 기반으로 메서드 호출을 가로채서 검증 |
+| **그룹 지정** | 불가능 (무조건 전체 검증) | 가능 (상황에 따라 원하는 조건만 묶어서 검증) |
+| **적용 대상** | 메서드 파라미터, 필드(객체 내부의 객체 검증 시) | 클래스 레벨, 메서드 파라미터 |
+| **발생 예외 (Exception)** | `MethodArgumentNotValidException` | AOP 동작 시 `ConstraintViolationException` 발생 |
+
+### 공통점
+객체에 설정된 제약 조건(`@NotNull`, `@Size` 등)을 확인하여 옳지 않은 데이터가 서버 내부로 들어오는 것을 막아줌
+
+---
+
+## @Validated
+
+> **@Validated**
+> Spring 프레임워크에서 전용으로 제공하는 유효성 검증 어노테이션.
+> 자바 표준인 `@Valid`의 모든 기능을 포함하면서, 추가적으로 **그룹화(Grouping)** 기능을 제공하여 특정 상황에 맞는 검증만 실행하라고 알려주는 신호.
+> `@Valid`가 주로 웹(Controller) 계층에서만 동작한다면, `@Validated`는 스프링 빈으로 등록된 클래스라면 어디서든 유효성 검증이 가능해짐.
+
+* **주 기능 : 그룹 유효성 검사 (Validation Groups)**
+ * ex) 회원 가입할 때와 회원 정보를 수정할 때 요구하는 데이터의 조건이 다를 경우 → 가입할 때는 비밀번호가 필수지만, 프로필 수정 시에는 비밀번호를 바꾸지 않을 수 있음
+
+### @Validated 동작 흐름
+1. 클라이언트 → Controller/Service 호출
+2. Spring AOP 동작 (메서드 가로채기)
+3. 지정된 그룹 또는 파라미터 검증
+ * **실패 :** `ConstraintViolationException` 또는 `MethodArgumentNotValidException` 발생
+ * **성공 :** 대상 메서드의 비즈니스 로직 정상 수행
+
+### 어노테이션 설명 (예시)
+* `@NotBlank(groups = CreateGroup.class)` : 가입(Create)할 때만 필수 값으로 검증
+* `@NotNull(groups = UpdateGroup.class)` : 수정(Update)할 때만 null이 아닌지 검증
+* `@Validated(CreateGroup.class)` : 해당 그룹(Create)의 규칙만 검사하도록 지시함
+
+### 클래스 레벨 검증 (Method Validation)
+클래스 위에 붙여 해당 클래스의 모든 메서드 파라미터에 대한 유효성 검사를 활성화할 수 있음
+
+### 자바 @Valid와 비교했을 때 주의할 점
+`@Validated`를 클래스 레벨에 적용하여 발생하는 예외(`ConstraintViolationException`)와 컨트롤러 DTO 검증 시 발생하는 예외(`MethodArgumentNotValidException`)가 다름
+→ 글로벌 예외 처리를 구현할 때 이 두 가지를 모두 처리해주어야 한다.
+
+---
+
+## @Valid
+
+> **@Valid**
+> Java Bean Validation의 기본 어노테이션.
+> DTO, Entity, Method Parameter 등에서 검증을 시작하라고 Spring/Hibernate Validator에게 알려주는 신호.
+> `@Valid`를 사용하면 객체 안에서 들어오는 값에 대해 검증이 가능해짐.
+
+### @Valid 동작 흐름
+1. 클라이언트 → Controller 호출
+2. Valid → Hibernate Validator 호출
+3. DTO 필드 검증
+ * **실패 :** `MethodArgumentNotValidException` 발생
+ * **성공 :** Controller 비즈니스 로직 수행
+
+### 검증 어노테이션 종류
+
+#### 문자열 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@NotBlank` | null X, 공백 제외 길이 > 0 |
+| `@NotEmpty` | null X, 빈 문자열("") X |
+| `@NotNull` | null X (타입 상관 없음) |
+| `@Null` | 반드시 null 값 |
+
+#### 최대/최솟값 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@DecimalMax` | 지정값 이하 (String 기반) |
+| `@DecimalMin` | 지정값 이상 (String 기반) |
+| `@Max` | 지정값 이하 (숫자 기반) |
+| `@Min` | 지정값 이상 (숫자 기반) |
+
+#### 범위 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Positive` | 양수만 허용 |
+| `@PositiveOrZero` | 0 이상 |
+| `@Negative` | 음수만 허용 |
+| `@NegativeOrZero` | 0 이하 |
+
+*참고: `DecimalMax/Min`은 BigDecimal, BigInteger, String 등 정밀 수치용 / `Max/Min`은 기본 숫자형(int, long 등) 용*
+
+#### 시간 값 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Future` | 현재 시간보다 미래 |
+| `@FutureOrPresent` | 현재 or 미래 |
+| `@Past` | 현재 시간보다 과거 |
+| `@PastOrPresent` | 현재 or 과거 |
+
+#### Boolean 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@AssertTrue` | true 값만 허용 |
+| `@AssertFalse` | false 값만 허용 |
+
+#### 크기 검증
+| 어노테이션 | 설명 |
+| --- | --- |
+| `@Size` | 값의 길이가 min 이상 max 이하
→ `String`, `Collection`, `Map`, `Array` 에 적용 |
+
+### 자바 Valid 가 제공하지 않는 기능
+`@Valid`는 `javax.validation` 표준이지만, 그룹 유효성 검사를 지원하지 않음.
+→ 필요한 경우 `@Validated` 사용
\ No newline at end of file
diff --git a/Seohui/keyword_summary/ch08.md b/Seohui/keyword_summary/ch08.md
new file mode 100644
index 00000000..c63e5e30
--- /dev/null
+++ b/Seohui/keyword_summary/ch08.md
@@ -0,0 +1,80 @@
+# Spring Security가 무엇인가?
+
+Java 서블릿 기반 애플리케이션에서 **인증(Authentication)**과 인가(Authorization), 보안 공격 방어 기능을 제공하는 프레임워크
+→ 서블릿 필터의 체인 구조를 통해 요청을 가로채고 보안 로직을 처리함
+요청이 DispatcherServlet(Controller)에 도달하기 전에 가로채서 보안 로직을 수행함
+
+* **DelegatingFilterProxy**
+ * 서블릿 컨테이너와 스프링 컨테이너(IoC 컨테이너)를 연결하는 역할을 하는 표준 서블릿 필터
+ * 서블릿 필터는 스프링 빈을 직접 알지 못하므로 이 프록시 필터가 요청을 받아 스프링 빈으로 등록된 보안 필터(FilterChainProxy)에 위임
+* **FilterChainProxy**
+ * Spring Security의 실질적인 진입점으로 DelegatingFilterProxy로부터 요청을 받아 현재 요청에 적용되어야 할 SecurityFilterChain을 찾아 실행시키는 역할을 함
+* **SecurityFilterChain**
+ * 보안 처리를 위한 필터 묶음
+ * SecurityConfig에서 `.authorizeHttpRequests()`, `.formLogin()` 등을 설정하면 이에 맞는 필터들이 체인 형태로 구성
+
+---
+
+# 인증(Authentication) vs 인가(Authorization)
+
+## 인증(Authentication)
+해당 사용자가 누구인지 확인하는 과정에서 필요한 주요 객체
+
+* **Authentication**
+ * 인증 전/ 인증 후의 사용자 정보를 담는 객체
+ * 구성 요소:
+ * Principal: 사용자 식별자 (로그인 전: ID / 로그인 후: UserDetails 객체)
+ * Credentials: 자격 증명 (로그인 전: 비밀번호 / 로그인 후: 보안을 위해 삭제됨)
+ * Authorities: 사용자가 가진 권한 목록 (ROLE_USER, ROLE_ADMIN 등)
+* **SecurityContext**
+ * 현재 인증된 사용자의 Authentication 객체를 보관하는 객체
+* **SecurityContextHolder**
+ * SecurityContext 저장하는 저장소
+ * 기본적으로 ThreadLocal 전략으로 같은 쓰레드 내에서는 어디서든 인증 정보에 접근할 수 있음
+ * 요청이 끝나면 초기화되지만 JWT 환경에서는 매 요청마다 필터가 이곳에 다시 값을 채워둠
+* **AuthenticationManager**
+ * 인증을 처리하는 최상위 인터페이스로 해당 인증 요청을 처리하라는 `authenticate()` 메서드 하나를 가지고있음
+ * 실질적인 구현체는 대부분 ProviderManager 사용
+* **ProviderManager**
+ * AuthenticationManager의 구현체.. 스스로 인증을 하지 않고 여러 개의 AuthenticationProvider 목록을 순회하며 인증 처리를 위임
+* **AuthenticationProvider**
+ * 인증 로직을 수행하는 위치
+ * `authenticate()`: 실제 검증 로직 수행
+ * `supports()`: 해당 Provider가 특정 Authentication 타입을 처리할 수 있는지 확인
+* **UserDetailsService**
+ * DB나 메모리 등에서 유저 정보를 가져오는 역할
+ * username으로 DB를 조회 → UserDetails 객체 반환
+* **UserDetails**
+ * Spring Security가 내부적으로 사용하는 유저 정보 표준 인터페이스
+ * User 엔티티를 이 인터페이스에 맞춰 감싸거나(CustomUserDetails)/ 엔티티가 직접 구현하도록 하여 Spring Security에 전달
+
+## 인가 (Authorization)
+해당 사용자가 이 리소스에 접근할 권한이 있는지 확인
+
+* **GrantedAuthority**
+ * 사용자 권한
+ * SimpleGrantedAuthority 구현체 사용, ROLE_ADMIN 와 같은 문자열 형태로 저장
+ * Authentication 객체 안에 리스트 형태로 저장
+* **AuthorizationFilter**
+ * 필터 체인의 마지막부에 위치하고 URL 별로 필요한 권한이 있는지 검사
+ * SecurityConfig에서 작성한 `requestMatchers("/admin/**").hasRole("ADMIN")` 정보를 바탕으로 판단
+
+---
+
+# Stateful vs Stateless
+
+* **Stateful (상태 유지)**
+ * 서버가 클라이언트와의 통신 상태나 이전 요청 데이터를 메모리나 디스크에 계속 저장하고 기억하는 네트워크 통신 방식
+ * 클라이언트가 첫 번째 요청에서 인증을 완료하면 서버가 이를 기억하고 있으므로 두 번째 요청부터는 추가 인증 없이 작업을 처리할 수 있다
+* **Stateless (무상태)**
+ * 서버가 클라이언트의 상태를 전혀 저장하지 않는 네트워크 통신 방식임
+ * 서버는 이전 요청이 무엇이었는지 전혀 알지 못하므로 클라이언트는 매 요청마다 서버가 작업을 처리하는 데 필요한 모든 데이터를 온전히 담아서 보내야 함
+
+| 구분 | Stateful (상태 유지) | Stateless (무상태) |
+| :--- | :--- | :--- |
+| **공통점** | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 |
+| **상태 저장** | 서버가 클라이언트의 이전 상태와 컨텍스트를 저장함 | 서버는 클라이언트의 상태를 저장하지 않음 |
+| **요청 데이터** | 서버가 정보를 기억함 → 클라이언트는 최소한의 데이터만 전송 | 서버가 기억하지 않으므로 요청할 때마다 모든 데이터를 전송해야 함 |
+| **서버** | 접속자가 많아질수록 서버 메모리 사용량이 급격히 증가 | 상태 저장을 하지 않음 → 서버 메모리 부담이 적음 |
+| **확장성** | 여러 서버로 트래픽을 분산하는 Scale-out이 어려움 | 서버 간 상태 공유가 필요 없어 서버 확장에 자유로움 |
+| **장애 대응** | 연결된 서버가 다운되면 해당 서버에 저장된 상태 정보가 날아감 → 다시 재작업 | 특정 서버가 다운되어도 다른 서버가 동일하게 요청을 처리할 수 있어 장애 대응에 유연 |
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
index e56ec820..d2acaf42 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java
@@ -1,6 +1,7 @@
package com.study.UMC10.domain.mission.controller;
import com.study.UMC10.domain.mission.code.MissionSuccessCode;
+import com.study.UMC10.domain.mission.dto.request.MissionRequestDto;
import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
import com.study.UMC10.domain.mission.service.MissionService;
import com.study.UMC10.global.apiPayload.ApiResponse;
@@ -10,12 +11,7 @@
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@@ -25,18 +21,21 @@ public class MissionController {
private final MissionService missionService;
- @Operation(summary = "미션 목록 조회 API", description = "상태값에 따른 미션 목록을 조회하는 API입니다.")
+ @Operation(summary = "미션 목록 조회 API", description = "나의 미션 목록을 조회합니다.")
@Parameters({
@Parameter(name = "MissionStatus", description = "조회할 미션 상태", example = "IN_PROGRESS, SUCCESS, FAILED"),
- @Parameter(name = "page", description = "페이지 번호", example = "0")
+ @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10"),
+ @Parameter(name = "pageNumber", description = "페이지 번호", example = "0")
})
@GetMapping("/v1/missions")
- public ApiResponse getMissions(
+ public ApiResponse> getMissions(
+ @RequestBody MissionRequestDto.GetMyMissionsDto requestDto,
@RequestParam("MissionStatus") String status,
- @RequestParam(name = "page", defaultValue = "0") Integer page
+ @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+ @RequestParam(name = "pageNumber", defaultValue = "0") Integer pageNumber
) {
BaseSuccessCode code = MissionSuccessCode.OK;
- return ApiResponse.onSuccess(code, missionService.getMissions(status, page));
+ return ApiResponse.onSuccess(code, missionService.getMissions(requestDto.userId(), status, pageSize, pageNumber));
}
@Operation(summary = "미션 완료 처리 API", description = "진행 중인 미션을 완료 상태로 변경하는 API입니다.")
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
index 8584fa2b..58fe9a35 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java
@@ -1,4 +1,19 @@
package com.study.UMC10.domain.mission.converter;
+import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
+
+import java.util.List;
+
public class MissionConverter {
+ public static MissionResponseDto.Pagination toPagination(
+ List data,
+ Integer pageNumber,
+ Integer pageSize
+ ){
+ return MissionResponseDto.Pagination.builder()
+ .data(data)
+ .pageNumber(pageNumber)
+ .pageSize(pageSize)
+ .build();
+ }
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
index fc6eeee9..01f321a5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java
@@ -1,4 +1,17 @@
package com.study.UMC10.domain.mission.dto.request;
+import io.swagger.v3.oas.annotations.media.Schema;
+
public class MissionRequestDto {
-}
+
+ /*
+ * NOTE:
+ * GET 요청에 Request Body를 사용하지 않지만 미션 요구사항에 따라 Request Body로 userId를 전달받음
+ */
+ @Schema(description = "진행 중인 미션 조회 요청")
+ public record GetMyMissionsDto(
+ @Schema(description = "조회할 유저 ID", example = "1")
+ Long userId
+ ) {
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
index dae8104f..77dce4dc 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java
@@ -44,4 +44,11 @@ public record MissionCompleteResultDto(
String status
) {
}
+
+ @Builder
+ public record Pagination(
+ List data,
+ Integer pageNumber,
+ Integer pageSize
+ ) {}
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
index 09accced..ac89d900 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java
@@ -1,5 +1,6 @@
package com.study.UMC10.domain.mission.service;
+import com.study.UMC10.domain.mission.converter.MissionConverter;
import com.study.UMC10.domain.mission.dto.response.MissionResponseDto;
import com.study.UMC10.domain.mission.entity.UserMission;
import com.study.UMC10.domain.mission.enums.MissionStatus;
@@ -20,16 +21,14 @@ public class MissionService {
private final UserMissionRepository userMissionRepository;
@Transactional(readOnly = true)
- public MissionResponseDto.MissionListDto getMissions(String status, Integer page) {
-
- // 임시 유저
- Long dummyUserId = 1L;
+ public MissionResponseDto.Pagination getMissions(
+ Long userId, String status, Integer pageSize, Integer pageNumber) {
MissionStatus missionStatus = MissionStatus.valueOf(status.toUpperCase());
- // 페이징
- PageRequest pageRequest = PageRequest.of(page, 10);
- Page userMissionPage = userMissionRepository.findMyMissions(dummyUserId, missionStatus, pageRequest);
+ PageRequest pageRequest = PageRequest.of(pageNumber, pageSize);
+
+ Page userMissionPage = userMissionRepository.findMyMissions(userId, missionStatus, pageRequest);
List missionDetailDtoList = userMissionPage.stream()
.map(userMission -> MissionResponseDto.MissionDetailDto.builder()
@@ -41,12 +40,17 @@ public MissionResponseDto.MissionListDto getMissions(String status, Integer page
.build())
.collect(Collectors.toList());
- return MissionResponseDto.MissionListDto.builder()
- .missions(missionDetailDtoList)
- .build();
+ return MissionConverter.toPagination(
+ missionDetailDtoList,
+ userMissionPage.getNumber(),
+ userMissionPage.getSize()
+ );
}
- // 아직 구현Xx
+ /*
+ * NOTE:
+ * 아직 구현 안함
+ */
public MissionResponseDto.MissionCompleteResultDto completeMission(Long missionId) {
return null;
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
index ea33545e..f5f60866 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java
@@ -6,14 +6,20 @@
import com.study.UMC10.domain.review.service.ReviewService;
import com.study.UMC10.global.apiPayload.ApiResponse;
import com.study.UMC10.global.apiPayload.code.BaseSuccessCode;
+import com.study.UMC10.global.security.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -29,10 +35,29 @@ public class ReviewController {
@PostMapping("/v1/stores/{storeId}/reviews")
public ApiResponse createReview(
@PathVariable("storeId") Long storeId,
- @RequestBody ReviewRequestDto.CreateReviewDto requestDto
+ @RequestBody @Valid ReviewRequestDto.CreateReviewDto requestDto
) {
BaseSuccessCode code = ReviewSuccessCode.REVIEW_CREATED;
return ApiResponse.onSuccess(code, reviewService.createReview(storeId, requestDto));
}
+
+ @Operation(summary = "내가 작성한 리뷰 목록 조회 API", description = "커서 기반 페이징으로 내가 작성한 리뷰를 최신순/별점순으로 조회합니다.")
+ @Parameters({
+ @Parameter(name = "userId", description = "조회할 유저 ID", example = "1"),
+ @Parameter(name = "query", description = "정렬 기준(최신순: id, 별점순: rate)", example = "id"),
+ @Parameter(name = "cursor", description = "다음 페이지 커서/ 최초 요청 시 -1 입력", example = "-1"),
+ @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10")
+ })
+ @GetMapping("/v1/reviews/me")
+ public ApiResponse> getMyReviews(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @RequestParam(name = "query", defaultValue = "id") String query,
+ @RequestParam(name = "cursor", defaultValue = "-1") String cursor,
+ @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize
+ ) {
+ Long userId = customUserDetails.getUser().getId();
+ BaseSuccessCode code = com.study.UMC10.global.apiPayload.code.GeneralSuccessCode.OK;
+ return ApiResponse.onSuccess(code, reviewService.getMyReviews(userId, query, cursor, pageSize));
+ }
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
index 9ff77d70..b7159e16 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java
@@ -1,15 +1,24 @@
package com.study.UMC10.domain.review.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
public class ReviewRequestDto {
@Schema(description = "리뷰 생성")
public record CreateReviewDto(
+
+ @NotNull(message = "별점은 필수 입력 항목입니다.")
+ @DecimalMin(value = "0.0", message = "별점은 0.0 이상이어야 합니다.")
+ @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.")
@Schema(description = "가게 별점", example = "4.5")
Double rate,
- @Schema(description = "리뷰 내용", example = "음식이 맛있습니다!")
+ @NotBlank(message = "리뷰 내용은 비어있을 수 없습니다.")
+ @Schema(description = "리뷰 내용", example = "음식이 맛있어요!")
String content
) {
}
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
index 1e32ccfc..f2d064c5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
+import java.time.LocalDate;
import java.util.List;
public class ReviewResponseDto {
@@ -26,4 +27,45 @@ public record CreateReviewResultDto(
List images
) {
}
+
+ @Builder
+ @Schema(description = "내가 작성한 리뷰 상세 정보")
+ public record MyReviewDto(
+ @Schema(description = "리뷰 ID", example = "1")
+ Long reviewId,
+
+ @Schema(description = "가게 이름", example = "반이학생마라탕마라반")
+ String storeName,
+
+ @Schema(description = "유저 닉네임", example = "닉네임1234")
+ String nickname,
+
+ @Schema(description = "별점", example = "4.5")
+ Double score,
+
+ @Schema(description = "작성 날짜", example = "2022.05.14")
+ LocalDate createdAt,
+
+ @Schema(description = "사장님 답글 내용 (없으면 null)", example = "감사합니다.")
+ String ownerComment,
+
+ @Schema(description = "사장님 답글 작성 날짜 (없으면 null)", example = "2022.05.15")
+ LocalDate ownerCommentCreatedAt
+ ) {}
+
+ @Builder
+ @Schema(description = "커서 기반 페이징")
+ public record CursorPagination(
+ @Schema(description = "데이터 리스트")
+ List data,
+
+ @Schema(description = "다음 페이지 존재 여부", example = "true")
+ Boolean hasNext,
+
+ @Schema(description = "다음 페이지 조회를 위한 커서 값", example = "383:383")
+ String nextCursor,
+
+ @Schema(description = "요청한 데이터 수", example = "10")
+ Integer pageSize
+ ) {}
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
index ad412495..1b0e9ef5 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java
@@ -3,17 +3,7 @@
import com.study.UMC10.domain.store.entity.Store;
import com.study.UMC10.domain.user.entity.User;
import com.study.UMC10.global.apiPayload.code.BaseEntity;
-import jakarta.persistence.CascadeType;
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.FetchType;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.JoinColumn;
-import jakarta.persistence.ManyToOne;
-import jakarta.persistence.OneToMany;
-import jakarta.persistence.Table;
+import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -51,4 +41,7 @@ public class Review extends BaseEntity {
@OneToMany(mappedBy = "review", cascade = CascadeType.ALL)
private List reviewPhotoList = new ArrayList<>();
+
+ @OneToOne(mappedBy = "review", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
+ private OwnerComment ownerComment;
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
index 21d2d2f2..7e4065ea 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java
@@ -1,7 +1,30 @@
package com.study.UMC10.domain.review.repository;
import com.study.UMC10.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 JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.id DESC")
+ Slice findMyReviewsOrderByIdDesc(@Param("userId") Long userId, Pageable pageable);
+
+ // 커서 조회
+ @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND r.id < :cursorId ORDER BY r.id DESC")
+ Slice findMyReviewsByCursorId(@Param("userId") Long userId, @Param("cursorId") Long cursorId, Pageable pageable);
+
+ // 별점 순
+ // 최초 조회
+ @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.score DESC, r.id DESC")
+ Slice findMyReviewsOrderByScoreDesc(@Param("userId") Long userId, Pageable pageable);
+
+ // 커서 조회
+ @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND (r.score < :cursorScore OR (r.score = :cursorScore AND r.id < :cursorId)) ORDER BY r.score DESC, r.id DESC")
+ Slice findMyReviewsByCursorScoreAndId(@Param("userId") Long userId, @Param("cursorScore") Double cursorScore, @Param("cursorId") Long cursorId, Pageable pageable);
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
index 8b2859ab..625fc69d 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java
@@ -9,10 +9,14 @@
import com.study.UMC10.domain.user.entity.User;
import com.study.UMC10.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -25,7 +29,11 @@ public class ReviewService {
@Transactional
public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, ReviewRequestDto.CreateReviewDto requestDto) {
- // 임시 유저 (로그인 구현 X)
+ /*
+ * NOTE:
+ * 임시 유저로 하드 코딩
+ * 추후 로그인 구현 후 수정 예정
+ */
Long dummyUserId = 1L;
User user = userRepository.findById(dummyUserId)
.orElseThrow(() -> new RuntimeException("해당 유저를 찾을 수 없습니다."));
@@ -51,4 +59,65 @@ public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, Review
.images(new ArrayList<>())
.build();
}
+
+ // 내 리뷰 목록 조회
+ @Transactional(readOnly = true)
+ public ReviewResponseDto.CursorPagination getMyReviews(
+ Long userId, String query, String cursor, Integer pageSize) {
+
+ PageRequest pageRequest = PageRequest.of(0, pageSize);
+ Slice reviewSlice;
+
+ if (query.equalsIgnoreCase("rate")) {
+ // 별점 순
+ if (cursor.equals("-1")) {
+ reviewSlice = reviewRepository.findMyReviewsOrderByScoreDesc(userId, pageRequest);
+ } else {
+ String[] cursorSplit = cursor.split(":");
+ Double scoreCursor = Double.parseDouble(cursorSplit[0]);
+ Long idCursor = Long.parseLong(cursorSplit[1]);
+ reviewSlice = reviewRepository.findMyReviewsByCursorScoreAndId(userId, scoreCursor, idCursor, pageRequest);
+ }
+ } else {
+ // 리뷰 ID 순
+ if (cursor.equals("-1")) {
+ reviewSlice = reviewRepository.findMyReviewsOrderByIdDesc(userId, pageRequest);
+ } else {
+ Long idCursor = Long.parseLong(cursor);
+ reviewSlice = reviewRepository.findMyReviewsByCursorId(userId, idCursor, pageRequest);
+ }
+ }
+
+ List dtoList = reviewSlice.getContent().stream()
+ .map(review -> ReviewResponseDto.MyReviewDto.builder()
+ .reviewId(review.getId())
+ .storeName(review.getStore().getStoreName())
+ .nickname(review.getUser().getNickname())
+ .score(review.getScore())
+ .createdAt(review.getCreatedAt() != null ? review.getCreatedAt().toLocalDate() : null)
+ .ownerComment(review.getOwnerComment() != null ? review.getOwnerComment().getOwnerCommentContent() : null)
+ .ownerCommentCreatedAt(review.getOwnerComment() != null && review.getOwnerComment().getCreatedAt() != null ? review.getOwnerComment().getCreatedAt().toLocalDate() : null)
+ .build())
+ .collect(Collectors.toList());
+
+ // 다음 커서 계산
+ String nextCursor = "-1";
+ if (reviewSlice.hasNext() && !reviewSlice.getContent().isEmpty()) {
+ Review lastReview = reviewSlice.getContent().get(reviewSlice.getContent().size() - 1);
+ if (query.equalsIgnoreCase("rate")) {
+ // 별점 순 커서: (별점:리뷰ID) .. 별점 같을 시 리뷰 ID순
+ nextCursor = lastReview.getScore() + ":" + lastReview.getId();
+ } else {
+ // 최신순 커서: 리뷰ID
+ nextCursor = String.valueOf(lastReview.getId());
+ }
+ }
+
+ return ReviewResponseDto.CursorPagination.builder()
+ .data(dtoList)
+ .hasNext(reviewSlice.hasNext())
+ .nextCursor(nextCursor)
+ .pageSize(reviewSlice.getSize())
+ .build();
+ }
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java
index f5a24083..2157e3c7 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java
@@ -9,13 +9,12 @@
@RequiredArgsConstructor
public enum UserErrorCode implements BaseErrorCode {
- MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,
- "MEMBER404_1",
- "해당 사용자를 찾을 수 없습니다.")
+ MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."),
+ EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER400_1", "이미 사용 중인 이메일입니다."),
+ INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER400_2", "올바르지 않은 성별 형식입니다.")
;
private final HttpStatus status;
private final String code;
private final String message;
-
-}
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java
index e87eefe9..022ca303 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java
@@ -46,6 +46,9 @@ public class User extends BaseEntity {
@Column(name = "email", nullable = false, length = 40)
private String email;
+ @Column(name = "password", nullable = false)
+ private String password;
+
@Column(name = "nickname", length = 20)
private String nickname;
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java
index 8980ff3e..a2e5f834 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java
@@ -3,5 +3,9 @@
import com.study.UMC10.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Optional;
+
public interface UserRepository extends JpaRepository {
+ Optional findByEmail(String email);
+ boolean existsByEmail(String email);
}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java
index 7a1c0496..d9aa3f93 100644
--- a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java
+++ b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java
@@ -4,7 +4,6 @@
import com.study.UMC10.domain.mission.repository.MissionRepository;
import com.study.UMC10.domain.user.code.UserErrorCode;
import com.study.UMC10.domain.user.code.UserException;
-import com.study.UMC10.domain.user.converter.UserConverter;
import com.study.UMC10.domain.user.dto.request.UserRequestDto;
import com.study.UMC10.domain.user.dto.response.UserResponseDto;
import com.study.UMC10.domain.user.entity.User;
@@ -12,6 +11,7 @@
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;
@@ -24,12 +24,12 @@ public class UserService {
private final UserRepository userRepository;
private final MissionRepository missionRepository;
+ private final PasswordEncoder passwordEncoder;
// 마이페이지
@Transactional(readOnly = true)
public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) {
Long userId = dto.id();
-
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND));
@@ -43,8 +43,48 @@ public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) {
.build();
}
+ // 회원가입
+ @Transactional
public UserResponseDto.SignUpResultDto signUp(UserRequestDto.SignUpDto requestDto) {
- return null;
+
+ // 이메일 중복 체크
+ if (userRepository.existsByEmail(requestDto.email())) {
+ throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS);
+ }
+
+ String encodedPassword = passwordEncoder.encode(requestDto.password());
+
+ com.study.UMC10.domain.user.enums.Gender userGender;
+ try {
+ userGender = com.study.UMC10.domain.user.enums.Gender.valueOf(requestDto.gender().toUpperCase());
+ } catch (Exception e) {
+ throw new UserException(UserErrorCode.INVALID_GENDER);
+ }
+
+ java.time.LocalDate userBirth = null;
+ if (requestDto.birth() != null && !requestDto.birth().isBlank()) {
+ userBirth = java.time.LocalDate.parse(requestDto.birth());
+ }
+
+ User newUser = User.builder()
+ .email(requestDto.email())
+ .password(encodedPassword)
+ .name(requestDto.name())
+ .nickname(requestDto.nickname())
+ .address(requestDto.address())
+ .gender(userGender)
+ .birth(userBirth)
+ .totalPoint(0)
+ .finMission(0)
+ .status(com.study.UMC10.domain.user.enums.UserStatus.ACTIVE)
+ .build();
+
+ User savedUser = userRepository.save(newUser);
+
+ return UserResponseDto.SignUpResultDto.builder()
+ .userId(savedUser.getId())
+ .name(savedUser.getName())
+ .build();
}
// 홈 화면 (지역별 미션 조회 + 페이징)
@@ -56,7 +96,6 @@ public UserResponseDto.HomeResultDto getHome(String region, Integer page) {
.orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND));
PageRequest pageRequest = PageRequest.of(page, 10);
-
Page missionPage = missionRepository.findMissionsByRegion(region, pageRequest);
List missionDtoList = missionPage.stream()
diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java
index 66e7a667..a3b078db 100644
--- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java
+++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java
@@ -2,6 +2,7 @@
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
@@ -9,6 +10,7 @@
import java.time.LocalDateTime;
+@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
index dc0fe1b3..7dff42f9 100644
--- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
+++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java
@@ -5,9 +5,13 @@
import com.study.UMC10.global.apiPayload.code.GeneralErrorCode;
import com.study.UMC10.global.apiPayload.exception.GeneralException;
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,22 @@ public ResponseEntity> handleMemberException(
.body(ApiResponse.onFailure(errorCode, null));
}
+ @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(
diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java
new file mode 100644
index 00000000..a2a90ab6
--- /dev/null
+++ b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java
@@ -0,0 +1,68 @@
+package com.study.UMC10.global.config;
+
+import com.study.UMC10.global.security.CustomAccessDeniedHandler;
+import com.study.UMC10.global.security.CustomAuthenticationEntryPoint;
+import lombok.RequiredArgsConstructor;
+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
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final CustomAccessDeniedHandler customAccessDeniedHandler;
+ private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
+
+ // 인증x 접근 가능한 Public API
+ private final String[] allowUris = {
+ "/swagger-ui/**",
+ "/swagger-resources/**",
+ "/v3/api-docs/**",
+ "/api/auth/**"
+ };
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+
+ .authorizeHttpRequests(requests -> requests
+ .requestMatchers(allowUris).permitAll() // Public API는 허용
+ .anyRequest().authenticated() // 그 외의 Private API는 인증 필요
+ )
+
+ // 폼 로그인 설정
+ .formLogin(form -> form
+ .defaultSuccessUrl("/swagger-ui/index.html", true)
+ .permitAll()
+ )
+
+ // 로그아웃 설정
+ .logout(logout -> logout
+ .logoutUrl("/logout")
+ .logoutSuccessUrl("/login?logout")
+ .permitAll()
+ )
+
+ // 예외 핸들러
+ .exceptionHandling(exception -> exception
+ .accessDeniedHandler(customAccessDeniedHandler)
+ .authenticationEntryPoint(customAuthenticationEntryPoint)
+ );
+
+ return http.build();
+ }
+
+ // BCrypt 인코더 빈
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java
new file mode 100644
index 00000000..b7e74feb
--- /dev/null
+++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java
@@ -0,0 +1,30 @@
+package com.study.UMC10.global.security;
+// 로그인 후 권한 없을 경우
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.study.UMC10.global.apiPayload.ApiResponse;
+import com.study.UMC10.global.apiPayload.code.GeneralErrorCode;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class CustomAccessDeniedHandler implements AccessDeniedHandler {
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
+ ObjectMapper objectMapper = new ObjectMapper();
+ GeneralErrorCode code = GeneralErrorCode.FORBIDDEN;
+
+ response.setContentType("application/json; charset=UTF-8");
+ response.setStatus(code.getStatus().value());
+
+ ApiResponse errorResponse = ApiResponse.onFailure(code, null);
+
+ objectMapper.writeValue(response.getOutputStream(), errorResponse);
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java
new file mode 100644
index 00000000..dcf05571
--- /dev/null
+++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java
@@ -0,0 +1,30 @@
+package com.study.UMC10.global.security;
+//인증되지 않은 사용자가 Private API 접근 시
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.study.UMC10.global.apiPayload.ApiResponse;
+import com.study.UMC10.global.apiPayload.code.GeneralErrorCode;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
+ ObjectMapper objectMapper = new ObjectMapper();
+ GeneralErrorCode code = GeneralErrorCode.UNAUTHORIZED;
+
+ response.setContentType("application/json; charset=UTF-8");
+ response.setStatus(code.getStatus().value());
+
+ ApiResponse errorResponse = ApiResponse.onFailure(code, null);
+
+ objectMapper.writeValue(response.getOutputStream(), errorResponse);
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java
new file mode 100644
index 00000000..a7b00361
--- /dev/null
+++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java
@@ -0,0 +1,53 @@
+package com.study.UMC10.global.security;
+
+import com.study.UMC10.domain.user.entity.User;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+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 CustomUserDetails implements UserDetails {
+
+ private final User user;
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return List.of(new SimpleGrantedAuthority("ROLE_USER"));
+ }
+
+ @Override
+ public String getPassword() {
+ return user.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getEmail();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java
new file mode 100644
index 00000000..9957bd8a
--- /dev/null
+++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java
@@ -0,0 +1,24 @@
+package com.study.UMC10.global.security;
+
+import com.study.UMC10.domain.user.entity.User;
+import com.study.UMC10.domain.user.repository.UserRepository;
+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 UserRepository userRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ User user = userRepository.findByEmail(username)
+ .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 사용자를 찾을 수 없습니다: " + username));
+
+ return new CustomUserDetails(user);
+ }
+}
\ No newline at end of file