diff --git a/22jiwon/.idea/.gitignore b/22jiwon/.idea/.gitignore
new file mode 100644
index 0000000..c3f502a
--- /dev/null
+++ b/22jiwon/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 디폴트 무시된 파일
+/shelf/
+/workspace.xml
+# 에디터 기반 HTTP 클라이언트 요청
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/22jiwon/.idea/22jiwon.iml b/22jiwon/.idea/22jiwon.iml
new file mode 100644
index 0000000..d6ebd48
--- /dev/null
+++ b/22jiwon/.idea/22jiwon.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/compiler.xml b/22jiwon/.idea/compiler.xml
new file mode 100644
index 0000000..cc849ee
--- /dev/null
+++ b/22jiwon/.idea/compiler.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/dataSources.xml b/22jiwon/.idea/dataSources.xml
new file mode 100644
index 0000000..5fbe03c
--- /dev/null
+++ b/22jiwon/.idea/dataSources.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ mysql.8
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://localhost:3306/umc9th
+
+
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/gradle.xml b/22jiwon/.idea/gradle.xml
new file mode 100644
index 0000000..6d8e4e9
--- /dev/null
+++ b/22jiwon/.idea/gradle.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/misc.xml b/22jiwon/.idea/misc.xml
new file mode 100644
index 0000000..3ecc1af
--- /dev/null
+++ b/22jiwon/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/modules.xml b/22jiwon/.idea/modules.xml
new file mode 100644
index 0000000..3c80a1c
--- /dev/null
+++ b/22jiwon/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/.idea/vcs.xml b/22jiwon/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/22jiwon/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/22jiwon/chapter4/.gitattributes b/22jiwon/chapter4/.gitattributes
new file mode 100644
index 0000000..8af972c
--- /dev/null
+++ b/22jiwon/chapter4/.gitattributes
@@ -0,0 +1,3 @@
+/gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary
diff --git a/22jiwon/chapter4/.gitignore b/22jiwon/chapter4/.gitignore
new file mode 100644
index 0000000..57003d3
--- /dev/null
+++ b/22jiwon/chapter4/.gitignore
@@ -0,0 +1,38 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+/generated/querydsl/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
diff --git a/22jiwon/chapter4/build.gradle b/22jiwon/chapter4/build.gradle
new file mode 100644
index 0000000..6afe63c
--- /dev/null
+++ b/22jiwon/chapter4/build.gradle
@@ -0,0 +1,69 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.5.6'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'com.example'
+version = '0.0.1-SNAPSHOT'
+description = 'Demo project for Spring Boot'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.security:spring-security-crypto'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'io.jsonwebtoken:jjwt:0.9.1'
+ compileOnly 'org.projectlombok:lombok'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ annotationProcessor 'org.projectlombok:lombok'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ // QueryDSL : OpenFeign
+ implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0"
+ implementation "io.github.openfeign.querydsl:querydsl-core:7.0"
+ annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+// QueryDSL 관련 설정
+// generated/querydsl 폴더 생성 & 삽입
+def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
+
+// 소스 세트에 생성 경로 추가 (구체적인 경로 지정)
+sourceSets {
+ main.java.srcDirs += [ querydslDir ]
+}
+
+// 컴파일 시 생성 경로 지정
+tasks.withType(JavaCompile).configureEach {
+ options.generatedSourceOutputDirectory.set(querydslDir)
+}
+
+// clean 태스크에 생성 폴더 삭제 로직 추가
+clean.doLast {
+ file(querydslDir).deleteDir()
+}
diff --git a/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.jar b/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.properties b/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/22jiwon/chapter4/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/22jiwon/chapter4/gradlew b/22jiwon/chapter4/gradlew
new file mode 100644
index 0000000..23d15a9
--- /dev/null
+++ b/22jiwon/chapter4/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/22jiwon/chapter4/gradlew.bat b/22jiwon/chapter4/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/22jiwon/chapter4/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/22jiwon/chapter4/settings.gradle b/22jiwon/chapter4/settings.gradle
new file mode 100644
index 0000000..1cccfaf
--- /dev/null
+++ b/22jiwon/chapter4/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'chapter4'
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/Chapter4Application.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/Chapter4Application.java
new file mode 100644
index 0000000..58095c7
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/Chapter4Application.java
@@ -0,0 +1,15 @@
+package com.example.chapter4;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@EnableJpaAuditing
+@SpringBootApplication
+public class Chapter4Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Chapter4Application.class, args);
+ }
+
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/QueryDslConfig.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/QueryDslConfig.java
new file mode 100644
index 0000000..fceabd8
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/QueryDslConfig.java
@@ -0,0 +1,20 @@
+package com.example.chapter4;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class QueryDslConfig {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ // EntityManager를 주입받아 JPAQueryFactory를 생성하여 Bean으로 반환
+ return new JPAQueryFactory(entityManager);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/code/MissionErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/code/MissionErrorCode.java
new file mode 100644
index 0000000..3efed9f
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/code/MissionErrorCode.java
@@ -0,0 +1,19 @@
+package com.example.chapter4.domain.mission.code;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum MissionErrorCode implements BaseErrorCode {
+
+ NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION404_1", "존재하지 않는 미션입니다."),
+ ALREADY_CHALLENGED(HttpStatus.CONFLICT, "MISSION409_1", "이미 도전 중인 미션입니다."),
+ INVALID_STATUS(HttpStatus.BAD_REQUEST, "MISSION400_1", "미션 상태가 올바르지 않습니다.");
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/controller/MissionController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/controller/MissionController.java
new file mode 100644
index 0000000..f7f9ec6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/controller/MissionController.java
@@ -0,0 +1,114 @@
+package com.example.chapter4.domain.mission.controller;
+
+import com.example.chapter4.domain.mission.dto.MissionListResponseDto;
+import com.example.chapter4.domain.mission.dto.MissionResponseDto;
+import com.example.chapter4.domain.mission.dto.UserMissionChallengeRequestDto;
+import com.example.chapter4.domain.mission.dto.UserMissionChallengeResponseDto;
+import com.example.chapter4.domain.mission.dto.UserMissionListResponseDto;
+import com.example.chapter4.domain.mission.service.MissionService;
+import com.example.chapter4.global.annotation.CheckPage;
+import com.example.chapter4.global.annotation.ExistStore;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+@Validated
+@RestController
+@RequestMapping("/missions")
+@RequiredArgsConstructor
+public class MissionController {
+
+ private final MissionService missionService;
+
+ @GetMapping
+ public ApiResponse getMissions(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size
+ ) {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, missionService.getMissions(page, size));
+ }
+
+ @GetMapping("/{missionId}")
+ public ApiResponse getMission(@PathVariable Long missionId) {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, missionService.getMission(missionId));
+ }
+
+ @PostMapping("/{missionId}/challenge")
+ public ApiResponse challengeMission(
+ @PathVariable Long missionId,
+ @RequestBody @Valid UserMissionChallengeRequestDto request
+ ) {
+ request.setMissionId(missionId);
+ Long userId = 1L;
+ UserMissionChallengeResponseDto response = missionService.challengeMission(request, userId);
+ return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response);
+ }
+
+ // 특정 가게의 미션 목록 조회
+ @Operation(
+ summary = "특정 가게의 미션 목록 조회",
+ description = "storeId에 해당하는 미션을 10개 단위로 페이징 조회합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page 오류"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 없음")
+ })
+ @GetMapping("/stores/{storeId}")
+ public ApiResponse getMissionsByStore(
+ @PathVariable @ExistStore Long storeId,
+ @CheckPage Integer page
+ ) {
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.OK,
+ missionService.getMissionsByStore(storeId, page)
+ );
+ }
+
+ // 내가 진행중인 미션 목록 조회
+ @Operation(
+ summary = "내가 진행중인 미션 목록 조회",
+ description = "로그인한 사용자가 진행중(IN_PROGRESS)인 미션을 10개 단위로 페이징 조회합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page 오류")
+ })
+ @GetMapping("/me/in-progress")
+ public ApiResponse getMyInProgressMissions(
+ @CheckPage Integer page
+ ) {
+ Long userId = 1L; // 기존 컨벤션 유지(임시 하드코딩)
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.OK,
+ missionService.getMyInProgressMissions(userId, page)
+ );
+ }
+
+ // 진행중 미션 완료 처리 + 변경된 상태 반환
+ @Operation(
+ summary = "진행중 미션 완료 처리",
+ description = "내가 진행중(IN_PROGRESS)인 미션을 COMPLETED로 변경하고, 변경된 상태를 반환합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "진행중 미션이 아님"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "도전 기록 없음/미션 없음")
+ })
+ @PatchMapping("/{missionId}/complete")
+ public ApiResponse completeMission(
+ @PathVariable Long missionId
+ ) {
+ Long userId = 1L; // 기존 컨벤션 유지(임시 하드코딩)
+
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.OK,
+ missionService.completeMyMission(missionId, userId)
+ );
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/MissionConverter.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/MissionConverter.java
new file mode 100644
index 0000000..5c026e6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/MissionConverter.java
@@ -0,0 +1,35 @@
+package com.example.chapter4.domain.mission.converter;
+
+import com.example.chapter4.domain.mission.dto.MissionListResponseDto;
+import com.example.chapter4.domain.mission.dto.MissionResponseDto;
+import com.example.chapter4.domain.mission.entity.Mission;
+import org.springframework.data.domain.Page;
+
+public class MissionConverter {
+
+ public static MissionResponseDto toMissionResponseDto(Mission m) {
+ return MissionResponseDto.builder()
+ .id(m.getId())
+ .name(m.getName())
+ .description(m.getDescription())
+ .pointAwarded(m.getPointAwarded())
+ .deadline(m.getDeadline())
+ .storeId(m.getStore().getId())
+ .storeName(m.getStore().getName())
+ .build();
+ }
+
+ public static MissionListResponseDto toMissionListResponseDto(Page page) {
+ return MissionListResponseDto.builder()
+ .content(
+ page.getContent().stream()
+ .map(MissionConverter::toMissionResponseDto)
+ .toList()
+ )
+ .page(page.getNumber() + 1) // 다시 1-based
+ .size(page.getSize())
+ .totalElements(page.getTotalElements())
+ .totalPages(page.getTotalPages())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/UserMissionConverter.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/UserMissionConverter.java
new file mode 100644
index 0000000..9102122
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/converter/UserMissionConverter.java
@@ -0,0 +1,41 @@
+package com.example.chapter4.domain.mission.converter;
+
+import com.example.chapter4.domain.mission.dto.UserMissionListResponseDto;
+import com.example.chapter4.domain.mission.entity.UserMissionCompletion;
+import org.springframework.data.domain.Page;
+
+public class UserMissionConverter {
+
+ public static UserMissionListResponseDto.UserMissionPreviewDto toUserMissionPreviewDto(
+ UserMissionCompletion umc
+ ){
+ var m = umc.getMission();
+ return UserMissionListResponseDto.UserMissionPreviewDto.builder()
+ .missionId(m.getId())
+ .missionName(m.getName())
+ .storeName(m.getStore().getName())
+ .pointAwarded(m.getPointAwarded())
+ .deadline(m.getDeadline())
+ .status(umc.getStatus())
+ .challengedAt(umc.getChallengedAt())
+ .build();
+ }
+
+ public static UserMissionListResponseDto.UserMissionPreviewListDto toUserMissionPreviewListDto(
+ Page page
+ ){
+ return UserMissionListResponseDto.UserMissionPreviewListDto.builder()
+ .content(
+ page.getContent().stream()
+ .map(UserMissionConverter::toUserMissionPreviewDto)
+ .toList()
+ )
+ .page(page.getNumber() + 1)
+ .size(page.getSize())
+ .totalElements(page.getTotalElements())
+ .totalPages(page.getTotalPages())
+ .isFirst(page.isFirst())
+ .isLast(page.isLast())
+ .build();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionListResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionListResponseDto.java
new file mode 100644
index 0000000..244384e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionListResponseDto.java
@@ -0,0 +1,18 @@
+package com.example.chapter4.domain.mission.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@Builder
+@AllArgsConstructor
+public class MissionListResponseDto {
+ private final List content;
+ private final int page;
+ private final int size;
+ private final long totalElements;
+ private final int totalPages;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionResponseDto.java
new file mode 100644
index 0000000..781e112
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/MissionResponseDto.java
@@ -0,0 +1,31 @@
+package com.example.chapter4.domain.mission.dto;
+
+import com.example.chapter4.domain.mission.entity.Mission;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Builder
+@AllArgsConstructor
+public class MissionResponseDto {
+ private Long id;
+ private String name;
+ private String description;
+ private Integer pointAwarded;
+ private LocalDateTime deadline;
+ private Long storeId;
+ private String storeName;
+
+ public MissionResponseDto(Mission m) {
+ this.id = m.getId();
+ this.name = m.getName();
+ this.description = m.getDescription();
+ this.pointAwarded = m.getPointAwarded();
+ this.deadline = m.getDeadline();
+ this.storeId = m.getStore().getId();
+ this.storeName = m.getStore().getName();
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeRequestDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeRequestDto.java
new file mode 100644
index 0000000..b770127
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeRequestDto.java
@@ -0,0 +1,19 @@
+package com.example.chapter4.domain.mission.dto;
+
+import com.example.chapter4.global.annotation.ExistMission;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import jakarta.validation.constraints.NotNull;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserMissionChallengeRequestDto {
+ @NotNull(message = "미션ID는 필수입니다.")
+ @ExistMission
+ private Long missionId;
+ // 필요시 userId도 받음(인증 없이 테스트용), 보통은 SecurityContext에서 가져옴
+
+ // 생성자 등 추가 필요시
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeResponseDto.java
new file mode 100644
index 0000000..29dab4a
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionChallengeResponseDto.java
@@ -0,0 +1,49 @@
+package com.example.chapter4.domain.mission.dto;
+
+import com.example.chapter4.domain.mission.entity.UserMissionCompletion;
+import com.example.chapter4.domain.mission.entity.UserMissionStatus;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor
+public class UserMissionChallengeResponseDto {
+
+ private Long userMissionId;
+ private Long userId;
+ private Long missionId;
+ private UserMissionStatus status;
+ private LocalDateTime challengedAt;
+ private LocalDateTime completedAt;
+
+ @Builder
+ public UserMissionChallengeResponseDto(
+ Long userMissionId,
+ Long userId,
+ Long missionId,
+ UserMissionStatus status,
+ LocalDateTime challengedAt,
+ LocalDateTime completedAt
+ ) {
+ this.userMissionId = userMissionId;
+ this.userId = userId;
+ this.missionId = missionId;
+ this.status = status;
+ this.challengedAt = challengedAt;
+ this.completedAt = completedAt;
+ }
+
+ public static UserMissionChallengeResponseDto from(UserMissionCompletion entity) {
+ return UserMissionChallengeResponseDto.builder()
+ .userMissionId(entity.getId())
+ .userId(entity.getUser().getId())
+ .missionId(entity.getMission().getId())
+ .status(entity.getStatus())
+ .challengedAt(entity.getChallengedAt())
+ .completedAt(entity.getCompletedAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionListResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionListResponseDto.java
new file mode 100644
index 0000000..8573ce2
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/dto/UserMissionListResponseDto.java
@@ -0,0 +1,39 @@
+package com.example.chapter4.domain.mission.dto;
+
+import com.example.chapter4.domain.mission.entity.UserMissionStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class UserMissionListResponseDto {
+
+ @Getter
+ @Builder
+ @AllArgsConstructor
+ public static class UserMissionPreviewDto {
+ private Long missionId;
+ private String missionName;
+ private String storeName;
+ private Integer pointAwarded;
+ private LocalDateTime deadline;
+
+ private UserMissionStatus status;
+ private LocalDateTime challengedAt;
+ }
+
+ @Getter
+ @Builder
+ @AllArgsConstructor
+ public static class UserMissionPreviewListDto {
+ private List content;
+ private int page; // 1-based
+ private int size; // 10
+ private long totalElements;
+ private int totalPages;
+ private boolean isFirst;
+ private boolean isLast;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/Mission.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/Mission.java
new file mode 100644
index 0000000..12808e1
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/Mission.java
@@ -0,0 +1,51 @@
+package com.example.chapter4.domain.mission.entity;
+
+import com.example.chapter4.domain.store.entity.Store;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "missions",
+ indexes = {
+ @Index(name = "idx_missions_store_id", columnList = "store_id")
+ }
+)
+public class Mission extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "mission_id")
+ private Long id;
+
+ @Column(name = "mission_name", nullable = false, length = 100)
+ private String name;
+
+ @Column(name = "mission_desc", nullable = false, length = 255)
+ private String description;
+
+ @Column(name = "point_awarded", nullable = false)
+ private Integer pointAwarded;
+
+ @Column(name = "deadline", nullable = false)
+ private LocalDateTime deadline;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "store_id", nullable = false)
+ private Store store;
+
+ @Builder
+ public Mission(String name, String description, Integer pointAwarded,
+ LocalDateTime deadline, Store store) {
+ this.name = name;
+ this.description = description;
+ this.pointAwarded = pointAwarded;
+ this.deadline = deadline;
+ this.store = store;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionCompletion.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionCompletion.java
new file mode 100644
index 0000000..a614ff9
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionCompletion.java
@@ -0,0 +1,61 @@
+package com.example.chapter4.domain.mission.entity;
+
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "user_mission_completions",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_user_mission", columnNames = {"user_id", "mission_id"})
+ },
+ indexes = {
+ @Index(name = "idx_umc_user", columnList = "user_id"),
+ @Index(name = "idx_umc_mission", columnList = "mission_id")
+ }
+)
+public class UserMissionCompletion extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "umc_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "mission_id", nullable = false)
+ private Mission mission;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ private UserMissionStatus status;
+
+ @Column(name = "challenged_at", nullable = false)
+ private LocalDateTime challengedAt;
+
+ @Column(name = "completed_at")
+ private LocalDateTime completedAt;
+
+ @Builder
+ public UserMissionCompletion(User user, Mission mission, UserMissionStatus status, LocalDateTime challengedAt, LocalDateTime completedAt) {
+ this.user = user;
+ this.mission = mission;
+ this.status = status;
+ this.challengedAt = challengedAt;
+ this.completedAt = completedAt;
+ }
+
+ public void completeMission() {
+ this.status = UserMissionStatus.COMPLETED;
+ this.completedAt = LocalDateTime.now();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionStatus.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionStatus.java
new file mode 100644
index 0000000..8247b69
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/entity/UserMissionStatus.java
@@ -0,0 +1,6 @@
+package com.example.chapter4.domain.mission.entity;
+
+public enum UserMissionStatus {
+ IN_PROGRESS,
+ COMPLETED
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/MissionRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/MissionRepository.java
new file mode 100644
index 0000000..d05735e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/MissionRepository.java
@@ -0,0 +1,19 @@
+package com.example.chapter4.domain.mission.repository;
+
+import com.example.chapter4.domain.mission.entity.Mission;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface MissionRepository extends JpaRepository {
+
+ // 특정 지역의 미션 조회 (기존)
+ @Query("select m from Mission m where m.store.region.id = :regionId")
+ Page findByRegionId(@Param("regionId") Long regionId, Pageable pageable);
+
+ // 특정 가게의 미션 조회 (페이징)
+ @Query("select m from Mission m where m.store.id = :storeId")
+ Page findByStoreId(@Param("storeId") Long storeId, Pageable pageable);
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/UserMissionCompletionRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/UserMissionCompletionRepository.java
new file mode 100644
index 0000000..00ed29f
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/repository/UserMissionCompletionRepository.java
@@ -0,0 +1,27 @@
+package com.example.chapter4.domain.mission.repository;
+
+import com.example.chapter4.domain.mission.entity.Mission;
+import com.example.chapter4.domain.mission.entity.UserMissionCompletion;
+import com.example.chapter4.domain.mission.entity.UserMissionStatus;
+import com.example.chapter4.domain.user.entity.User;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserMissionCompletionRepository extends JpaRepository {
+
+ // 유저-미션 단건 조회 (도전 중복 체크용)
+ Optional findByUserAndMission(User user, Mission mission);
+
+ // 내가 진행중(IN_PROGRESS)인 미션 목록 페이징 조회
+ Page findByUserIdAndStatus(
+ Long userId,
+ UserMissionStatus status,
+ Pageable pageable
+ );
+
+ // (기존 코드)
+ // Page findByUserId(Long userId, Pageable pageable);
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/service/MissionService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/service/MissionService.java
new file mode 100644
index 0000000..437f9ec
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/mission/service/MissionService.java
@@ -0,0 +1,130 @@
+package com.example.chapter4.domain.mission.service;
+
+import com.example.chapter4.domain.mission.converter.MissionConverter;
+import com.example.chapter4.domain.mission.dto.MissionListResponseDto;
+import com.example.chapter4.domain.mission.dto.MissionResponseDto;
+import com.example.chapter4.domain.mission.dto.UserMissionChallengeRequestDto;
+import com.example.chapter4.domain.mission.dto.UserMissionChallengeResponseDto;
+import com.example.chapter4.domain.mission.converter.UserMissionConverter;
+import com.example.chapter4.domain.mission.dto.UserMissionListResponseDto;
+import com.example.chapter4.domain.mission.entity.Mission;
+import com.example.chapter4.domain.mission.entity.UserMissionCompletion;
+import com.example.chapter4.domain.mission.entity.UserMissionStatus;
+import com.example.chapter4.domain.mission.repository.MissionRepository;
+import com.example.chapter4.domain.mission.repository.UserMissionCompletionRepository;
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.domain.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+@Service
+@RequiredArgsConstructor
+public class MissionService {
+
+ private final MissionRepository missionRepository;
+ private final UserMissionCompletionRepository userMissionCompletionRepository;
+ private final UserRepository userRepository;
+
+ // 기존 전체 미션 조회 (컨벤션 통일: Converter + Builder)
+ public MissionListResponseDto getMissions(int page, int size) {
+ Page missions = missionRepository.findAll(PageRequest.of(page, size));
+ return MissionConverter.toMissionListResponseDto(missions);
+ }
+
+ public MissionResponseDto getMission(Long missionId) {
+ Mission mission = missionRepository.findById(missionId)
+ .orElseThrow(() -> new IllegalArgumentException("미션 없음"));
+ return MissionConverter.toMissionResponseDto(mission);
+ }
+
+ // 특정 가게의 미션 목록 조회 (페이징 10개)
+ public MissionListResponseDto getMissionsByStore(Long storeId, int page0) {
+
+ PageRequest pageable = PageRequest.of(
+ page0,
+ 10,
+ Sort.by(Sort.Direction.DESC, "createdAt")
+ );
+
+ Page missions = missionRepository.findByStoreId(storeId, pageable);
+
+ return MissionConverter.toMissionListResponseDto(missions);
+ }
+
+ public UserMissionListResponseDto.UserMissionPreviewListDto getMyInProgressMissions(
+ Long userId, int page0
+ ) {
+ PageRequest pageable = PageRequest.of(
+ page0,
+ 10,
+ Sort.by(Sort.Direction.DESC, "challengedAt")
+ );
+
+ Page result =
+ userMissionCompletionRepository.findByUserIdAndStatus(
+ userId, UserMissionStatus.IN_PROGRESS, pageable
+ );
+
+ return UserMissionConverter.toUserMissionPreviewListDto(result);
+ }
+
+ // 미션 도전 기능
+ @Transactional
+ public UserMissionChallengeResponseDto challengeMission(UserMissionChallengeRequestDto request, Long userId) {
+
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("유저 없음"));
+
+ Mission mission = missionRepository.findById(request.getMissionId())
+ .orElseThrow(() -> new IllegalArgumentException("미션 없음"));
+
+ userMissionCompletionRepository.findByUserAndMission(user, mission).ifPresent(existing -> {
+ throw new IllegalStateException("이미 도전 중인 미션입니다.");
+ });
+
+ UserMissionCompletion challenge = UserMissionCompletion.builder()
+ .user(user)
+ .mission(mission)
+ .status(UserMissionStatus.IN_PROGRESS)
+ .challengedAt(LocalDateTime.now())
+ .completedAt(null)
+ .build();
+
+ userMissionCompletionRepository.save(challenge);
+
+ return UserMissionChallengeResponseDto.from(challenge);
+ }
+
+ // 진행중 미션 > 완료 처리 + 변경된 상태 반환
+ @Transactional
+ public UserMissionChallengeResponseDto completeMyMission(Long missionId, Long userId) {
+
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("유저 없음"));
+
+ Mission mission = missionRepository.findById(missionId)
+ .orElseThrow(() -> new IllegalArgumentException("미션 없음"));
+
+ UserMissionCompletion umc = userMissionCompletionRepository.findByUserAndMission(user, mission)
+ .orElseThrow(() -> new IllegalArgumentException("도전 기록 없음"));
+
+ if (umc.getStatus() != UserMissionStatus.IN_PROGRESS) {
+ throw new IllegalStateException("진행중인 미션이 아닙니다.");
+ }
+
+ // 상태 변경 (IN_PROGRESS -> COMPLETED)
+ umc.completeMission();
+
+ // 변경된 엔티티 저장 (dirty checking으로도 반영되지만 명시 저장)
+ userMissionCompletionRepository.save(umc);
+
+ // 변경된 상태 그대로 반환
+ return UserMissionChallengeResponseDto.from(umc);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/controller/PostController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/controller/PostController.java
new file mode 100644
index 0000000..43a7c87
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/controller/PostController.java
@@ -0,0 +1,28 @@
+package com.example.chapter4.domain.post.controller;
+
+import com.example.chapter4.domain.post.dto.PostResponseDto;
+import com.example.chapter4.domain.post.service.PostService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/posts")
+@RequiredArgsConstructor
+public class PostController {
+
+ private final PostService postService;
+
+ @GetMapping
+ public ApiResponse> getAllPosts() {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.getAllPosts());
+ }
+
+ @GetMapping("/{postId}")
+ public ApiResponse getPost(@PathVariable Long postId) {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.getPost(postId));
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostListResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostListResponseDto.java
new file mode 100644
index 0000000..c69133d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostListResponseDto.java
@@ -0,0 +1,11 @@
+package com.example.chapter4.domain.post.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import java.util.List;
+
+@Getter
+@AllArgsConstructor
+public class PostListResponseDto {
+ private final List content;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostResponseDto.java
new file mode 100644
index 0000000..5edcdfd
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/dto/PostResponseDto.java
@@ -0,0 +1,23 @@
+package com.example.chapter4.domain.post.dto;
+
+import com.example.chapter4.domain.post.entity.Post;
+import lombok.Getter;
+
+@Getter
+public class PostResponseDto {
+ private final Long id;
+ private final String title;
+ private final String content;
+ private final String status;
+ private final Long authorId;
+ private final String authorNickname;
+
+ public PostResponseDto(Post post) {
+ this.id = post.getId();
+ this.title = post.getTitle();
+ this.content = post.getContent();
+ this.status = post.getStatus().name();
+ this.authorId = post.getAuthor().getId();
+ this.authorNickname = post.getAuthor().getUsername();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/entity/Post.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/entity/Post.java
new file mode 100644
index 0000000..0e8e185
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/entity/Post.java
@@ -0,0 +1,48 @@
+package com.example.chapter4.domain.post.entity;
+
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "posts",
+ indexes = {
+ @Index(name = "idx_posts_user_id", columnList = "user_id")
+ }
+)
+public class Post extends BaseEntity {
+
+ public enum Status { DRAFT, PUBLISHED }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "post_id")
+ private Long id;
+
+ @Column(name = "title", nullable = false, length = 150)
+ private String title;
+
+ @Lob
+ @Column(name = "content", nullable = false) // TEXT
+ private String content;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private Status status;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User author;
+
+ @Builder
+ public Post(String title, String content, Status status, User author) {
+ this.title = title;
+ this.content = content;
+ this.status = status;
+ this.author = author;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/repository/PostRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/repository/PostRepository.java
new file mode 100644
index 0000000..7ef0df1
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/repository/PostRepository.java
@@ -0,0 +1,7 @@
+package com.example.chapter4.domain.post.repository;
+
+import com.example.chapter4.domain.post.entity.Post;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/service/PostService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/service/PostService.java
new file mode 100644
index 0000000..7794a0d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/post/service/PostService.java
@@ -0,0 +1,32 @@
+package com.example.chapter4.domain.post.service;
+
+import com.example.chapter4.domain.post.dto.PostResponseDto;
+import com.example.chapter4.domain.post.entity.Post;
+import com.example.chapter4.domain.post.repository.PostRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class PostService {
+
+ private final PostRepository postRepository;
+
+ // 게시글 전체 조회
+ public List getAllPosts() {
+ List posts = postRepository.findAll();
+ return posts.stream()
+ .map(PostResponseDto::new)
+ .collect(Collectors.toList());
+ }
+
+ // 게시글 단건 조회
+ public PostResponseDto getPost(Long id) {
+ Post post = postRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Post not found"));
+ return new PostResponseDto(post);
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/code/RegionErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/code/RegionErrorCode.java
new file mode 100644
index 0000000..a707dd9
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/code/RegionErrorCode.java
@@ -0,0 +1,17 @@
+package com.example.chapter4.domain.region.code;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum RegionErrorCode implements BaseErrorCode {
+
+ NOT_FOUND(HttpStatus.NOT_FOUND, "REGION404_1", "해당하는 지역이 존재하지 않습니다.");
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/controller/RegionController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/controller/RegionController.java
new file mode 100644
index 0000000..cf7e091
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/controller/RegionController.java
@@ -0,0 +1,30 @@
+package com.example.chapter4.domain.region.controller;
+
+import com.example.chapter4.domain.region.dto.RegionResponseDto;
+import com.example.chapter4.domain.region.service.RegionService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/regions")
+@RequiredArgsConstructor
+public class RegionController {
+
+ private final RegionService regionService;
+
+ // 전체 조회
+ @GetMapping
+ public ApiResponse> getAllRegions() {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, regionService.getAllRegions());
+ }
+
+ // 단일 조회 (선택)
+ @GetMapping("/{regionId}")
+ public ApiResponse getRegion(@PathVariable Long regionId) {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, regionService.getRegion(regionId));
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionListResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionListResponseDto.java
new file mode 100644
index 0000000..f4471fc
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionListResponseDto.java
@@ -0,0 +1,12 @@
+package com.example.chapter4.domain.region.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@AllArgsConstructor
+public class RegionListResponseDto {
+ private final List regions;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionResponseDto.java
new file mode 100644
index 0000000..0e6d5c3
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/dto/RegionResponseDto.java
@@ -0,0 +1,15 @@
+package com.example.chapter4.domain.region.dto;
+
+import com.example.chapter4.domain.region.entity.Region;
+import lombok.Getter;
+
+@Getter
+public class RegionResponseDto {
+ private final Long id;
+ private final String name;
+
+ public RegionResponseDto(Region region) {
+ this.id = region.getId();
+ this.name = region.getName();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/Region.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/Region.java
new file mode 100644
index 0000000..4708912
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/Region.java
@@ -0,0 +1,30 @@
+package com.example.chapter4.domain.region.entity;
+
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "regions",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_regions_region_name", columnNames = "region_name")
+ }
+)
+public class Region extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "region_id")
+ private Long id;
+
+ @Column(name = "region_name", nullable = false, length = 100)
+ private String name;
+
+ @Builder
+ public Region(String name) {
+ this.name = name;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/UserRegionBonus.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/UserRegionBonus.java
new file mode 100644
index 0000000..e8382e1
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/entity/UserRegionBonus.java
@@ -0,0 +1,45 @@
+package com.example.chapter4.domain.region.entity;
+
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "user_region_bonus",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_user_region", columnNames = {"user_id", "region_id"})
+ },
+ indexes = {
+ @Index(name = "idx_urb_user", columnList = "user_id"),
+ @Index(name = "idx_urb_region", columnList = "region_id")
+ }
+)
+public class UserRegionBonus extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "urb_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "region_id", nullable = false)
+ private Region region;
+
+ @Column(name = "bonus_point", nullable = false)
+ private Integer bonusPoint;
+
+ @Builder
+ public UserRegionBonus(User user, Region region, Integer bonusPoint) {
+ this.user = user;
+ this.region = region;
+ this.bonusPoint = bonusPoint;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/repository/RegionRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/repository/RegionRepository.java
new file mode 100644
index 0000000..4186b9b
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/repository/RegionRepository.java
@@ -0,0 +1,8 @@
+package com.example.chapter4.domain.region.repository;
+
+import com.example.chapter4.domain.region.entity.Region;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface RegionRepository extends JpaRepository {
+ boolean existsByName(String name);
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/service/RegionService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/service/RegionService.java
new file mode 100644
index 0000000..c13f3cc
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/region/service/RegionService.java
@@ -0,0 +1,32 @@
+package com.example.chapter4.domain.region.service;
+
+import com.example.chapter4.domain.region.dto.RegionResponseDto;
+import com.example.chapter4.domain.region.entity.Region;
+import com.example.chapter4.domain.region.repository.RegionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class RegionService {
+
+ private final RegionRepository regionRepository;
+
+ // 전체 지역 리스트
+ public List getAllRegions() {
+ List regions = regionRepository.findAll();
+ return regions.stream()
+ .map(RegionResponseDto::new)
+ .collect(Collectors.toList());
+ }
+
+ // 단일 조회
+ public RegionResponseDto getRegion(Long id) {
+ Region region = regionRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("지역 없음"));
+ return new RegionResponseDto(region);
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/code/ReviewErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/code/ReviewErrorCode.java
new file mode 100644
index 0000000..eab531b
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/code/ReviewErrorCode.java
@@ -0,0 +1,19 @@
+package com.example.chapter4.domain.review.code;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum ReviewErrorCode implements BaseErrorCode {
+
+ NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW404_1", "해당 리뷰를 찾을 수 없습니다."),
+ INVALID_PERMISSION(HttpStatus.FORBIDDEN, "REVIEW403_1", "리뷰를 수정/삭제할 권한이 없습니다."),
+ DUPLICATE_REVIEW(HttpStatus.BAD_REQUEST, "REVIEW400_1", "이미 리뷰를 작성하였습니다.");
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/controller/ReviewController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/controller/ReviewController.java
new file mode 100644
index 0000000..40905e0
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/controller/ReviewController.java
@@ -0,0 +1,68 @@
+package com.example.chapter4.domain.review.controller;
+
+import com.example.chapter4.domain.review.dto.ReviewListResponseDto;
+import com.example.chapter4.domain.review.dto.ReviewRequestDto;
+import com.example.chapter4.domain.review.dto.ReviewResponseDto;
+import com.example.chapter4.domain.review.service.ReviewService;
+import com.example.chapter4.global.annotation.CheckPage;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/reviews")
+@RequiredArgsConstructor
+public class ReviewController {
+
+ private final ReviewService reviewService;
+
+ @GetMapping("/me")
+ public ApiResponse> getMyReviews(
+ @RequestParam(required = false) String storeName,
+ @RequestParam(required = false) Integer rating
+ ) {
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.OK,
+ reviewService.getMyReviews(storeName, rating)
+ );
+ }
+
+ @Operation(
+ summary = "내가 작성한 리뷰 목록 조회 (페이징)",
+ description = "로그인한 사용자가 작성한 리뷰를 10개 단위로 페이징 조회합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page 오류")
+ })
+ @GetMapping("/me/paged")
+ public ApiResponse getMyReviewsPaged(
+ @CheckPage Integer page, // query string page 검증 + 0-based 변환
+ @RequestParam(required = false) String storeName,
+ @RequestParam(required = false) Integer rating
+ ) {
+ Long userId = 1L; // 기존 컨벤션 유지 (임시 하드코딩)
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.OK,
+ reviewService.getMyReviewsPaged(userId, storeName, rating, page)
+ );
+ }
+
+ @PostMapping
+ public ApiResponse addReview(
+ @RequestBody @Valid ReviewRequestDto request
+ ) {
+ // 하드코딩 userId (인증 없이 임시로 1L로 지정 가능)
+ return ApiResponse.onSuccess(
+ GeneralSuccessCode.CREATED,
+ reviewService.addReview(request, 1L)
+ );
+ }
+ // 필요시 추가: 리뷰작성, 상세조회, 삭제 등
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/converter/ReviewConverter.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/converter/ReviewConverter.java
new file mode 100644
index 0000000..bdd517c
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/converter/ReviewConverter.java
@@ -0,0 +1,25 @@
+package com.example.chapter4.domain.review.converter;
+
+import com.example.chapter4.domain.review.dto.ReviewRequestDto;
+import com.example.chapter4.domain.review.dto.ReviewResponseDto;
+import com.example.chapter4.domain.review.entity.Review;
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.domain.store.entity.Store;
+
+public class ReviewConverter {
+
+ // 엔티티 -> 응답 DTO 변환
+ public static ReviewResponseDto toReviewResponse(Review review) {
+ return new ReviewResponseDto(review);
+ }
+
+ // DTO -> 엔티티(생성용)
+ public static Review toReview(ReviewRequestDto dto, User writer, Store store) {
+ return Review.builder()
+ .rating(dto.getRating())
+ .content(dto.getContent())
+ .writer(writer)
+ .store(store)
+ .build();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewListResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewListResponseDto.java
new file mode 100644
index 0000000..907d17c
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewListResponseDto.java
@@ -0,0 +1,17 @@
+package com.example.chapter4.domain.review.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@Builder
+public class ReviewListResponseDto {
+
+ private final List content;
+ private final int page;
+ private final int size;
+ private final long totalElements;
+ private final int totalPages;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewRequestDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewRequestDto.java
new file mode 100644
index 0000000..7c6f9b9
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewRequestDto.java
@@ -0,0 +1,20 @@
+package com.example.chapter4.domain.review.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import com.example.chapter4.global.annotation.ExistStore;
+
+@Getter
+@NoArgsConstructor
+public class ReviewRequestDto {
+ @NotNull(message = "별점 필수")
+ private Float rating;
+ @NotBlank(message = "리뷰 내용 필수")
+ private String content;
+ @NotNull(message = "가게ID 필수")
+ @ExistStore
+ private Long storeId;
+}
+
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewResponseDto.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewResponseDto.java
new file mode 100644
index 0000000..1c18a83
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/dto/ReviewResponseDto.java
@@ -0,0 +1,28 @@
+package com.example.chapter4.domain.review.dto;
+
+import com.example.chapter4.domain.review.entity.Review;
+import lombok.Getter;
+import java.time.LocalDateTime;
+
+@Getter
+public class ReviewResponseDto {
+ private Long id;
+ private Long storeId;
+ private String storeName;
+ private Long writerId;
+ private String writerNickname;
+ private Float rating;
+ private String content;
+ private LocalDateTime createdAt;
+
+ public ReviewResponseDto(Review review) {
+ this.id = review.getId();
+ this.storeId = review.getStore().getId();
+ this.storeName = review.getStore().getName();
+ this.writerId = review.getWriter().getId();
+ this.writerNickname = review.getWriter().getUsername();
+ this.rating = review.getRating();
+ this.content = review.getContent();
+ this.createdAt = review.getCreatedAt();
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/entity/Review.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/entity/Review.java
new file mode 100644
index 0000000..5de0488
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/entity/Review.java
@@ -0,0 +1,47 @@
+package com.example.chapter4.domain.review.entity;
+
+import com.example.chapter4.domain.store.entity.Store;
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "reviews",
+ indexes = {
+ @Index(name = "idx_reviews_user_id", columnList = "user_id"),
+ @Index(name = "idx_reviews_store_id", columnList = "store_id")
+ }
+)
+public class Review extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "review_id")
+ private Long id;
+
+ @Column(name = "rating", nullable = false)
+ private Float rating;
+
+ @Column(name = "content", length = 500)
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User writer;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "store_id", nullable = false)
+ private Store store;
+
+ @Builder
+ public Review(Float rating, String content, User writer, Store store) {
+ this.rating = rating;
+ this.content = content;
+ this.writer = writer;
+ this.store = store;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepository.java
new file mode 100644
index 0000000..b64eaf6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepository.java
@@ -0,0 +1,21 @@
+package com.example.chapter4.domain.review.repository;
+
+import com.example.chapter4.domain.review.entity.Review;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface ReviewRepository extends JpaRepository, ReviewRepositoryCustom {
+
+ // 내가 쓴 리뷰 (마이페이지)
+ List findByWriterId(Long userId);
+
+ // 특정 가게의 리뷰들
+ List findByStoreId(Long storeId);
+
+ // @Query 예시 (조건 커스터마이징 가능)
+ @Query("select r from Review r where r.writer.id = :userId and r.store.id = :storeId")
+ List findByUserAndStore(@Param("userId") Long userId, @Param("storeId") Long storeId);
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryCustom.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryCustom.java
new file mode 100644
index 0000000..a00d8d0
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryCustom.java
@@ -0,0 +1,14 @@
+package com.example.chapter4.domain.review.repository;
+
+import com.example.chapter4.domain.review.entity.Review;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+
+public interface ReviewRepositoryCustom {
+
+ List findMyReviewsDynamic(Long writerId, String storeName, Integer rating);
+
+ Page findMyReviewsPaged(Long writerId, String storeName, Integer rating, Pageable pageable);
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryImpl.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryImpl.java
new file mode 100644
index 0000000..cd4f1da
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/repository/ReviewRepositoryImpl.java
@@ -0,0 +1,86 @@
+package com.example.chapter4.domain.review.repository;
+
+import com.example.chapter4.domain.review.entity.Review;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.*;
+import org.springframework.stereotype.Repository;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+import static com.example.chapter4.domain.review.entity.QReview.review;
+import static com.example.chapter4.domain.store.entity.QStore.store;
+import static com.example.chapter4.domain.user.entity.QUser.user;
+
+@Repository
+@RequiredArgsConstructor
+public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findMyReviewsDynamic(Long writerId, String storeName, Integer rating) {
+
+ return queryFactory
+ .selectFrom(review)
+ .leftJoin(review.store, store).fetchJoin()
+ .leftJoin(review.writer, user).fetchJoin()
+ .where(
+ review.writer.id.eq(writerId),
+ storeNameEq(storeName),
+ ratingCondition(rating)
+ )
+ .orderBy(review.createdAt.desc())
+ .fetch();
+ }
+
+ @Override
+ public Page findMyReviewsPaged(Long writerId, String storeName, Integer rating, Pageable pageable) {
+
+ List content = queryFactory
+ .selectFrom(review)
+ .leftJoin(review.store, store).fetchJoin()
+ .leftJoin(review.writer, user).fetchJoin()
+ .where(
+ review.writer.id.eq(writerId),
+ storeNameEq(storeName),
+ ratingCondition(rating)
+ )
+ .orderBy(review.createdAt.desc())
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+
+ Long total = queryFactory
+ .select(review.count())
+ .from(review)
+ .leftJoin(review.store, store)
+ .where(
+ review.writer.id.eq(writerId),
+ storeNameEq(storeName),
+ ratingCondition(rating)
+ )
+ .fetchOne();
+
+ long totalElements = total == null ? 0L : total;
+
+ return new PageImpl<>(content, pageable, totalElements);
+ }
+
+ private BooleanExpression storeNameEq(String storeName) {
+ if (!StringUtils.hasText(storeName)) return null;
+ return store.name.eq(storeName);
+ }
+
+ private BooleanExpression ratingCondition(Integer rating) {
+ if (rating == null) return null;
+ if (rating == 5) return review.rating.eq(5.0f);
+ else if (rating == 4) return review.rating.goe(4.0f).and(review.rating.lt(5.0f));
+ else if (rating == 3) return review.rating.goe(3.0f).and(review.rating.lt(4.0f));
+ else if (rating == 2) return review.rating.goe(2.0f).and(review.rating.lt(3.0f));
+ else if (rating == 1) return review.rating.goe(1.0f).and(review.rating.lt(2.0f));
+ else return null;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/service/ReviewService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/service/ReviewService.java
new file mode 100644
index 0000000..dcf9ac8
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/review/service/ReviewService.java
@@ -0,0 +1,75 @@
+package com.example.chapter4.domain.review.service;
+
+import com.example.chapter4.domain.review.converter.ReviewConverter;
+import com.example.chapter4.domain.review.dto.ReviewListResponseDto;
+import com.example.chapter4.domain.review.dto.ReviewRequestDto;
+import com.example.chapter4.domain.review.dto.ReviewResponseDto;
+import com.example.chapter4.domain.review.entity.Review;
+import com.example.chapter4.domain.review.repository.ReviewRepository;
+import com.example.chapter4.domain.store.entity.Store;
+import com.example.chapter4.domain.store.repository.StoreRepository;
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.domain.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.*;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ReviewService {
+
+ private final ReviewRepository reviewRepository;
+ private final UserRepository userRepository;
+ private final StoreRepository storeRepository;
+
+ public List getMyReviews(String storeName, Integer rating) {
+ Long userId = 1L; // 기존 컨벤션 유지(임시 하드코딩)
+
+ List reviews = reviewRepository.findMyReviewsDynamic(userId, storeName, rating);
+
+ return reviews.stream()
+ .map(ReviewConverter::toReviewResponse)
+ .toList();
+ }
+
+ @Transactional
+ public ReviewResponseDto addReview(ReviewRequestDto request, Long userId) {
+ User writer = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+ Store store = storeRepository.findById(request.getStoreId())
+ .orElseThrow(() -> new IllegalArgumentException("Store not found"));
+
+ Review newReview = ReviewConverter.toReview(request, writer, store);
+
+ Review savedReview = reviewRepository.save(newReview);
+
+ return ReviewConverter.toReviewResponse(savedReview);
+ }
+
+ public ReviewListResponseDto getMyReviewsPaged(
+ Long userId, String storeName, Integer rating, int page0
+ ) {
+ Pageable pageable = PageRequest.of(
+ page0, 10, Sort.by(Sort.Direction.DESC, "createdAt")
+ );
+
+ Page result = reviewRepository.findMyReviewsPaged(userId, storeName, rating, pageable);
+
+ List content = result.getContent().stream()
+ .map(ReviewConverter::toReviewResponse)
+ .toList();
+
+ return ReviewListResponseDto.builder()
+ .content(content)
+ .page(result.getNumber() + 1) // 다시 1-based로 변환
+ .size(result.getSize())
+ .totalElements(result.getTotalElements())
+ .totalPages(result.getTotalPages())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/code/StoreErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/code/StoreErrorCode.java
new file mode 100644
index 0000000..21753f5
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/code/StoreErrorCode.java
@@ -0,0 +1,15 @@
+package com.example.chapter4.domain.store.code;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum StoreErrorCode implements BaseErrorCode {
+ NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404_1", "해당 가게가 존재하지 않습니다.");
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/controller/StoreController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/controller/StoreController.java
new file mode 100644
index 0000000..8b25772
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/controller/StoreController.java
@@ -0,0 +1,38 @@
+package com.example.chapter4.domain.store.controller;
+
+import com.example.chapter4.domain.store.dto.StoreRequest;
+import com.example.chapter4.domain.store.dto.StoreResponse;
+import com.example.chapter4.domain.store.service.StoreService;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/stores")
+@RequiredArgsConstructor
+public class StoreController {
+ private final StoreService storeService;
+
+ @GetMapping
+ public ApiResponse> getStores() {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, storeService.getStores());
+ }
+
+ @GetMapping("/{storeId}")
+ public ApiResponse getStore(@PathVariable Long storeId) {
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, storeService.getStore(storeId));
+ }
+
+ @PostMapping
+ public ApiResponse addStore(
+ @RequestBody @Valid StoreRequest request
+ ) {
+ StoreResponse storeResponse = storeService.addStore(request);
+
+ return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, storeResponse);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/converter/StoreConverter.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/converter/StoreConverter.java
new file mode 100644
index 0000000..415c50e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/converter/StoreConverter.java
@@ -0,0 +1,30 @@
+package com.example.chapter4.domain.store.converter;
+
+import com.example.chapter4.domain.region.entity.Region;
+import com.example.chapter4.domain.store.dto.StoreRequest;
+import com.example.chapter4.domain.store.dto.StoreResponse;
+import com.example.chapter4.domain.store.entity.Store;
+
+public class StoreConverter {
+
+
+ public static Store toStore(StoreRequest dto, Region region) {
+ return Store.builder()
+ .name(dto.getName())
+ .detailAddress(dto.getDetailAddress())
+ .managerNumber(dto.getManagerNumber())
+ .region(region) // 연관관계 설정
+ .build();
+ }
+
+
+ public static StoreResponse toStoreResponse(Store store) {
+ return new StoreResponse(
+ store.getId(),
+ store.getName(),
+ store.getManagerNumber(),
+ store.getDetailAddress(),
+ store.getRegion().getId()
+ );
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreListResponse.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreListResponse.java
new file mode 100644
index 0000000..a4f8747
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreListResponse.java
@@ -0,0 +1,16 @@
+package com.example.chapter4.domain.store.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@AllArgsConstructor
+public class StoreListResponse {
+ private List content;
+ private int page;
+ private int size;
+ private long totalElements;
+ private int totalPages;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreRequest.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreRequest.java
new file mode 100644
index 0000000..6751e03
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreRequest.java
@@ -0,0 +1,26 @@
+package com.example.chapter4.domain.store.dto;
+
+import com.example.chapter4.global.annotation.ExistRegion;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class StoreRequest {
+
+ @NotBlank(message = "가게 이름은 필수입니다.")
+ private String name;
+
+ private String managerNumber;
+
+ @NotBlank(message = "상세 주소는 필수입니다.")
+ private String detailAddress;
+
+ @ExistRegion
+ @NotNull(message = "지역 ID는 필수입니다.")
+ private Long regionId;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreResponse.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreResponse.java
new file mode 100644
index 0000000..dd21a9f
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/dto/StoreResponse.java
@@ -0,0 +1,15 @@
+package com.example.chapter4.domain.store.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class StoreResponse {
+ private Long id;
+ private String name;
+ private String managerNumber;
+ private String detailAddress;
+ private Long regionId;
+}
+
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/entity/Store.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/entity/Store.java
new file mode 100644
index 0000000..511e14d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/entity/Store.java
@@ -0,0 +1,44 @@
+package com.example.chapter4.domain.store.entity;
+
+import com.example.chapter4.domain.region.entity.Region;
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "stores",
+ indexes = {
+ @Index(name = "idx_stores_region_id", columnList = "region_id")
+ }
+)
+public class Store extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "store_id")
+ private Long id;
+
+ @Column(name = "store_name", nullable = false, length = 100)
+ private String name;
+
+ @Column(name = "manager_number", length = 20)
+ private String managerNumber;
+
+ @Column(name = "detail_address", length = 255)
+ private String detailAddress;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "region_id", nullable = false)
+ private Region region;
+
+ @Builder
+ public Store(String name, String managerNumber, String detailAddress, Region region) {
+ this.name = name;
+ this.managerNumber = managerNumber;
+ this.detailAddress = detailAddress;
+ this.region = region;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/repository/StoreRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/repository/StoreRepository.java
new file mode 100644
index 0000000..989e3e6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/repository/StoreRepository.java
@@ -0,0 +1,7 @@
+package com.example.chapter4.domain.store.repository;
+
+import com.example.chapter4.domain.store.entity.Store;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface StoreRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/service/StoreService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/service/StoreService.java
new file mode 100644
index 0000000..9605773
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/store/service/StoreService.java
@@ -0,0 +1,49 @@
+package com.example.chapter4.domain.store.service;
+
+import com.example.chapter4.domain.region.entity.Region;
+import com.example.chapter4.domain.region.repository.RegionRepository;
+import com.example.chapter4.domain.store.converter.StoreConverter;
+import com.example.chapter4.domain.store.dto.StoreRequest;
+import com.example.chapter4.domain.store.entity.Store;
+import com.example.chapter4.domain.store.repository.StoreRepository;
+import com.example.chapter4.domain.store.dto.StoreResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class StoreService {
+
+ private final StoreRepository storeRepository;
+ private final RegionRepository regionRepository;
+
+ public List getStores() {
+ return storeRepository.findAll().stream()
+ .map(StoreConverter::toStoreResponse)
+ .collect(Collectors.toList());
+ }
+
+ public StoreResponse getStore(Long storeId) {
+ Store store = storeRepository.findById(storeId)
+ .orElseThrow(() -> new RuntimeException("Store not found"));
+ return StoreConverter.toStoreResponse(store);
+ }
+
+ @Transactional
+ public StoreResponse addStore(StoreRequest request) {
+
+ Region region = regionRepository.findById(request.getRegionId())
+ .orElseThrow(() -> new RuntimeException("Region not found (Validation failed)"));
+
+ Store newStore = StoreConverter.toStore(request, region);
+
+ Store savedStore = storeRepository.save(newStore);
+
+ return StoreConverter.toStoreResponse(savedStore);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/controller/AuthController.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/controller/AuthController.java
new file mode 100644
index 0000000..004b5bb
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/controller/AuthController.java
@@ -0,0 +1,38 @@
+package com.example.chapter4.domain.user.controller;
+
+import com.example.chapter4.domain.user.dto.UserLoginRequest;
+import com.example.chapter4.domain.user.dto.UserLoginResponse;
+import com.example.chapter4.domain.user.dto.UserSignupRequest;
+import com.example.chapter4.domain.user.dto.UserSignupResponse;
+import com.example.chapter4.domain.user.service.UserService;
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralSuccessCode;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final UserService userService;
+
+ // 회원가입
+ @PostMapping("/signup")
+ public ApiResponse signup(
+ @RequestBody @Valid UserSignupRequest request
+ ) {
+ UserSignupResponse response = userService.signup(request);
+ return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response);
+ }
+
+ // 로그인
+ @PostMapping("/login")
+ public ApiResponse login(
+ @RequestBody @Valid UserLoginRequest request
+ ) {
+ UserLoginResponse response = userService.login(request);
+ return ApiResponse.onSuccess(GeneralSuccessCode.OK, response);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginRequest.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginRequest.java
new file mode 100644
index 0000000..b182afe
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginRequest.java
@@ -0,0 +1,9 @@
+package com.example.chapter4.domain.user.dto;
+
+import lombok.Getter;
+
+@Getter
+public class UserLoginRequest {
+ private String email;
+ private String password;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginResponse.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginResponse.java
new file mode 100644
index 0000000..ffc8e1c
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserLoginResponse.java
@@ -0,0 +1,13 @@
+package com.example.chapter4.domain.user.dto;
+
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+@Getter
+@AllArgsConstructor
+public class UserLoginResponse {
+ private String accessToken;
+ private String refreshToken;
+ private String tokenType;
+ private Long expiresIn;
+ private UserSummary user;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupRequest.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupRequest.java
new file mode 100644
index 0000000..42308f5
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupRequest.java
@@ -0,0 +1,11 @@
+package com.example.chapter4.domain.user.dto;
+
+import lombok.Getter;
+@Getter
+public class UserSignupRequest {
+ private String email;
+ private String password;
+ private String nickname;
+ private String phone;
+ private boolean marketingAgree;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupResponse.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupResponse.java
new file mode 100644
index 0000000..82f601e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSignupResponse.java
@@ -0,0 +1,13 @@
+package com.example.chapter4.domain.user.dto;
+
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+import java.time.LocalDateTime;
+@Getter
+@AllArgsConstructor
+public class UserSignupResponse {
+ private Long userId;
+ private String email;
+ private String nickname;
+ private LocalDateTime createdAt;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSummary.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSummary.java
new file mode 100644
index 0000000..55d0c9d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/dto/UserSummary.java
@@ -0,0 +1,11 @@
+package com.example.chapter4.domain.user.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class UserSummary {
+ private Long userId;
+ private String nickname;
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/Follow.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/Follow.java
new file mode 100644
index 0000000..1cc2f8d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/Follow.java
@@ -0,0 +1,40 @@
+package com.example.chapter4.domain.user.entity;
+
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "follows",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_follows_pair", columnNames = {"follower_user_id", "followed_user_id"})
+ },
+ indexes = {
+ @Index(name = "idx_follows_follower", columnList = "follower_user_id"),
+ @Index(name = "idx_follows_followed", columnList = "followed_user_id")
+ }
+)
+public class Follow extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "follow_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "follower_user_id", nullable = false)
+ private User follower;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "followed_user_id", nullable = false)
+ private User followed;
+
+ @Builder
+ public Follow(User follower, User followed) {
+ this.follower = follower;
+ this.followed = followed;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/User.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/User.java
new file mode 100644
index 0000000..3d419e9
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/User.java
@@ -0,0 +1,63 @@
+package com.example.chapter4.domain.user.entity;
+
+import com.example.chapter4.global.jpa.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "users",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_users_username", columnNames = "username")
+ }
+)
+public class User extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id") // ERD와 동일한 컬럼명
+ private Long id;
+
+ // 로그인 아이디(이메일)로 사용할 username
+ @Column(name = "username", nullable = false, length = 50)
+ private String username;
+
+ // 암호화된 비밀번호
+ @Column(name = "password", nullable = false, length = 255)
+ private String password;
+
+ // 사용자 닉네임
+ @Column(name = "nickname", nullable = false, length = 50)
+ private String nickname;
+
+ // 전화번호
+ @Column(name = "phone", length = 20)
+ private String phone;
+
+ // 마케팅 수신 동의 여부
+ @Column(name = "marketing_agree", nullable = false)
+ private boolean marketingAgree;
+
+ // 권한 (USER / OWNER / ADMIN 등)
+ @Enumerated(EnumType.STRING)
+ @Column(name = "role", nullable = false, length = 20)
+ private UserRole role;
+
+ @Builder
+ public User(String username,
+ String password,
+ String nickname,
+ String phone,
+ boolean marketingAgree,
+ UserRole role) {
+
+ this.username = username;
+ this.password = password;
+ this.nickname = nickname;
+ this.phone = phone;
+ this.marketingAgree = marketingAgree;
+ this.role = role;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/UserRole.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/UserRole.java
new file mode 100644
index 0000000..b85481e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/entity/UserRole.java
@@ -0,0 +1,5 @@
+package com.example.chapter4.domain.user.entity;
+
+public enum UserRole {
+ USER, OWNER, ADMIN
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/repository/UserRepository.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/repository/UserRepository.java
new file mode 100644
index 0000000..428baf5
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/repository/UserRepository.java
@@ -0,0 +1,11 @@
+package com.example.chapter4.domain.user.repository;
+
+import com.example.chapter4.domain.user.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserRepository extends JpaRepository {
+ boolean existsByUsername(String username);
+ Optional findByUsername(String username);
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/service/UserService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/service/UserService.java
new file mode 100644
index 0000000..b471406
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/domain/user/service/UserService.java
@@ -0,0 +1,92 @@
+package com.example.chapter4.domain.user.service;
+
+import com.example.chapter4.domain.user.dto.UserLoginRequest;
+import com.example.chapter4.domain.user.dto.UserLoginResponse;
+import com.example.chapter4.domain.user.dto.UserSignupRequest;
+import com.example.chapter4.domain.user.dto.UserSignupResponse;
+import com.example.chapter4.domain.user.dto.UserSummary;
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.domain.user.entity.UserRole;
+import com.example.chapter4.domain.user.repository.UserRepository;
+import com.example.chapter4.global.security.JwtProvider;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final JwtProvider jwtProvider;
+
+ // 회원가입
+ public UserSignupResponse signup(UserSignupRequest request) {
+
+ // 중복 체크 (email을 username으로 쓰는 경우)
+ if (userRepository.existsByUsername(request.getEmail())) {
+ // 실전에서는 커스텀 예외 / 에러코드로 던지면 더 좋음
+ throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
+ }
+
+ // 비밀번호 암호화
+ String encodedPassword = passwordEncoder.encode(request.getPassword());
+
+ // User 엔티티 생성
+ User user = User.builder()
+ // 여기서 username = email 로 사용하는 설계
+ .username(request.getEmail())
+ .password(encodedPassword)
+ .nickname(request.getNickname())
+ .phone(request.getPhone())
+ .marketingAgree(request.isMarketingAgree())
+ .role(UserRole.USER)
+ .build();
+
+ // 저장
+ User saved = userRepository.save(user);
+
+ // 응답 DTO 생성
+ LocalDateTime createdAt =
+ saved.getCreatedAt() != null ? saved.getCreatedAt() : LocalDateTime.now();
+
+ return new UserSignupResponse(
+ saved.getId(),
+ saved.getUsername(),
+ saved.getNickname(),
+ createdAt
+ );
+ }
+
+ // 로그인
+ public UserLoginResponse login(UserLoginRequest request) {
+
+ // 아이디(이메일)로 유저 조회
+ User user = userRepository.findByUsername(request.getEmail())
+ .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
+
+ // 비밀번호 검증
+ if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
+ throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
+ }
+
+ // JWT 발급
+ String accessToken = jwtProvider.createAccessToken(user);
+ String refreshToken = jwtProvider.createRefreshToken(user);
+
+ // 응답 DTO 조립
+ return new UserLoginResponse(
+ accessToken,
+ refreshToken,
+ "Bearer",
+ 3600L,
+ new UserSummary(
+ user.getId(),
+ user.getNickname()
+ )
+ );
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/CheckPage.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/CheckPage.java
new file mode 100644
index 0000000..7776452
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/CheckPage.java
@@ -0,0 +1,9 @@
+package com.example.chapter4.global.annotation;
+
+import java.lang.annotation.*;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface CheckPage {
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistMission.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistMission.java
new file mode 100644
index 0000000..1e7e218
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistMission.java
@@ -0,0 +1,16 @@
+package com.example.chapter4.global.annotation;
+
+import com.example.chapter4.global.validator.MissionExistValidator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.*;
+
+@Documented
+@Constraint(validatedBy = MissionExistValidator.class)
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExistMission {
+ String message() default "존재하지 않는 미션입니다.";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistRegion.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistRegion.java
new file mode 100644
index 0000000..06401e2
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistRegion.java
@@ -0,0 +1,16 @@
+package com.example.chapter4.global.annotation;
+
+import com.example.chapter4.global.validator.RegionExistValidator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.*;
+
+@Documented
+@Constraint(validatedBy = RegionExistValidator.class)
+@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExistRegion {
+ String message() default "해당하는 지역이 존재하지 않습니다.";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistStore.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistStore.java
new file mode 100644
index 0000000..5061c88
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/annotation/ExistStore.java
@@ -0,0 +1,16 @@
+package com.example.chapter4.global.annotation;
+
+import com.example.chapter4.global.validator.StoreExistValidator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.*;
+
+@Documented
+@Constraint(validatedBy = StoreExistValidator.class)
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExistStore {
+ String message() default "해당 가게가 존재하지 않습니다.";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/ApiResponse.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/ApiResponse.java
new file mode 100644
index 0000000..3f59caa
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/ApiResponse.java
@@ -0,0 +1,52 @@
+package com.example.chapter4.global.apiPayload;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import com.example.chapter4.global.apiPayload.code.BaseSuccessCode;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
+public class ApiResponse {
+
+ @JsonProperty("isSuccess")
+ private final Boolean isSuccess;
+
+ @JsonProperty("code")
+ private final String code;
+
+ @JsonProperty("message")
+ private final String message;
+
+ @JsonProperty("result")
+ private T result;
+
+
+ // --- 성공 응답 ---
+
+ // 성공한 경우 (result가 없는 경우)
+ public static ApiResponse onSuccess(BaseSuccessCode code) {
+ return new ApiResponse<>(true, code.getCode(), code.getMessage(), null);
+ }
+
+ // 성공한 경우 (result가 있는 경우)
+ public static ApiResponse onSuccess(BaseSuccessCode code, T result) {
+ return new ApiResponse<>(true, code.getCode(), code.getMessage(), result);
+ }
+
+
+ // --- 실패 응답 ---
+
+ // 실패한 경우 (result가 없는 경우)
+ public static ApiResponse onFailure(BaseErrorCode code) {
+ return new ApiResponse<>(false, code.getCode(), code.getMessage(), null);
+ }
+
+ // 실패한 경우 (result가 있는 경우)
+ public static ApiResponse onFailure(BaseErrorCode code, T result) {
+ return new ApiResponse<>(false, code.getCode(), code.getMessage(), result);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseErrorCode.java
new file mode 100644
index 0000000..be65024
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseErrorCode.java
@@ -0,0 +1,10 @@
+package com.example.chapter4.global.apiPayload.code;
+
+import org.springframework.http.HttpStatus;
+
+public interface BaseErrorCode {
+
+ HttpStatus getStatus();
+ String getCode();
+ String getMessage();
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseSuccessCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseSuccessCode.java
new file mode 100644
index 0000000..b7c498e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/BaseSuccessCode.java
@@ -0,0 +1,10 @@
+package com.example.chapter4.global.apiPayload.code;
+
+import org.springframework.http.HttpStatus;
+
+public interface BaseSuccessCode {
+
+ HttpStatus getStatus();
+ String getCode();
+ String getMessage();
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralErrorCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralErrorCode.java
new file mode 100644
index 0000000..c90b0bd
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralErrorCode.java
@@ -0,0 +1,37 @@
+package com.example.chapter4.global.apiPayload.code;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum GeneralErrorCode implements BaseErrorCode {
+
+ // 가장 일반적인 에러
+ BAD_REQUEST(HttpStatus.BAD_REQUEST,
+ "COMMON400_1",
+ "잘못된 요청입니다."),
+ UNAUTHORIZED(HttpStatus.UNAUTHORIZED,
+ "AUTH401_1",
+ "인증이 필요합니다."),
+ FORBIDDEN(HttpStatus.FORBIDDEN,
+ "AUTH403_1",
+ "요청이 거부되었습니다."),
+ NOT_FOUND(HttpStatus.NOT_FOUND,
+ "COMMON404_1",
+ "요청한 리소스를 찾을 수 없습니다."),
+ PAGE_VALIDATION_ERROR(HttpStatus.BAD_REQUEST,
+ "COMMON400_2",
+ "page는 1 이상이어야 합니다."),
+
+ // 기타 서버 에러 (워크북에서 추가)
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,
+ "COMMON500_1",
+ "예기치 않은 서버 에러가 발생했습니다."),
+ ;
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralSuccessCode.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralSuccessCode.java
new file mode 100644
index 0000000..f365a25
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/apiPayload/code/GeneralSuccessCode.java
@@ -0,0 +1,20 @@
+package com.example.chapter4.global.apiPayload.code;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum GeneralSuccessCode implements BaseSuccessCode {
+
+ // 일반적인 성공 케이스 (200 OK)
+ OK(HttpStatus.OK, "COMMON200", "성공적으로 요청을 처리했습니다."),
+
+ // 201 Created
+ CREATED(HttpStatus.CREATED, "COMMON201", "성공적으로 생성되었습니다.");
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/SwaggerConfig b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/SwaggerConfig
new file mode 100644
index 0000000..519cb61
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/SwaggerConfig
@@ -0,0 +1,25 @@
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI swagger() {
+ Info info = new Info().title("Project").description("Project Swagger").version("0.0.1");
+
+ // JWT 토큰 헤더 방식
+ String securityScheme = "JWT TOKEN";
+ SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityScheme);
+
+ Components components = new Components()
+ .addSecuritySchemes(securityScheme, new SecurityScheme()
+ .name(securityScheme)
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("Bearer")
+ .bearerFormat("JWT"));
+
+ return new OpenAPI()
+ .info(info)
+ .addServersItem(new Server().url("/"))
+ .addSecurityItem(securityRequirement)
+ .components(components);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/WebConfig.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/WebConfig.java
new file mode 100644
index 0000000..5647e0b
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/config/WebConfig.java
@@ -0,0 +1,21 @@
+package com.example.chapter4.global.config;
+
+import com.example.chapter4.global.resolver.CheckPageArgumentResolver;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebConfig implements WebMvcConfigurer {
+
+ private final CheckPageArgumentResolver checkPageArgumentResolver;
+
+ @Override
+ public void addArgumentResolvers(List resolvers) {
+ resolvers.add(checkPageArgumentResolver);
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/GeneralException.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/GeneralException.java
new file mode 100644
index 0000000..70245b6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/GeneralException.java
@@ -0,0 +1,15 @@
+package com.example.chapter4.global.exception;
+
+import com.example.chapter4.global.apiPayload.code.BaseErrorCode;
+import lombok.Getter;
+
+@Getter
+public class GeneralException extends RuntimeException {
+
+ private final BaseErrorCode code;
+
+ public GeneralException(BaseErrorCode code) {
+ super(code.getMessage());
+ this.code = code;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/PageValidationException.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/PageValidationException.java
new file mode 100644
index 0000000..f848712
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/exception/PageValidationException.java
@@ -0,0 +1,10 @@
+package com.example.chapter4.global.exception;
+
+import com.example.chapter4.global.apiPayload.code.GeneralErrorCode;
+
+public class PageValidationException extends GeneralException {
+ public PageValidationException() {
+ super(GeneralErrorCode.PAGE_VALIDATION_ERROR);
+ // GeneralErrorCode에 아래 enum 추가 필요
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/jpa/BaseEntity.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/jpa/BaseEntity.java
new file mode 100644
index 0000000..f03ee8f
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/jpa/BaseEntity.java
@@ -0,0 +1,25 @@
+package com.example.chapter4.global.jpa;
+
+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;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntity {
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/resolver/CheckPageArgumentResolver.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/resolver/CheckPageArgumentResolver.java
new file mode 100644
index 0000000..a6b1ea6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/resolver/CheckPageArgumentResolver.java
@@ -0,0 +1,41 @@
+package com.example.chapter4.global.resolver;
+
+import com.example.chapter4.global.annotation.CheckPage;
+import com.example.chapter4.global.exception.PageValidationException;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+@Component
+public class CheckPageArgumentResolver implements HandlerMethodArgumentResolver {
+
+ @Override
+ public boolean supportsParameter(MethodParameter parameter) {
+ return parameter.hasParameterAnnotation(CheckPage.class)
+ && parameter.getParameterType().equals(Integer.class);
+ }
+
+ @Override
+ public Object resolveArgument(
+ MethodParameter parameter,
+ ModelAndViewContainer mavContainer,
+ NativeWebRequest webRequest,
+ WebDataBinderFactory binderFactory
+ ) {
+ String pageStr = webRequest.getParameter("page");
+ if (pageStr == null) {
+ return 0;
+ }
+
+ int page = Integer.parseInt(pageStr);
+ if (page <= 0) {
+ throw new PageValidationException();
+ }
+
+ // 프론트는 1-based, JPA는 0-based
+ return page - 1;
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/AuthenticationEntryPointImpl.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/AuthenticationEntryPointImpl.java
new file mode 100644
index 0000000..2811ec6
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/AuthenticationEntryPointImpl.java
@@ -0,0 +1,34 @@
+package com.example.chapter4.global.security;
+
+import com.example.chapter4.global.apiPayload.ApiResponse;
+import com.example.chapter4.global.apiPayload.code.GeneralErrorCode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import java.io.IOException;
+
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Override
+ public void commence(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException
+ ) throws IOException {
+
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json;charset=UTF-8");
+
+ ApiResponse body = ApiResponse.onFailure(
+ GeneralErrorCode.UNAUTHORIZED,
+ null
+ );
+
+ objectMapper.writeValue(response.getOutputStream(), body);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetails.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetails.java
new file mode 100644
index 0000000..9c12f2e
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetails.java
@@ -0,0 +1,46 @@
+package com.example.chapter4.global.security;
+
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.domain.user.entity.UserRole;
+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;
+
+@RequiredArgsConstructor
+public class CustomUserDetails implements UserDetails {
+
+ private final User user;
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ // ROLE_ 접두사를 붙여서 사용
+ String roleName = "ROLE_" + user.getRole().name(); // USER, OWNER, ADMIN
+ return List.of(new SimpleGrantedAuthority(roleName));
+ }
+
+ @Override
+ public String getPassword() {
+ return user.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getUsername();
+ }
+
+ @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/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetailsService.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetailsService.java
new file mode 100644
index 0000000..079d9ab
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/CustomUserDetailsService.java
@@ -0,0 +1,25 @@
+package com.example.chapter4.global.security;
+
+import com.example.chapter4.domain.user.entity.User;
+import com.example.chapter4.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;
+
+ // username을 email로 사용
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ User user = userRepository.findByUsername(username)
+ .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
+
+ return new CustomUserDetails(user);
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtAuthFilter.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtAuthFilter.java
new file mode 100644
index 0000000..d065c45
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtAuthFilter.java
@@ -0,0 +1,50 @@
+package com.example.chapter4.global.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@RequiredArgsConstructor
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private final JwtProvider jwtProvider;
+ private final CustomUserDetailsService customUserDetailsService;
+
+ @Override
+ protected void doFilterInternal(
+ @NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain
+ ) throws ServletException, IOException {
+
+ String header = request.getHeader("Authorization");
+
+ if (header != null && header.startsWith("Bearer ")) {
+ String token = header.substring(7);
+
+ if (jwtProvider.validateToken(token)) {
+ String username = jwtProvider.getUsernameFromToken(token);
+ UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
+
+ Authentication auth = new UsernamePasswordAuthenticationToken(
+ userDetails,
+ null,
+ userDetails.getAuthorities()
+ );
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtProvider.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtProvider.java
new file mode 100644
index 0000000..fe0f953
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/JwtProvider.java
@@ -0,0 +1,78 @@
+package com.example.chapter4.global.security;
+
+import com.example.chapter4.domain.user.entity.User;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Date;
+
+@Component
+public class JwtProvider {
+
+ private final SecretKey secretKey;
+ private final long accessTokenExpirationMs;
+ private final long refreshTokenExpirationMs;
+
+ public JwtProvider(
+ @Value("${jwt.secret:defaultSecretKey}") String secret,
+ @Value("${jwt.access-token-expiration-ms:3600000}") long accessTokenExpirationMs,
+ @Value("${jwt.refresh-token-expiration-ms:86400000}") long refreshTokenExpirationMs
+ ) {
+ this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ this.accessTokenExpirationMs = accessTokenExpirationMs;
+ this.refreshTokenExpirationMs = refreshTokenExpirationMs;
+ }
+
+ // Access Token 생성
+ public String createAccessToken(User user) {
+ return createToken(user, accessTokenExpirationMs);
+ }
+
+ // Refresh Token 생성
+ public String createRefreshToken(User user) {
+ return createToken(user, refreshTokenExpirationMs);
+ }
+
+ private String createToken(User user, long expirationMs) {
+ Instant now = Instant.now();
+ Instant expiry = now.plusMillis(expirationMs);
+
+ return Jwts.builder()
+ .subject(user.getUsername()) // username = 이메일
+ .claim("role", user.getRole().name())
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(expiry))
+ .signWith(secretKey)
+ .compact();
+ }
+
+ // 토큰에서 username(email) 꺼내기
+ public String getUsernameFromToken(String token) {
+ return parseClaims(token).getPayload().getSubject();
+ }
+
+ // 토큰 유효성 검증
+ public boolean validateToken(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (JwtException | IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ private Jws parseClaims(String token) {
+ return Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token);
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/SecurityConfig.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/SecurityConfig.java
new file mode 100644
index 0000000..b243e7b
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/security/SecurityConfig.java
@@ -0,0 +1,86 @@
+package com.example.chapter4.global.security;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final CustomUserDetailsService customUserDetailsService;
+ private final JwtProvider jwtProvider;
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+
+ http
+ // CSRF off (미션 편의용)
+ .csrf(csrf -> csrf.disable())
+
+ // 세션 + JWT 둘 다 사용할 거라 IF_REQUIRED
+ .sessionManagement(sm ->
+ sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
+ )
+
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(
+ "/api/auth/signup",
+ "/api/auth/login"
+ ).permitAll()
+
+ .requestMatchers(
+ "/css/**",
+ "/js/**"
+ ).permitAll()
+ .anyRequest().authenticated()
+ )
+
+ // 세션 기반 로그인 미션 1
+ .formLogin(form -> form
+ .defaultSuccessUrl("/swagger-ui/index.html", true)
+ .permitAll()
+ )
+
+ // 로그아웃
+ .logout(logout -> logout
+ .logoutUrl("/logout")
+ .logoutSuccessUrl("/login?logout")
+ .invalidateHttpSession(true)
+ .deleteCookies("JSESSIONID")
+ .permitAll()
+ )
+
+ .userDetailsService(customUserDetailsService)
+
+ // JWT 필터 미션 2
+ .addFilterBefore(
+ new JwtAuthFilter(jwtProvider, customUserDetailsService),
+ UsernamePasswordAuthenticationFilter.class
+ );
+
+ return http.build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(
+ AuthenticationConfiguration configuration
+ ) throws Exception {
+ return configuration.getAuthenticationManager();
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/MissionExistValidator.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/MissionExistValidator.java
new file mode 100644
index 0000000..37f3765
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/MissionExistValidator.java
@@ -0,0 +1,38 @@
+package com.example.chapter4.global.validator;
+
+import com.example.chapter4.domain.mission.code.MissionErrorCode;
+import com.example.chapter4.domain.mission.repository.MissionRepository;
+import com.example.chapter4.global.annotation.ExistMission;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class MissionExistValidator implements ConstraintValidator {
+
+ private final MissionRepository missionRepository;
+
+ @Override
+ public void initialize(ExistMission constraintAnnotation) {
+ ConstraintValidator.super.initialize(constraintAnnotation);
+ }
+
+ @Override
+ public boolean isValid(Long value, ConstraintValidatorContext context) {
+ if (value == null) {
+ return true;
+ }
+
+ boolean isValid = missionRepository.existsById(value);
+
+ if (!isValid) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(MissionErrorCode.NOT_FOUND.getMessage())
+ .addConstraintViolation();
+ }
+
+ return isValid;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/RegionExistValidator.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/RegionExistValidator.java
new file mode 100644
index 0000000..0f574fd
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/RegionExistValidator.java
@@ -0,0 +1,39 @@
+package com.example.chapter4.global.validator;
+
+import com.example.chapter4.domain.region.code.RegionErrorCode;
+import com.example.chapter4.domain.region.repository.RegionRepository;
+import com.example.chapter4.global.annotation.ExistRegion;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class RegionExistValidator implements ConstraintValidator {
+
+ private final RegionRepository regionRepository;
+
+ @Override
+ public void initialize(ExistRegion constraintAnnotation) {
+ ConstraintValidator.super.initialize(constraintAnnotation);
+ }
+
+ @Override
+ public boolean isValid(Long value, ConstraintValidatorContext context) {
+ // regionId가 null이면 검증 통과 (다른 @NotNull 등으로 잡아야 함)
+ if (value == null) {
+ return true;
+ }
+
+ boolean isValid = regionRepository.existsById(value);
+
+ if (!isValid) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(RegionErrorCode.NOT_FOUND.getMessage())
+ .addConstraintViolation();
+ }
+
+ return isValid;
+ }
+}
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/StoreExistValidator.java b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/StoreExistValidator.java
new file mode 100644
index 0000000..f1a4b24
--- /dev/null
+++ b/22jiwon/chapter4/src/main/java/com/example/chapter4/global/validator/StoreExistValidator.java
@@ -0,0 +1,35 @@
+package com.example.chapter4.global.validator;
+
+import com.example.chapter4.domain.store.repository.StoreRepository;
+import com.example.chapter4.global.annotation.ExistStore;
+import com.example.chapter4.domain.store.code.StoreErrorCode;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class StoreExistValidator implements ConstraintValidator {
+
+ private final StoreRepository storeRepository;
+
+ @Override
+ public boolean isValid(Long value, ConstraintValidatorContext context) {
+ if (value == null) {
+ return true;
+ }
+
+ boolean isValid = storeRepository.existsById(value);
+
+ if (!isValid) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(
+ StoreErrorCode.NOT_FOUND.getMessage()
+ ).addConstraintViolation();
+ }
+
+ return isValid;
+ }
+}
diff --git a/22jiwon/chapter4/src/main/resources/application.yml b/22jiwon/chapter4/src/main/resources/application.yml
new file mode 100644
index 0000000..32a473d
--- /dev/null
+++ b/22jiwon/chapter4/src/main/resources/application.yml
@@ -0,0 +1,24 @@
+spring:
+ application:
+ name: "umc9th"
+
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: ${DB_URL}
+ username: ${DB_USER}
+ password: ${DB_PW}
+
+ jpa:
+ database: mysql
+ database-platform: org.hibernate.dialect.MySQLDialect
+ show-sql: true
+ hibernate:
+ ddl-auto: update
+ properties:
+ hibernate:
+ format_sql: true
+
+jwt:
+ secret: "this_is_a_very_long_jwt_secret_key_32_bytes_at_least!!"
+ access-token-expiration-ms: 3600000
+ refresh-token-expiration-ms: 86400000
\ No newline at end of file
diff --git a/22jiwon/chapter4/src/test/java/com/example/chapter4/Chapter4ApplicationTests.java b/22jiwon/chapter4/src/test/java/com/example/chapter4/Chapter4ApplicationTests.java
new file mode 100644
index 0000000..61792b4
--- /dev/null
+++ b/22jiwon/chapter4/src/test/java/com/example/chapter4/Chapter4ApplicationTests.java
@@ -0,0 +1,13 @@
+package com.example.chapter4;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class Chapter4ApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}