diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
new file mode 100644
index 00000000..eef46991
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -0,0 +1,22 @@
+---
+name: Issue Template
+about: Sopetit Issue Template
+title: ""
+labels: ''
+assignees: ''
+
+---
+
+[comment]: <> (priority 와 task size를 뱃지로 정해주세요)
+
+**📌 상세 설명**
+
+[comment]: <> (이슈에 대한 설명을 적어주세요)
+
+**📝 체크리스트**
+
+[comment]: <> (해야 할 일들을 상세히 나눠 적어주시면 좋아요)
+
+- [ ]
+
+- [ ]
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..971ff5e1
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+## ✨ Related Issue
+- close #이슈번호
+
+
+## 📝 기능 구현 명세
+- 이곳에는 postman 테스트 결과를 넣어주세요
+
+## 🐥 추가적인 언급 사항
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 00000000..4cfc81cb
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [ "develop" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: 체크아웃
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+
+ - name: application.yaml 생성
+ run: |
+ cd src/main/resources
+ echo "${{ secrets.APPLICATION_SECRET_YML }}" > ./application-secret.yml
+ working-directory: ${{ env.working-directory }}
+
+ - name: 빌드
+ run: |
+ chmod +x gradlew
+ ./gradlew build -x test
+ working-directory: ${{ env.working-directory }}
+ shell: bash
\ No newline at end of file
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
new file mode 100644
index 00000000..f23aacfb
--- /dev/null
+++ b/.github/workflows/deploy-prod.yml
@@ -0,0 +1,60 @@
+name: deploy
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: 17
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Create application-secret.yml
+ run: |
+ pwd
+ touch src/main/resources/application-secret.yml
+ echo "${{ secrets.APPLICATION_SECRET_YML }}" >> src/main/resources/application-secret.yml
+ cat src/main/resources/application-secret.yml
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v1-node16
+ with:
+ aws-access-key-id: ${{ secrets.ACCESS_KEY_ID_PROD }}
+ aws-secret-access-key: ${{ secrets.ACCESS_KEY_SECRET_PROD }}
+ aws-region: ap-northeast-2
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x ./gradlew
+ shell: bash
+
+ - name: Build with Gradle
+ run: ./gradlew build
+ shell: bash
+
+ - name: Make zip file
+ run: zip -qq -r ./$GITHUB_SHA.zip .
+ shell: bash
+
+ - name: Upload to AWS S3
+ run: |
+ aws deploy push \
+ --application-name softie-code-deploy \
+ --ignore-hidden-files \
+ --s3-location s3://${{ secrets.AWS_BUCKET_NAME_PROD }}/$GITHUB_SHA.zip \
+ --source .
+
+ - name: Code Deploy
+ run: aws deploy create-deployment --application-name softie-code-deploy
+ --deployment-config-name CodeDeployDefault.AllAtOnce
+ --deployment-group-name prod-group
+ --s3-location bucket=${{ secrets.AWS_BUCKET_NAME_PROD }},bundleType=zip,key=$GITHUB_SHA.zip
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..558fc777
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,60 @@
+name: deploy
+
+on:
+ push:
+ branches: [ develop ]
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: 17
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Create application-secret.yml
+ run: |
+ pwd
+ touch src/main/resources/application-secret.yml
+ echo "${{ secrets.APPLICATION_SECRET_YML }}" >> src/main/resources/application-secret.yml
+ cat src/main/resources/application-secret.yml
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v1-node16
+ with:
+ aws-access-key-id: ${{ secrets.ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.ACCESS_KEY_SECRET }}
+ aws-region: ap-northeast-2
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x ./gradlew
+ shell: bash
+
+ - name: Build with Gradle
+ run: ./gradlew build
+ shell: bash
+
+ - name: Make zip file
+ run: zip -qq -r ./$GITHUB_SHA.zip .
+ shell: bash
+
+ - name: Upload to AWS S3
+ run: |
+ aws deploy push \
+ --application-name sopetit-codedeploy \
+ --ignore-hidden-files \
+ --s3-location s3://${{ secrets.AWS_BUCKET_NAME }}/$GITHUB_SHA.zip \
+ --source .
+
+ - name: Code Deploy
+ run: aws deploy create-deployment --application-name sopetit-codedeploy
+ --deployment-config-name CodeDeployDefault.AllAtOnce
+ --deployment-group-name sopetit-group
+ --s3-location bucket=${{ secrets.AWS_BUCKET_NAME }},bundleType=zip,key=$GITHUB_SHA.zip
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c2065bc2..89562085 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,6 @@ out/
### VS Code ###
.vscode/
+
+### configuration ###
+application-secret.yml
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..198514ee
--- /dev/null
+++ b/README.md
@@ -0,0 +1,137 @@
+### 📑 Architecture
+![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/fc4c347d-03fd-4298-a097-3110d36c92ad)
+
+### 📋 Model Diagram
+![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/a260dcff-292b-4dd1-8acd-030a57a57a65)
+
+### 📖 Directory
+```
+📁 Sopetit-server
+├── .github
+├── .idea
+├── build
+├── gen
+├── gradel
+├── scripts
+├── src.main
+│ ├──java.com.soptie.server
+│ ├── auth
+│ ├── common
+│ ├── conversation
+│ ├── doll
+│ ├── member
+│ ├── controller
+│ ├── dto
+│ ├── entity
+│ ├── message
+│ ├── repository
+│ ├── service
+│ ├── memberDoll
+│ ├── memberRoutine
+│ ├── routine
+│ ├── test
+├── src.test
+│ ├──java.com.soptie.server
+│ ├── auth
+│ ├── base
+│ ├── doll
+│ ├── member
+│ ├── memberRoutine
+│ ├── controller
+│ ├── fixture
+│ ├── routine
+│ ├── test
+```
+
+### ✉️ Commit Messge Rules
+
+**서버** 들의 **Git Commit Message Rules**
+
+- 반영사항을 바로 확인할 수 있도록 작은 기능 하나라도 구현되면 커밋을 권장합니다.
+- 기능 구현이 완벽하지 않을 땐, 각자 브랜치에 커밋을 해주세요.
+
+### 📌 Commit Convention
+
+**[태그] 제목의 형태**
+
+| 태그 이름 | 설명 |
+| :-------: | :-----------------------------------------------: |
+| FEAT | 새로운 기능을 추가할 경우 |
+| FIX | 버그를 고친 경우 |
+| CHORE | 짜잘한 수정 |
+| DOCS | 문서 수정 |
+| INIT | 초기 설정 |
+| TEST | 테스트 코드, 리펙토링 테스트 코드 추가 |
+| RENAME | 파일 혹은 폴더명을 수정하거나 옮기는 작업인 경우 |
+| STYLE | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 |
+| REFACTOR | 코드 리팩토링 |
+
+### **커밋 타입**
+
+- `[태그] 설명` 형식으로 커밋 메시지를 작성합니다.
+- 태그는 영어를 쓰고 대문자로 작성합니다.
+
+예시 >
+
+```
+ [FEAT] 검색 api 추가
+```
+
+### **💻 Github mangement**
+
+**소프티** 들의 WorkFlow : **Gitflow Workflow**
+
+- Develop, Feature, Hotfix 브랜치
+
+- 개발(develop): 기능들의 통합 브랜치
+
+- 기능 단위 개발(feature): 기능 단위 브랜치
+
+- 버그 수정 및 갑작스런 수정(hotfix): 수정 사항 발생 시 브랜치
+
+- 개발 브랜치 아래 기능별 브랜치를 만들어 작성합니다.
+
+### ✍🏻 Code Convention
+
+[에어비앤비 코드 컨벤션](https://github.com/airbnb/javascript)
+
+### 📍 Gitflow 규칙
+
+- Develop에 직접적인 commit, push는 금지합니다.
+- 커밋 메세지는 다른 사람들이 봐도 이해할 수 있게 써주세요.
+- 작업 이전에 issue 작성 후 pullrequest 와 issue를 연동해 주세요.
+- 풀리퀘스트를 통해 코드 리뷰를 전원이 코드리뷰를 진행합니다.
+- 기능 개발 시 개발 브랜치에서 feature/기능 으로 브랜치를 파서 관리합니다.
+- feature 자세한 기능 한 가지를 담당하며, 기능 개발이 완료되면 각자의 브랜치로 Pull Request를 보냅니다.
+- 각자가 기간 동안 맡은 역할을 전부 수행하면, 각자 브랜치에서 develop브랜치로 Pull Request를 보냅니다.
+ **develop 브랜치로의 Pull Request는 상대방의 코드리뷰 후에 merge할 수 있습니다.**
+
+### ❗️ branch naming convention
+
+- develop
+- feature/issue_number-도메인-http Method-api
+- fix/issue_number-도메인-http Method-api
+- release/version_number
+- hotfix/issue_number - Short Description
+
+예시 >
+
+```
+ feature/#3-user-post-api
+```
+
+### 📋 Code Review Convention
+
+- P1: 꼭 반영해주세요 (Request changes)
+- P2: 적극적으로 고려해주세요 (Request changes)
+- P3: 웬만하면 반영해 주세요 (Comment)
+- P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)
+- P5: 그냥 사소한 의견입니다 (Approve)
+
+### 👩👧👧 Our Team
+
+| **🍀 [최승빈](https://github.com/csb9427)** | **🍀 [남궁찬](https://github.com/Chan531)** |**🍀 [김소현](https://github.com/thguss)** |
+ |:-----------------------------------:|:-----------------------------------:|:-----------------------------------:|
+| Server Developer | Server Developer | Server Developer |
+| ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/4eaa9aaa-b834-4883-91c8-cb5dd3005c5d) | ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/3e82a81c-1710-4199-8c5c-c920fdb8229b) | ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/ca9420e7-744d-4725-a9d9-36f79669fd04) |
+| 프로젝트 세팅
| 프로젝트 셋팅
| 프로젝트 세팅
|
diff --git a/appspec.yml b/appspec.yml
new file mode 100644
index 00000000..09ed6224
--- /dev/null
+++ b/appspec.yml
@@ -0,0 +1,19 @@
+version: 0.0
+os: linux
+
+files:
+ - source: /
+ destination: /home/ubuntu/build
+ overwrite: yes
+
+permissions:
+ - object: /home/ubuntu
+ pattern: '**'
+ owner: ubuntu
+ group: ubuntu
+
+hooks:
+ AfterInstall:
+ - location: scripts/deploy.sh
+ timeout: 1000
+ runas: ubuntu
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 08753b73..4d65d1a8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,19 @@
+import org.hidetake.gradle.swagger.generator.GenerateSwaggerUI
+
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
+
+ // restdocs-swagger
+ id 'com.epages.restdocs-api-spec' version '0.18.2'
+ id 'org.hidetake.swagger.generator' version '2.18.2'
+}
+
+swaggerSources {
+ sample {
+ setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml"))
+ }
}
group = 'com.soptie'
@@ -25,14 +37,61 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
- runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
+
+ // https://mvnrepository.com/artifact/org.postgresql/postgresql
+ implementation 'org.postgresql:postgresql:42.7.1'
+
+ // jwt
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
+ // gson
+ implementation 'com.google.code.gson:gson:2.8.6'
+
+ // restdocs-swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
+ testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+
+ // queryDSL
+ implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+
+ // slack logback
+ implementation 'com.github.maricn:logback-slack-appender:1.4.0'
}
tasks.named('test') {
useJUnitPlatform()
}
+
+tasks.withType(GenerateSwaggerUI).configureEach {
+ dependsOn 'openapi3'
+ copy {
+ from "build/resources/main/static/docs"
+ into "src/main/resources/static/docs/"
+ }
+}
+
+bootJar {
+ dependsOn(':openapi3')
+}
+
+openapi3 {
+ server = "http://localhost:8080"
+ title = "소프티 API"
+ description = "소프티 API 명세서"
+ version = "0.0.1"
+ outputFileNamePrefix = 'open-api-3.0.1'
+ format = 'json'
+ outputDirectory = 'build/resources/main/static/docs'
+}
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100644
index 00000000..daacb9f6
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,29 @@
+REPOSITORY=/home/ubuntu/build
+cd $REPOSITORY
+
+APP_NAME=server-0.0.1-SNAPSHOT.jar
+JAR_NAME=$(ls $REPOSITORY/build/libs/ | grep '.jar' | tail -n 1)
+JAR_PATH=$REPOSITORY/build/libs/$JAR_NAME
+
+CURRENT_PID=$(pgrep -f $APP_NAME)
+
+if [ -z $CURRENT_PID ]
+then
+ echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
+else
+ echo "> kill -15 $CURRENT_PID"
+ sudo kill -15 $CURRENT_PID
+ sleep 5
+fi
+
+echo "> $JAR_PATH 배포"
+
+if [ "$DEPLOYMENT_GROUP_NAME" == "sopetit-group" ]
+then
+ nohup java -jar -Dspring.profiles.active=dev /home/ubuntu/build/build/libs/server-0.0.1-SNAPSHOT.jar > /dev/null 2> /dev/null < /dev/null &
+fi
+
+if [ "$DEPLOYMENT_GROUP_NAME" == "prod-group" ]
+then
+ nohup java -jar -Dspring.profiles.active=prod /home/ubuntu/build/build/libs/server-0.0.1-SNAPSHOT.jar > /dev/null 2> /dev/null < /dev/null &
+fi
\ No newline at end of file
diff --git a/src/main/java/com/soptie/server/auth/controller/AuthController.java b/src/main/java/com/soptie/server/auth/controller/AuthController.java
new file mode 100644
index 00000000..58cd8720
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/controller/AuthController.java
@@ -0,0 +1,48 @@
+package com.soptie.server.auth.controller;
+
+import com.soptie.server.auth.dto.SignInRequest;
+import com.soptie.server.auth.service.AuthService;
+import com.soptie.server.common.dto.Response;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.security.Principal;
+
+import static com.soptie.server.auth.message.SuccessMessage.*;
+import static com.soptie.server.common.dto.Response.success;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1/auth")
+public class AuthController {
+
+ private final AuthService authService;
+
+ @PostMapping
+ public ResponseEntity signIn(@RequestHeader("Authorization") String socialAccessToken, @RequestBody SignInRequest request) {
+ val response = authService.signIn(socialAccessToken, request);
+ return ResponseEntity.ok(success(SUCCESS_SIGN_IN.getMessage(), response));
+ }
+
+ @PostMapping("/token")
+ public ResponseEntity reissueToken(@RequestHeader("Authorization") String refreshToken) {
+ val response = authService.reissueToken(refreshToken);
+ return ResponseEntity.ok(success(SUCCESS_RECREATE_TOKEN.getMessage(), response));
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity signOut(Principal principal) {
+ val memberId = Long.parseLong(principal.getName());
+ authService.signOut(memberId);
+ return ResponseEntity.ok(success(SUCCESS_SIGN_OUT.getMessage()));
+ }
+
+ @DeleteMapping
+ public ResponseEntity withdrawal(Principal principal) {
+ val memberId = Long.parseLong(principal.getName());
+ authService.withdraw(memberId);
+ return ResponseEntity.ok(success(SUCCESS_WITHDRAWAL.getMessage()));
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/dto/SignInRequest.java b/src/main/java/com/soptie/server/auth/dto/SignInRequest.java
new file mode 100644
index 00000000..872d9a0c
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/dto/SignInRequest.java
@@ -0,0 +1,14 @@
+package com.soptie.server.auth.dto;
+
+import com.soptie.server.member.entity.SocialType;
+
+import lombok.NonNull;
+
+public record SignInRequest(
+ @NonNull SocialType socialType
+) {
+
+ public static SignInRequest of(SocialType socialType) {
+ return new SignInRequest(socialType);
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/dto/SignInResponse.java b/src/main/java/com/soptie/server/auth/dto/SignInResponse.java
new file mode 100644
index 00000000..7ab612e8
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/dto/SignInResponse.java
@@ -0,0 +1,21 @@
+package com.soptie.server.auth.dto;
+
+import com.soptie.server.auth.vo.Token;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record SignInResponse(
+ @NonNull String accessToken,
+ @NonNull String refreshToken,
+ boolean isMemberDollExist
+) {
+
+ public static SignInResponse of(Token token, boolean isMemberDollExist) {
+ return SignInResponse.builder()
+ .accessToken(token.getAccessToken())
+ .refreshToken(token.getRefreshToken())
+ .isMemberDollExist(isMemberDollExist)
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/dto/TokenResponse.java b/src/main/java/com/soptie/server/auth/dto/TokenResponse.java
new file mode 100644
index 00000000..ad715a6a
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/dto/TokenResponse.java
@@ -0,0 +1,16 @@
+package com.soptie.server.auth.dto;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record TokenResponse(
+ @NonNull String accessToken
+) {
+
+ public static TokenResponse of(String accessToken) {
+ return TokenResponse.builder()
+ .accessToken(accessToken)
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/exception/AuthException.java b/src/main/java/com/soptie/server/auth/exception/AuthException.java
new file mode 100644
index 00000000..189342c2
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/exception/AuthException.java
@@ -0,0 +1,16 @@
+package com.soptie.server.auth.exception;
+
+import com.soptie.server.auth.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class AuthException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public AuthException(ErrorCode errorCode) {
+ super("[AuthException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java b/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java
new file mode 100644
index 00000000..581d364e
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java
@@ -0,0 +1,35 @@
+package com.soptie.server.auth.jwt;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.soptie.server.common.dto.Response;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+import static com.soptie.server.auth.message.ErrorCode.*;
+import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
+
+@Component
+@RequiredArgsConstructor
+public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
+ setResponse(response);
+ }
+
+ private void setResponse(HttpServletResponse response) throws IOException {
+ response.setCharacterEncoding("UTF-8");
+ response.setContentType(APPLICATION_JSON_VALUE);
+ response.setStatus(SC_UNAUTHORIZED);
+ response.getWriter().println(objectMapper.writeValueAsString(Response.fail(INVALID_TOKEN.getMessage())));
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java
new file mode 100644
index 00000000..e05c9eb8
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java
@@ -0,0 +1,69 @@
+package com.soptie.server.auth.jwt;
+
+import com.soptie.server.auth.exception.AuthException;
+import com.soptie.server.common.config.ValueConfig;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import lombok.val;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+import static com.soptie.server.auth.jwt.JwtValidationType.VALID_JWT;
+import static io.jsonwebtoken.lang.Strings.hasText;
+import static org.springframework.http.HttpHeaders.AUTHORIZATION;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final ValueConfig valueConfig;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+ try {
+ val token = getAccessTokenFromRequest(request);
+ if (hasText(token) && jwtTokenProvider.validateToken(token) == VALID_JWT) {
+ val authentication = new UserAuthentication(getMemberId(token), null, null);
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ } catch (Exception exception) {
+ log.error(exception.getMessage());
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ private long getMemberId(String token) {
+ return jwtTokenProvider.getUserFromJwt(token);
+ }
+
+ private String getAccessTokenFromRequest(HttpServletRequest request) {
+ return isContainsAccessToken(request) ? getAuthorizationAccessToken(request) : null;
+ }
+
+ private boolean isContainsAccessToken(HttpServletRequest request) {
+ val authorization = request.getHeader(AUTHORIZATION);
+ return authorization != null && authorization.startsWith(valueConfig.getBEARER_HEADER());
+ }
+
+ private String getAuthorizationAccessToken(HttpServletRequest request) {
+ return getTokenFromBearerString(request.getHeader(AUTHORIZATION));
+ }
+
+ private String getTokenFromBearerString(String token) {
+ return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK());
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java b/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java
new file mode 100644
index 00000000..a3f1c6ba
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java
@@ -0,0 +1,78 @@
+package com.soptie.server.auth.jwt;
+
+import com.soptie.server.common.config.ValueConfig;
+import io.jsonwebtoken.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import lombok.val;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.util.Date;
+
+import static com.soptie.server.auth.jwt.JwtValidationType.*;
+import static io.jsonwebtoken.Header.*;
+import static io.jsonwebtoken.security.Keys.hmacShaKeyFor;
+import static java.util.Base64.getEncoder;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtTokenProvider {
+
+ private final ValueConfig valueConfig;
+
+ public String generateToken(Authentication authentication, long expiration) {
+ return Jwts.builder()
+ .setHeaderParam(TYPE, JWT_TYPE)
+ .setClaims(generateClaims(authentication))
+ .setIssuedAt(new Date(System.currentTimeMillis()))
+ .setExpiration(new Date(System.currentTimeMillis() + expiration))
+ .signWith(getSigningKey())
+ .compact();
+ }
+
+ public JwtValidationType validateToken(String token) {
+ try {
+ getBody(token);
+ return VALID_JWT;
+ } catch (MalformedJwtException exception) {
+ log.error(exception.getMessage());
+ return INVALID_JWT_TOKEN;
+ } catch (ExpiredJwtException exception) {
+ log.error(exception.getMessage());
+ return EXPIRED_JWT_TOKEN;
+ } catch (UnsupportedJwtException exception) {
+ log.error(exception.getMessage());
+ return UNSUPPORTED_JWT_TOKEN;
+ } catch (IllegalArgumentException exception) {
+ log.error(exception.getMessage());
+ return EMPTY_JWT;
+ }
+ }
+
+ private Claims generateClaims(Authentication authentication) {
+ val claims = Jwts.claims();
+ claims.put("memberId", authentication.getPrincipal());
+ return claims;
+ }
+
+ public Long getUserFromJwt(String token) {
+ val claims = getBody(token);
+ return Long.parseLong(claims.get("memberId").toString());
+ }
+
+ private Claims getBody(final String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(getSigningKey())
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ private SecretKey getSigningKey() {
+ val encodedKey = getEncoder().encodeToString(valueConfig.getSecretKey().getBytes());
+ return hmacShaKeyFor(encodedKey.getBytes());
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java b/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java
new file mode 100644
index 00000000..3f22222a
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java
@@ -0,0 +1,9 @@
+package com.soptie.server.auth.jwt;
+
+public enum JwtValidationType {
+ VALID_JWT,
+ INVALID_JWT_TOKEN,
+ EXPIRED_JWT_TOKEN,
+ UNSUPPORTED_JWT_TOKEN,
+ EMPTY_JWT
+}
diff --git a/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java b/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java
new file mode 100644
index 00000000..3e07e766
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java
@@ -0,0 +1,13 @@
+package com.soptie.server.auth.jwt;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+public class UserAuthentication extends UsernamePasswordAuthenticationToken {
+
+ public UserAuthentication(Object principal, Object credentials, Collection extends GrantedAuthority> authorities) {
+ super(principal, credentials, authorities);
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/message/ErrorCode.java b/src/main/java/com/soptie/server/auth/message/ErrorCode.java
new file mode 100644
index 00000000..8cf415fa
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/message/ErrorCode.java
@@ -0,0 +1,21 @@
+package com.soptie.server.auth.message;
+
+import static org.springframework.http.HttpStatus.*;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum ErrorCode {
+
+ /* 401 UNAUTHORIZED : 권한 없음 */
+ INVALID_TOKEN(UNAUTHORIZED, "유효하지 않은 토큰입니다."),
+ INVALID_KEY(UNAUTHORIZED, "유효하지 않은 키입니다."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/auth/message/SuccessMessage.java b/src/main/java/com/soptie/server/auth/message/SuccessMessage.java
new file mode 100644
index 00000000..36d2a2ca
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/message/SuccessMessage.java
@@ -0,0 +1,17 @@
+package com.soptie.server.auth.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+
+ SUCCESS_SIGN_IN("소셜로그인 성공"),
+ SUCCESS_RECREATE_TOKEN("토큰 재발급 성공"),
+ SUCCESS_SIGN_OUT("로그아웃 성공"),
+ SUCCESS_WITHDRAWAL("회원 탈퇴 성공"),
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/auth/service/AppleService.java b/src/main/java/com/soptie/server/auth/service/AppleService.java
new file mode 100644
index 00000000..c8c6cfcb
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/AppleService.java
@@ -0,0 +1,6 @@
+package com.soptie.server.auth.service;
+
+public interface AppleService {
+
+ String getAppleData(String socialAccessToken);
+}
diff --git a/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java b/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java
new file mode 100644
index 00000000..5a535931
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java
@@ -0,0 +1,148 @@
+package com.soptie.server.auth.service;
+
+import com.google.gson.*;
+import com.soptie.server.auth.exception.AuthException;
+import com.soptie.server.common.config.ValueConfig;
+import io.jsonwebtoken.Jwts;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.math.BigInteger;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.RSAPublicKeySpec;
+import java.util.Base64;
+import java.util.Objects;
+
+import static com.soptie.server.auth.message.ErrorCode.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AppleServiceImpl implements AppleService {
+
+ private final ValueConfig valueConfig;
+
+ @Override
+ public String getAppleData(String socialAccessToken) {
+ val publicKeyList = getApplePublicKeys();
+ val publicKey = makePublicKey(socialAccessToken, publicKeyList);
+
+ val userInfo = Jwts.parserBuilder()
+ .setSigningKey(publicKey)
+ .build()
+ .parseClaimsJws(getTokenFromBearerString(socialAccessToken))
+ .getBody();
+
+ val userInfoObject = (JsonObject) JsonParser.parseString(new Gson().toJson(userInfo));
+ return userInfoObject.get(valueConfig.getID()).getAsString();
+ }
+
+ private JsonArray getApplePublicKeys() {
+ val connection = sendHttpRequest();
+ val result = getHttpResponse(connection);
+ val keys = (JsonObject) JsonParser.parseString(result.toString());
+ return (JsonArray) keys.get(valueConfig.getKEY());
+ }
+
+ private HttpURLConnection sendHttpRequest() {
+ try {
+ val url = new URL(valueConfig.getAppleUri());
+ val connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod(HttpMethod.GET.name());
+ return connection;
+ } catch (IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ }
+
+ private StringBuilder getHttpResponse(HttpURLConnection connection) {
+ try {
+ val bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+ return splitHttpResponse(bufferedReader);
+ } catch (IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ }
+
+ private StringBuilder splitHttpResponse(BufferedReader bufferedReader) {
+ try {
+ val result = new StringBuilder();
+
+ String line;
+ while (Objects.nonNull(line = bufferedReader.readLine())) {
+ result.append(line);
+ }
+ bufferedReader.close();
+
+ return result;
+ } catch (IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ }
+
+ private PublicKey makePublicKey(String accessToken, JsonArray publicKeyList) {
+ val decodeArray = accessToken.split(valueConfig.getTOKEN_VALUE_DELIMITER());
+ val header = new String(Base64.getDecoder().decode(getTokenFromBearerString(decodeArray[0])));
+
+ val kid = ((JsonObject) JsonParser.parseString(header)).get(valueConfig.getKID_HEADER_KEY());
+ val alg = ((JsonObject) JsonParser.parseString(header)).get(valueConfig.getALG_HEADER_KEY());
+ val matchingPublicKey = findMatchingPublicKey(publicKeyList, kid, alg);
+
+ if (Objects.isNull(matchingPublicKey)) {
+ throw new AuthException(INVALID_KEY);
+ }
+
+ return getPublicKey(matchingPublicKey);
+ }
+
+ private String getTokenFromBearerString(String token) {
+ return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK());
+ }
+
+ private JsonObject findMatchingPublicKey(JsonArray publicKeyList, JsonElement kid, JsonElement alg) {
+ for (JsonElement publicKey : publicKeyList) {
+ val publicKeyObject = publicKey.getAsJsonObject();
+ val publicKid = publicKeyObject.get(valueConfig.getKID_HEADER_KEY());
+ val publicAlg = publicKeyObject.get(valueConfig.getALG_HEADER_KEY());
+
+ if (Objects.equals(kid, publicKid) && Objects.equals(alg, publicAlg)) {
+ return publicKeyObject;
+ }
+ }
+
+ return null;
+ }
+
+ private PublicKey getPublicKey(JsonObject object) {
+ try {
+ val modulus = object.get(valueConfig.getMODULUS()).toString();
+ val exponent = object.get(valueConfig.getEXPONENT()).toString();
+
+ val quotes = valueConfig.getQUOTES();
+ val modulusBytes = Base64.getUrlDecoder().decode(modulus.substring(quotes, modulus.length() - quotes));
+ val exponentBytes = Base64.getUrlDecoder().decode(exponent.substring(quotes, exponent.length() - quotes));
+
+ val positiveNumber = valueConfig.getPOSITIVE_NUMBER();
+ val modulusValue = new BigInteger(positiveNumber, modulusBytes);
+ val exponentValue = new BigInteger(positiveNumber, exponentBytes);
+
+ val publicKeySpec = new RSAPublicKeySpec(modulusValue, exponentValue);
+ val keyFactory = KeyFactory.getInstance(valueConfig.getRSA());
+
+ return keyFactory.generatePublic(publicKeySpec);
+ } catch (InvalidKeySpecException | NoSuchAlgorithmException exception) {
+ throw new AuthException(INVALID_KEY);
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/service/AuthService.java b/src/main/java/com/soptie/server/auth/service/AuthService.java
new file mode 100644
index 00000000..bb31fe8c
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/AuthService.java
@@ -0,0 +1,13 @@
+package com.soptie.server.auth.service;
+
+import com.soptie.server.auth.dto.SignInRequest;
+import com.soptie.server.auth.dto.SignInResponse;
+import com.soptie.server.auth.dto.TokenResponse;
+
+public interface AuthService {
+
+ SignInResponse signIn(String socialAccessToken, SignInRequest request);
+ void signOut(long memberId);
+ void withdraw(long memberId);
+ TokenResponse reissueToken(String refreshToken);
+}
diff --git a/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java b/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java
new file mode 100644
index 00000000..d5a72a8d
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java
@@ -0,0 +1,160 @@
+package com.soptie.server.auth.service;
+
+import com.soptie.server.auth.dto.SignInRequest;
+import com.soptie.server.auth.dto.SignInResponse;
+import com.soptie.server.auth.dto.TokenResponse;
+import com.soptie.server.auth.jwt.JwtTokenProvider;
+import com.soptie.server.auth.jwt.UserAuthentication;
+import com.soptie.server.auth.vo.Token;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.entity.SocialType;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.member.service.MemberService;
+import com.soptie.server.memberDoll.entity.MemberDoll;
+import com.soptie.server.memberDoll.service.MemberDollService;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+import com.soptie.server.memberRoutine.service.CompletedMemberDailyRoutineService;
+import com.soptie.server.memberRoutine.service.MemberDailyRoutineService;
+import com.soptie.server.memberRoutine.service.MemberHappinessRoutineService;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Objects;
+
+import static com.soptie.server.member.message.ErrorCode.INVALID_MEMBER;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AuthServiceImpl implements AuthService {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final MemberRepository memberRepository;
+ private final KakaoService kakaoService;
+ private final AppleService appleService;
+ private final MemberService memberService;
+ private final MemberDailyRoutineService memberDailyRoutineService;
+ private final MemberHappinessRoutineService memberHappinessRoutineService;
+ private final MemberDollService memberDollService;
+ private final CompletedMemberDailyRoutineService completedMemberDailyRoutineService;
+ private final ValueConfig valueConfig;
+
+ @Override
+ @Transactional
+ public SignInResponse signIn(String socialAccessToken, SignInRequest request) {
+ val member = getMember(socialAccessToken, request);
+ val token = getToken(member);
+ val isMemberDollExist = member.isMemberDollExist();
+ return SignInResponse.of(token, isMemberDollExist);
+ }
+
+ @Override
+ public TokenResponse reissueToken(String refreshToken) {
+ val member = memberRepository.findByRefreshToken(getTokenFromBearerString(refreshToken))
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ val token = generateAccessToken(new UserAuthentication(member.getId(), null, null));
+ return TokenResponse.of(token);
+ }
+
+ private String getTokenFromBearerString(String token) {
+ return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK());
+ }
+
+ @Override
+ @Transactional
+ public void signOut(long memberId) {
+ val member = findMember(memberId);
+ member.resetRefreshToken();
+ }
+
+ @Override
+ @Transactional
+ public void withdraw(long memberId) {
+ val member = findMember(memberId);
+ deleteMemberDoll(member.getMemberDoll());
+ deleteMemberDailyRoutines(member.getDailyRoutines());
+ deleteMemberHappinessRoutine(member.getHappinessRoutine());
+ deleteCompletedMemberDailyRoutines(member);
+ deleteMember(member);
+ }
+
+ private Member getMember(String socialAccessToken, SignInRequest request) {
+ val socialType = request.socialType();
+ val socialId = getSocialId(socialAccessToken, socialType);
+ return signUp(socialType, socialId);
+ }
+
+ private String getSocialId(String socialAccessToken, SocialType socialType) {
+ return switch (socialType) {
+ case APPLE -> appleService.getAppleData(socialAccessToken);
+ case KAKAO -> kakaoService.getKakaoData(socialAccessToken);
+ };
+ }
+
+ private Member signUp(SocialType socialType, String socialId) {
+ return memberRepository.findBySocialTypeAndSocialId(socialType, socialId)
+ .orElseGet(() -> saveMember(socialType, socialId));
+ }
+
+ private Member saveMember(SocialType socialType, String socialId) {
+ val member = Member.builder()
+ .socialType(socialType)
+ .socialId(socialId)
+ .build();
+ return memberRepository.save(member);
+ }
+
+ private Token getToken(Member member) {
+ val token = generateToken(new UserAuthentication(member.getId(), null, null));
+ member.updateRefreshToken(token.getRefreshToken());
+ return token;
+ }
+
+ private Token generateToken(Authentication authentication) {
+ return Token.builder()
+ .accessToken(jwtTokenProvider.generateToken(authentication, valueConfig.getAccessTokenExpired()))
+ .refreshToken(jwtTokenProvider.generateToken(authentication, valueConfig.getRefreshTokenExpired()))
+ .build();
+ }
+
+ private Member findMember(long id) {
+ return memberRepository.findById(id)
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ private String generateAccessToken(Authentication authentication) {
+ return jwtTokenProvider.generateToken(authentication, valueConfig.getAccessTokenExpired());
+ }
+
+ private void deleteMemberDoll(MemberDoll memberDoll) {
+ if (Objects.nonNull(memberDoll)) {
+ memberDollService.deleteMemberDoll(memberDoll);
+ }
+ }
+
+ private void deleteMemberDailyRoutines(List memberDailyRoutines) {
+ memberDailyRoutines
+ .forEach(memberDailyRoutineService::deleteMemberDailyRoutine);
+ }
+
+ private void deleteMemberHappinessRoutine(MemberHappinessRoutine memberHappinessRoutine) {
+ if (Objects.nonNull(memberHappinessRoutine)) {
+ memberHappinessRoutineService.deleteMemberHappinessRoutine(memberHappinessRoutine);
+ }
+ }
+
+ private void deleteCompletedMemberDailyRoutines(Member member) {
+ completedMemberDailyRoutineService.deleteCompletedMemberDailyRoutines(member);
+ }
+
+ private void deleteMember(Member member) {
+ memberService.deleteMember(member);
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/service/KakaoService.java b/src/main/java/com/soptie/server/auth/service/KakaoService.java
new file mode 100644
index 00000000..ee39320c
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/KakaoService.java
@@ -0,0 +1,6 @@
+package com.soptie.server.auth.service;
+
+public interface KakaoService {
+
+ String getKakaoData(String socialAccessToken);
+}
diff --git a/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java b/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java
new file mode 100644
index 00000000..5f276ba0
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java
@@ -0,0 +1,38 @@
+package com.soptie.server.auth.service;
+
+import static com.soptie.server.auth.message.ErrorCode.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.JsonArray;
+import com.soptie.server.auth.exception.AuthException;
+import com.soptie.server.common.config.ValueConfig;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class KakaoServiceImpl implements KakaoService {
+
+ private final ValueConfig valueConfig;
+ private final RestTemplate restTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public String getKakaoData(String socialAccessToken) {
+ try {
+ val headers = new HttpHeaders();
+ headers.add("Authorization", socialAccessToken);
+ val httpEntity = new HttpEntity(headers);
+ val responseData = restTemplate.postForEntity(valueConfig.getKakaoUri(), httpEntity, Object.class);
+ return objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString();
+ } catch (Exception exception) {
+ throw new AuthException(INVALID_TOKEN);
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/auth/vo/Token.java b/src/main/java/com/soptie/server/auth/vo/Token.java
new file mode 100644
index 00000000..76f17d0a
--- /dev/null
+++ b/src/main/java/com/soptie/server/auth/vo/Token.java
@@ -0,0 +1,32 @@
+package com.soptie.server.auth.vo;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.Objects;
+
+@Getter
+public class Token {
+
+ private final String accessToken;
+ private final String refreshToken;
+
+ @Builder
+ public Token(String accessToken, String refreshToken) {
+ this.accessToken = accessToken;
+ this.refreshToken = refreshToken;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Token token = (Token) o;
+ return Objects.equals(accessToken, token.accessToken) && Objects.equals(refreshToken, token.refreshToken);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(accessToken, refreshToken);
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java b/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java
new file mode 100644
index 00000000..9a4b314c
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java
@@ -0,0 +1,9 @@
+package com.soptie.server.common.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@Configuration
+@EnableJpaAuditing
+public class JpaAuditingConfig {
+}
diff --git a/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java b/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java
new file mode 100644
index 00000000..1d53ad24
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java
@@ -0,0 +1,17 @@
+package com.soptie.server.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import jakarta.persistence.EntityManager;
+
+@Configuration
+public class JpaQueryFactoryConfig {
+
+ @Bean
+ JPAQueryFactory jpaQueryFactory(EntityManager em) {
+ return new JPAQueryFactory(em);
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java b/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java
new file mode 100644
index 00000000..b0102b7f
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java
@@ -0,0 +1,14 @@
+package com.soptie.server.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+ @Bean
+ public RestTemplate restTemplate() {
+ return new RestTemplate();
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/config/SecurityConfig.java b/src/main/java/com/soptie/server/common/config/SecurityConfig.java
new file mode 100644
index 00000000..aa648b97
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/SecurityConfig.java
@@ -0,0 +1,67 @@
+package com.soptie.server.common.config;
+
+import com.soptie.server.auth.jwt.CustomJwtAuthenticationEntryPoint;
+import com.soptie.server.auth.jwt.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
+
+ @Bean
+ @Profile("dev")
+ public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception {
+ permitSwaggerUri(http);
+ setHttp(http);
+ return http.build();
+ }
+
+ private void permitSwaggerUri(HttpSecurity http) throws Exception {
+ http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
+ .requestMatchers(new AntPathRequestMatcher("/v3/api-docs/**")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/docs/**")).permitAll());
+ }
+
+ @Bean
+ @Profile("prod")
+ public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception {
+ setHttp(http);
+ return http.build();
+ }
+
+ private void setHttp(HttpSecurity http) throws Exception {
+ http.csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .sessionManagement(sessionManagement ->
+ sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ )
+ .exceptionHandling(exceptionHandling ->
+ exceptionHandling.authenticationEntryPoint(customJwtAuthenticationEntryPoint))
+ .authorizeHttpRequests(authorizeHttpRequests ->
+ authorizeHttpRequests
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/auth", "POST")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/test")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/routines/daily/themes")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/routines/daily")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/dolls/image/{type}")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/api/v1/versions/client/app")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
+ .anyRequest().authenticated())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/config/TimezoneConfig.java b/src/main/java/com/soptie/server/common/config/TimezoneConfig.java
new file mode 100644
index 00000000..74bee708
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/TimezoneConfig.java
@@ -0,0 +1,16 @@
+package com.soptie.server.common.config;
+
+import java.util.TimeZone;
+
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.annotation.PostConstruct;
+
+@Configuration
+public class TimezoneConfig {
+
+ @PostConstruct
+ public void init() {
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/config/ValueConfig.java b/src/main/java/com/soptie/server/common/config/ValueConfig.java
new file mode 100644
index 00000000..7818443a
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/config/ValueConfig.java
@@ -0,0 +1,69 @@
+package com.soptie.server.common.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Getter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+@Configuration
+@Getter
+public class ValueConfig {
+
+ @Value("${jwt.secret}")
+ private String secretKey;
+
+ @Value("${spring.security.oauth2.client.registration.kakao.client-id")
+ private String clientId;
+
+ @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri")
+ private String redirectUri;
+
+ @Value("${spring.security.oauth2.client.registration.kakao.client-secret")
+ private String clientSecret;
+
+ @Value("${spring.security.oauth2.client.provider.kakao.token-uri")
+ private String tokenUri;
+
+ @Value("${jwt.KAKAO_URL}")
+ private String kakaoUri;
+
+ @Value("${jwt.APPLE_URL}")
+ private String appleUri;
+
+ @Value("${jwt.ACCESS_TOKEN_EXPIRED}")
+ private Long accessTokenExpired;
+
+ @Value("${jwt.REFRESH_TOKEN_EXPIRED}")
+ private Long refreshTokenExpired;
+
+ private final String IOS_FORCE_UPDATE_VERSION = "0.0.9";
+ private final String IOS_APP_VERSION = "1.0.0";
+ private final String ANDROID_FORCE_UPDATE_VERSION = "0.0.9";
+ private final String ANDROID_APP_VERSION = "1.0.0";
+ private final String NOTIFICATION_TITLE = "새로운 버전이 업데이트 되었어요!";
+ private final String NOTIFICATION_CONTENT = "안정적인 서비스 사용을 위해\n최신버전으로 업데이트 해주세요.";
+ private final String TOKEN_VALUE_DELIMITER = "\\.";
+ private final String BEARER_HEADER = "Bearer ";
+ private final String BLANK = "";
+ private final String MODULUS = "n";
+ private final String EXPONENT = "e";
+ private final String KID_HEADER_KEY = "kid";
+ private final String ALG_HEADER_KEY = "alg";
+ private final String RSA = "RSA";
+ private final String KEY = "keys";
+ private final String ID = "sub";
+ private final int QUOTES = 1;
+ private final int POSITIVE_NUMBER = 1;
+
+ public static final int MIN_COTTON_COUNT = 0;
+ public static final int DAILY_ROUTINE_MAX_COUNT = 3;
+ public static final String MEMBER_DOLL_CONDITION = "^[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]{1,10}$";
+
+ @PostConstruct
+ protected void init() {
+ secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/dto/Response.java b/src/main/java/com/soptie/server/common/dto/Response.java
new file mode 100644
index 00000000..05374758
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/dto/Response.java
@@ -0,0 +1,22 @@
+package com.soptie.server.common.dto;
+
+import lombok.NonNull;
+
+public record Response(
+ boolean success,
+ @NonNull String message,
+ Object data
+) {
+
+ public static Response success(String message, Object data) {
+ return new Response(true, message, data);
+ }
+
+ public static Response success(String message) {
+ return new Response(true, message, null);
+ }
+
+ public static Response fail(String message) {
+ return new Response(false, message, null);
+ }
+}
diff --git a/src/main/java/com/soptie/server/common/entity/BaseTime.java b/src/main/java/com/soptie/server/common/entity/BaseTime.java
new file mode 100644
index 00000000..db8aa132
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/entity/BaseTime.java
@@ -0,0 +1,24 @@
+package com.soptie.server.common.entity;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+@Getter
+public class BaseTime {
+
+ @CreatedDate
+ protected LocalDateTime createdAt;
+
+ @LastModifiedDate
+ protected LocalDateTime updatedAt;
+
+}
diff --git a/src/main/java/com/soptie/server/common/handler/ErrorHandler.java b/src/main/java/com/soptie/server/common/handler/ErrorHandler.java
new file mode 100644
index 00000000..9b403b5f
--- /dev/null
+++ b/src/main/java/com/soptie/server/common/handler/ErrorHandler.java
@@ -0,0 +1,62 @@
+package com.soptie.server.common.handler;
+
+import static com.soptie.server.common.dto.Response.*;
+
+import com.soptie.server.memberDoll.exception.MemberDollException;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import com.soptie.server.auth.exception.AuthException;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.doll.exception.DollException;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.routine.exception.RoutineException;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.*;
+
+@Slf4j
+@RestControllerAdvice
+public class ErrorHandler {
+
+ @ExceptionHandler(AuthException.class)
+ public ResponseEntity authException(AuthException exception) {
+ log.error(exception.getMessage());
+
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(DollException.class)
+ public ResponseEntity dollException(DollException exception) {
+ log.error(exception.getMessage());
+
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(MemberException.class)
+ public ResponseEntity memberException(MemberException exception) {
+ log.error(exception.getMessage());
+
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(MemberDollException.class)
+ public ResponseEntity memberDollException(MemberDollException exception) {
+ log.error(exception.getMessage());
+
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(RoutineException.class)
+ public ResponseEntity routineException(RoutineException exception) {
+ log.error(exception.getMessage());
+
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage()));
+ }
+}
diff --git a/src/main/java/com/soptie/server/conversation/entity/Conversation.java b/src/main/java/com/soptie/server/conversation/entity/Conversation.java
new file mode 100644
index 00000000..afdbb16c
--- /dev/null
+++ b/src/main/java/com/soptie/server/conversation/entity/Conversation.java
@@ -0,0 +1,22 @@
+package com.soptie.server.conversation.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class Conversation {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "conversation_id")
+ private Long id;
+
+ private String content;
+}
diff --git a/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java b/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java
new file mode 100644
index 00000000..3151dbf8
--- /dev/null
+++ b/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java
@@ -0,0 +1,7 @@
+package com.soptie.server.conversation.repository;
+
+import com.soptie.server.conversation.entity.Conversation;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ConversationRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/soptie/server/doll/controller/DollController.java b/src/main/java/com/soptie/server/doll/controller/DollController.java
new file mode 100644
index 00000000..7bfc4a0c
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/controller/DollController.java
@@ -0,0 +1,30 @@
+package com.soptie.server.doll.controller;
+
+import static com.soptie.server.common.dto.Response.*;
+import static com.soptie.server.doll.message.SuccessMessage.*;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.doll.service.DollService;
+
+import lombok.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/dolls")
+public class DollController {
+
+ private final DollService dollService;
+
+ @GetMapping("/image/{type}")
+ public ResponseEntity getDollImages(@PathVariable DollType type) {
+ val response = dollService.getDollImage(type);
+ return ResponseEntity.ok(success(SUCCESS_GET_IMAGE.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java b/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java
new file mode 100644
index 00000000..4cd69c9c
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java
@@ -0,0 +1,14 @@
+package com.soptie.server.doll.dto;
+
+import com.soptie.server.doll.entity.Doll;
+
+import lombok.NonNull;
+
+public record DollImageResponse(
+ @NonNull String faceImageUrl
+) {
+
+ public static DollImageResponse of(Doll doll) {
+ return new DollImageResponse(doll.getImageInfo().getFaceImageUrl());
+ }
+}
diff --git a/src/main/java/com/soptie/server/doll/entity/Doll.java b/src/main/java/com/soptie/server/doll/entity/Doll.java
new file mode 100644
index 00000000..b16312a8
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/entity/Doll.java
@@ -0,0 +1,34 @@
+package com.soptie.server.doll.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class Doll {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "doll_id")
+ private Long id;
+
+ @Enumerated(value = EnumType.STRING)
+ private DollType dollType;
+
+ @Embedded
+ private DollImage imageInfo;
+
+ public Doll(DollType dollType, DollImage imageInfo) {
+ this.dollType = dollType;
+ this.imageInfo = imageInfo;
+ }
+}
diff --git a/src/main/java/com/soptie/server/doll/entity/DollImage.java b/src/main/java/com/soptie/server/doll/entity/DollImage.java
new file mode 100644
index 00000000..dd3dac73
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/entity/DollImage.java
@@ -0,0 +1,17 @@
+package com.soptie.server.doll.entity;
+
+import jakarta.persistence.Embeddable;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor
+@Getter
+public class DollImage {
+
+ private String faceImageUrl;
+
+ private String attentionImageUrl;
+
+ private String frameImageUrl;
+}
diff --git a/src/main/java/com/soptie/server/doll/entity/DollType.java b/src/main/java/com/soptie/server/doll/entity/DollType.java
new file mode 100644
index 00000000..84b3e960
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/entity/DollType.java
@@ -0,0 +1,5 @@
+package com.soptie.server.doll.entity;
+
+public enum DollType {
+ BROWN, GRAY, RED, WHITE
+}
diff --git a/src/main/java/com/soptie/server/doll/exception/DollException.java b/src/main/java/com/soptie/server/doll/exception/DollException.java
new file mode 100644
index 00000000..d0d8abdb
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/exception/DollException.java
@@ -0,0 +1,16 @@
+package com.soptie.server.doll.exception;
+
+import com.soptie.server.doll.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class DollException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public DollException(ErrorCode errorCode) {
+ super("[DollException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/soptie/server/doll/message/ErrorCode.java b/src/main/java/com/soptie/server/doll/message/ErrorCode.java
new file mode 100644
index 00000000..b78770cf
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/message/ErrorCode.java
@@ -0,0 +1,24 @@
+package com.soptie.server.doll.message;
+
+import static org.springframework.http.HttpStatus.*;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum ErrorCode {
+
+ /* 400 BAD_REQUEST : 잘못된 요청 */
+ INVALID_TYPE(BAD_REQUEST, "유효하지 않은 인형 타입입니다."),
+ INVALID_NAME(BAD_REQUEST, "조건에 맞지 않는 이름입니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ NOT_EXIST_MEMBER_DOLL(NOT_FOUND, "애착인형이 없는 회원입니다."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/doll/message/SuccessMessage.java b/src/main/java/com/soptie/server/doll/message/SuccessMessage.java
new file mode 100644
index 00000000..fc2b37a7
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/message/SuccessMessage.java
@@ -0,0 +1,14 @@
+package com.soptie.server.doll.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+
+ SUCCESS_GET_IMAGE("인형 이미지 조회 성공"),
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/doll/repository/DollRepository.java b/src/main/java/com/soptie/server/doll/repository/DollRepository.java
new file mode 100644
index 00000000..c35ce03d
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/repository/DollRepository.java
@@ -0,0 +1,12 @@
+package com.soptie.server.doll.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.doll.entity.Doll;
+import com.soptie.server.doll.entity.DollType;
+
+public interface DollRepository extends JpaRepository {
+ Optional findByDollType(DollType type);
+}
diff --git a/src/main/java/com/soptie/server/doll/service/DollService.java b/src/main/java/com/soptie/server/doll/service/DollService.java
new file mode 100644
index 00000000..c2fa9e8b
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/service/DollService.java
@@ -0,0 +1,8 @@
+package com.soptie.server.doll.service;
+
+import com.soptie.server.doll.dto.DollImageResponse;
+import com.soptie.server.doll.entity.DollType;
+
+public interface DollService {
+ DollImageResponse getDollImage(DollType type);
+}
diff --git a/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java b/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java
new file mode 100644
index 00000000..bb49b553
--- /dev/null
+++ b/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java
@@ -0,0 +1,33 @@
+package com.soptie.server.doll.service;
+
+import static com.soptie.server.doll.message.ErrorCode.*;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.soptie.server.doll.dto.DollImageResponse;
+import com.soptie.server.doll.entity.Doll;
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.doll.exception.DollException;
+import com.soptie.server.doll.repository.DollRepository;
+
+import lombok.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class DollServiceImpl implements DollService {
+
+ private final DollRepository dollRepository;
+
+ @Override
+ public DollImageResponse getDollImage(DollType type) {
+ val doll = findDoll(type);
+ return DollImageResponse.of(doll);
+ }
+
+ private Doll findDoll(DollType type) {
+ return dollRepository.findByDollType(type)
+ .orElseThrow(() -> new DollException(INVALID_TYPE));
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/controller/MemberController.java b/src/main/java/com/soptie/server/member/controller/MemberController.java
new file mode 100644
index 00000000..7e951981
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/controller/MemberController.java
@@ -0,0 +1,57 @@
+package com.soptie.server.member.controller;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.member.dto.MemberProfileRequest;
+import com.soptie.server.member.entity.CottonType;
+import com.soptie.server.member.service.MemberService;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import java.net.URI;
+import java.security.Principal;
+
+import static com.soptie.server.common.dto.Response.success;
+import static com.soptie.server.member.message.SuccessMessage.SUCCESS_CREATE_PROFILE;
+import static com.soptie.server.member.message.SuccessMessage.SUCCESS_GIVE_COTTON;
+import static com.soptie.server.member.message.SuccessMessage.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1/members")
+public class MemberController {
+
+ private final MemberService memberService;
+
+ @PostMapping
+ public ResponseEntity createMemberProfile(Principal principal, @RequestBody MemberProfileRequest request) {
+ val memberId = Long.parseLong(principal.getName());
+ memberService.createMemberProfile(memberId, request);
+ return ResponseEntity.created(getURI())
+ .body(success(SUCCESS_CREATE_PROFILE.getMessage()));
+ }
+
+ private URI getURI() {
+ return ServletUriComponentsBuilder
+ .fromCurrentRequest()
+ .path("/")
+ .buildAndExpand()
+ .toUri();
+ }
+
+ @PatchMapping("/cotton/{cottonType}")
+ public ResponseEntity giveCotton(Principal principal, @PathVariable CottonType cottonType) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberService.giveCotton(memberId, cottonType);
+ return ResponseEntity.ok(success(SUCCESS_GIVE_COTTON.getMessage(), response));
+ }
+
+ @GetMapping
+ public ResponseEntity getMemberHomeInfo(Principal principal) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberService.getMemberHomeInfo(memberId);
+ return ResponseEntity.ok(success(SUCCESS_HOME_INFO.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java b/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java
new file mode 100644
index 00000000..78983a56
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java
@@ -0,0 +1,15 @@
+package com.soptie.server.member.dto;
+
+import lombok.Builder;
+
+@Builder
+public record CottonCountResponse(
+ int cottonCount
+) {
+
+ public static CottonCountResponse of(int cottonCount) {
+ return CottonCountResponse.builder()
+ .cottonCount(cottonCount)
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java b/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java
new file mode 100644
index 00000000..b0c58f5c
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java
@@ -0,0 +1,30 @@
+package com.soptie.server.member.dto;
+
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.member.entity.Member;
+import lombok.Builder;
+import lombok.NonNull;
+
+import java.util.List;
+
+@Builder
+public record MemberHomeInfoResponse(
+ @NonNull String name,
+ @NonNull DollType dollType,
+ @NonNull String frameImageUrl,
+ int dailyCottonCount,
+ int happinessCottonCount,
+ @NonNull List conversations
+) {
+
+ public static MemberHomeInfoResponse of(Member member, List conversations) {
+ return MemberHomeInfoResponse.builder()
+ .name(member.getMemberDoll().getName())
+ .dollType(member.getMemberDoll().getDoll().getDollType())
+ .frameImageUrl(member.getMemberDoll().getDoll().getImageInfo().getFrameImageUrl())
+ .dailyCottonCount(member.getCottonInfo().getDailyCottonCount())
+ .happinessCottonCount(member.getCottonInfo().getHappinessCottonCount())
+ .conversations(conversations)
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java b/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java
new file mode 100644
index 00000000..6393042e
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java
@@ -0,0 +1,14 @@
+package com.soptie.server.member.dto;
+
+import com.soptie.server.doll.entity.DollType;
+
+import java.util.List;
+
+import lombok.NonNull;
+
+public record MemberProfileRequest(
+ @NonNull DollType dollType,
+ @NonNull String name,
+ @NonNull List routines
+) {
+}
diff --git a/src/main/java/com/soptie/server/member/entity/Cotton.java b/src/main/java/com/soptie/server/member/entity/Cotton.java
new file mode 100644
index 00000000..1431aa1a
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/entity/Cotton.java
@@ -0,0 +1,57 @@
+package com.soptie.server.member.entity;
+
+import static com.soptie.server.common.config.ValueConfig.*;
+import static com.soptie.server.member.message.ErrorCode.*;
+
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.memberDoll.entity.MemberDoll;
+
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor
+@AllArgsConstructor(access = AccessLevel.PACKAGE)
+@Getter
+public class Cotton {
+
+ private int dailyCottonCount;
+ private int happinessCottonCount;
+
+ protected void addDailyCotton() {
+ this.dailyCottonCount++;
+ }
+
+ protected void addHappinessCotton(){
+ this.happinessCottonCount++;
+ }
+
+ protected int subtractAndGetCotton(CottonType type, MemberDoll memberDoll) {
+ return switch (type) {
+ case DAILY -> subtractAndGetDailyCotton();
+ case HAPPINESS -> subtractAndGetHappinessCotton(memberDoll);
+ };
+ }
+
+ private int subtractAndGetDailyCotton() {
+ checkCount(this.dailyCottonCount);
+ this.dailyCottonCount--;
+ return this.dailyCottonCount;
+ }
+
+ private int subtractAndGetHappinessCotton(MemberDoll memberDoll) {
+ checkCount(this.happinessCottonCount);
+ this.happinessCottonCount--;
+ memberDoll.addHappinessCottonCount();
+ return this.happinessCottonCount;
+ }
+
+ private void checkCount(int count) {
+ if (count <= MIN_COTTON_COUNT) {
+ throw new MemberException(NOT_ENOUGH_COTTON);
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/entity/CottonType.java b/src/main/java/com/soptie/server/member/entity/CottonType.java
new file mode 100644
index 00000000..a197929c
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/entity/CottonType.java
@@ -0,0 +1,6 @@
+package com.soptie.server.member.entity;
+
+public enum CottonType {
+ DAILY,
+ HAPPINESS
+}
diff --git a/src/main/java/com/soptie/server/member/entity/Member.java b/src/main/java/com/soptie/server/member/entity/Member.java
new file mode 100644
index 00000000..2a48f684
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/entity/Member.java
@@ -0,0 +1,144 @@
+package com.soptie.server.member.entity;
+
+import static com.soptie.server.common.config.ValueConfig.*;
+import static com.soptie.server.doll.message.ErrorCode.*;
+import static com.soptie.server.member.message.ErrorCode.*;
+import static com.soptie.server.routine.message.ErrorCode.*;
+
+import com.soptie.server.common.entity.BaseTime;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.memberDoll.entity.MemberDoll;
+import com.soptie.server.memberDoll.exception.MemberDollException;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.exception.RoutineException;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class Member extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_id")
+ private Long id;
+
+ @Enumerated(value = EnumType.STRING)
+ private SocialType socialType;
+ private String socialId;
+
+ private String refreshToken;
+
+ @Embedded
+ private Cotton cottonInfo;
+
+ @OneToOne
+ @JoinColumn(name = "doll_id")
+ private MemberDoll memberDoll;
+
+ @OneToMany(mappedBy = "member")
+ private final List dailyRoutines = new ArrayList<>();
+
+ @OneToOne
+ @JoinColumn(name = "happiness_routine_id")
+ private MemberHappinessRoutine happinessRoutine;
+
+ @Builder
+ public Member(SocialType socialType, String socialId) {
+ this.socialType = socialType;
+ this.socialId = socialId;
+ this.cottonInfo = new Cotton(0, 0);
+ }
+
+ public void setMemberDoll(MemberDoll memberDoll) {
+ this.memberDoll = memberDoll;
+ }
+
+ public void resetHappinessRoutine() {
+ this.happinessRoutine = null;
+ }
+
+ public void addHappinessRoutine(MemberHappinessRoutine happinessRoutine) {
+ this.happinessRoutine = happinessRoutine;
+ }
+
+ public void updateRefreshToken(String refreshToken) {
+ this.refreshToken = refreshToken;
+ }
+
+ public void resetRefreshToken() {
+ this.refreshToken = null;
+ }
+
+ public void addDailyCotton() {
+ this.cottonInfo.addDailyCotton();
+ }
+
+ public void addHappinessCotton() {
+ this.cottonInfo.addHappinessCotton();
+ }
+
+ public int subtractAndGetCotton(CottonType type) {
+ if (Objects.isNull(this.memberDoll)) {
+ throw new MemberDollException(NOT_EXIST_MEMBER_DOLL);
+ }
+ return cottonInfo.subtractAndGetCotton(type, this.memberDoll);
+ }
+
+ public void checkMemberDollExist() {
+ if (!isMemberDollExist()) {
+ throw new MemberException(NOT_EXIST_DOLL);
+ }
+ }
+
+ public void checkMemberDollNonExist() {
+ if (isMemberDollExist()) {
+ throw new MemberException(EXIST_PROFILE);
+ }
+ }
+
+ public boolean isMemberDollExist() {
+ return Objects.nonNull(this.getMemberDoll());
+ }
+
+ public void checkDailyRoutineAddition() {
+ if (this.getDailyRoutines().size() >= DAILY_ROUTINE_MAX_COUNT) {
+ throw new RoutineException(CANNOT_ADD_MEMBER_ROUTINE);
+ }
+ }
+
+ public void checkHappinessRoutineAddition() {
+ if (Objects.nonNull(this.getHappinessRoutine())) {
+ throw new RoutineException(CANNOT_ADD_MEMBER_ROUTINE);
+ }
+ }
+
+ public void checkDailyRoutineForMember(MemberDailyRoutine routine) {
+ if (!isDailyRoutineForMember(routine)) {
+ throw new MemberException(INACCESSIBLE_ROUTINE);
+ }
+ }
+
+ public boolean isDailyRoutineForMember(MemberDailyRoutine routine) {
+ return this.getDailyRoutines().contains(routine);
+ }
+
+ public void checkHappinessRoutineForMember(MemberHappinessRoutine routine) {
+ if (!this.getHappinessRoutine().equals(routine)) {
+ throw new MemberException(INACCESSIBLE_ROUTINE);
+ }
+ }
+
+ public boolean isNotExistRoutine(DailyRoutine routine) {
+ return this.getDailyRoutines().stream()
+ .noneMatch(memberRoutine -> memberRoutine.getRoutine().equals(routine));
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/entity/SocialType.java b/src/main/java/com/soptie/server/member/entity/SocialType.java
new file mode 100644
index 00000000..b1900063
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/entity/SocialType.java
@@ -0,0 +1,5 @@
+package com.soptie.server.member.entity;
+
+public enum SocialType {
+ KAKAO, APPLE
+}
diff --git a/src/main/java/com/soptie/server/member/exception/MemberException.java b/src/main/java/com/soptie/server/member/exception/MemberException.java
new file mode 100644
index 00000000..4d10ee9d
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/exception/MemberException.java
@@ -0,0 +1,16 @@
+package com.soptie.server.member.exception;
+
+import com.soptie.server.member.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class MemberException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public MemberException(ErrorCode errorCode) {
+ super("[MemberException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/soptie/server/member/message/ErrorCode.java b/src/main/java/com/soptie/server/member/message/ErrorCode.java
new file mode 100644
index 00000000..90c06ccd
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/message/ErrorCode.java
@@ -0,0 +1,28 @@
+package com.soptie.server.member.message;
+
+import static org.springframework.http.HttpStatus.*;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum ErrorCode {
+
+ /* 400 BAD_REQUEST : 잘못된 요청 */
+ INACCESSIBLE_ROUTINE(BAD_REQUEST, "회원의 루틴이 아닙니다."),
+ NOT_ENOUGH_COTTON(BAD_REQUEST, "솜뭉치가 부족합니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_MEMBER(NOT_FOUND, "유효하지 않은 회원입니다."),
+ NOT_EXIST_DOLL(NOT_FOUND, "인형을 가지고 있지 않은 회원입니다."),
+
+ /* 409 CONFLICT : 중복된 데이터 존재 */
+ EXIST_PROFILE(CONFLICT, "프로필이 이미 존재합니다."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/member/message/SuccessMessage.java b/src/main/java/com/soptie/server/member/message/SuccessMessage.java
new file mode 100644
index 00000000..9b51e8b3
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/message/SuccessMessage.java
@@ -0,0 +1,16 @@
+package com.soptie.server.member.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+
+ SUCCESS_CREATE_PROFILE("프로필 생성 성공"),
+ SUCCESS_GIVE_COTTON("솜뭉치 주기 성공"),
+ SUCCESS_HOME_INFO("홈 화면 불러오기 성공")
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/member/repository/MemberRepository.java b/src/main/java/com/soptie/server/member/repository/MemberRepository.java
new file mode 100644
index 00000000..1ec5716c
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/repository/MemberRepository.java
@@ -0,0 +1,12 @@
+package com.soptie.server.member.repository;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.entity.SocialType;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface MemberRepository extends JpaRepository {
+ Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId);
+ Optional findByRefreshToken(String refreshToken);
+}
diff --git a/src/main/java/com/soptie/server/member/service/MemberService.java b/src/main/java/com/soptie/server/member/service/MemberService.java
new file mode 100644
index 00000000..9358d6db
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/service/MemberService.java
@@ -0,0 +1,15 @@
+package com.soptie.server.member.service;
+
+import com.soptie.server.member.dto.CottonCountResponse;
+import com.soptie.server.member.dto.MemberHomeInfoResponse;
+import com.soptie.server.member.dto.MemberProfileRequest;
+import com.soptie.server.member.entity.CottonType;
+import com.soptie.server.member.entity.Member;
+
+public interface MemberService {
+
+ void createMemberProfile(long memberId, MemberProfileRequest request);
+ CottonCountResponse giveCotton(long memberId, CottonType cottonType);
+ MemberHomeInfoResponse getMemberHomeInfo(long memberId);
+ void deleteMember(Member member);
+}
diff --git a/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java b/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java
new file mode 100644
index 00000000..f3150aae
--- /dev/null
+++ b/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java
@@ -0,0 +1,73 @@
+package com.soptie.server.member.service;
+
+import com.soptie.server.conversation.entity.Conversation;
+import com.soptie.server.conversation.repository.ConversationRepository;
+import com.soptie.server.member.dto.CottonCountResponse;
+import com.soptie.server.member.dto.MemberHomeInfoResponse;
+import com.soptie.server.member.dto.MemberProfileRequest;
+import com.soptie.server.member.entity.CottonType;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.memberDoll.service.MemberDollService;
+import com.soptie.server.memberRoutine.service.MemberDailyRoutineService;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+import static com.soptie.server.member.message.ErrorCode.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberServiceImpl implements MemberService {
+
+ private final MemberDollService memberDollService;
+ private final MemberDailyRoutineService memberDailyRoutineService;
+ private final MemberRepository memberRepository;
+ private final ConversationRepository conversationRepository;
+
+ @Override
+ @Transactional
+ public void createMemberProfile(long memberId, MemberProfileRequest request) {
+ val member = findMember(memberId);
+ member.checkMemberDollNonExist();
+ memberDailyRoutineService.createMemberDailyRoutines(member, request.routines());
+ memberDollService.createMemberDoll(member, request.dollType(), request.name());
+ }
+
+ @Override
+ @Transactional
+ public CottonCountResponse giveCotton(long memberId, CottonType cottonType) {
+ val member = findMember(memberId);
+ val cottonCount = member.subtractAndGetCotton(cottonType);
+ return CottonCountResponse.of(cottonCount);
+ }
+
+ @Override
+ public MemberHomeInfoResponse getMemberHomeInfo(long memberId) {
+ val member = findMember(memberId);
+ member.checkMemberDollExist();
+ val conversations = getConversations();
+ return MemberHomeInfoResponse.of(member, conversations);
+ }
+
+ @Override
+ public void deleteMember(Member member) {
+ memberRepository.delete(member);
+ }
+
+ private Member findMember(long id) {
+ return memberRepository.findById(id)
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ private List getConversations() {
+ return conversationRepository.findAll().stream()
+ .map(Conversation::getContent)
+ .toList();
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java b/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java
new file mode 100644
index 00000000..bb4f6c9f
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java
@@ -0,0 +1,66 @@
+package com.soptie.server.memberDoll.entity;
+
+import com.soptie.server.common.entity.BaseTime;
+import com.soptie.server.doll.entity.Doll;
+import com.soptie.server.member.entity.Member;
+
+import com.soptie.server.memberDoll.exception.MemberDollException;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToOne;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import static com.soptie.server.common.config.ValueConfig.*;
+import static com.soptie.server.doll.message.ErrorCode.*;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class MemberDoll extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_doll_id")
+ private Long id;
+
+ private String name;
+
+ private int happinessCottonCount;
+
+ @OneToOne(mappedBy = "memberDoll")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "doll_id")
+ private Doll doll;
+
+ public MemberDoll(Member member, Doll doll, String name) {
+ this.happinessCottonCount = 0;
+ setMember(member);
+ this.doll = doll;
+ setName(name);
+ }
+
+ private void setMember(Member member) {
+ this.member = member;
+ member.setMemberDoll(this);
+ }
+
+ private void setName(String name) {
+ if (!name.matches(MEMBER_DOLL_CONDITION)) {
+ throw new MemberDollException(INVALID_NAME);
+ }
+ this.name = name;
+ }
+
+ public void addHappinessCottonCount() {
+ this.happinessCottonCount++;
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java b/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java
new file mode 100644
index 00000000..a24c6aff
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java
@@ -0,0 +1,15 @@
+package com.soptie.server.memberDoll.exception;
+
+import com.soptie.server.doll.message.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class MemberDollException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public MemberDollException(ErrorCode errorCode) {
+ super("[MemberDollException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java b/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java
new file mode 100644
index 00000000..516f29d9
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java
@@ -0,0 +1,7 @@
+package com.soptie.server.memberDoll.repository;
+
+import com.soptie.server.memberDoll.entity.MemberDoll;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MemberDollRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java b/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java
new file mode 100644
index 00000000..ab7e46ea
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java
@@ -0,0 +1,11 @@
+package com.soptie.server.memberDoll.service;
+
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberDoll.entity.MemberDoll;
+
+public interface MemberDollService {
+
+ void createMemberDoll(Member member, DollType dollType, String name);
+ void deleteMemberDoll(MemberDoll memberDoll);
+}
diff --git a/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java b/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java
new file mode 100644
index 00000000..7e010c1d
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java
@@ -0,0 +1,43 @@
+package com.soptie.server.memberDoll.service;
+
+import com.soptie.server.doll.entity.Doll;
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.doll.exception.DollException;
+import com.soptie.server.doll.repository.DollRepository;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberDoll.entity.MemberDoll;
+import com.soptie.server.memberDoll.repository.MemberDollRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.soptie.server.doll.message.ErrorCode.INVALID_TYPE;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberDollServiceImpl implements MemberDollService {
+
+ private final MemberDollRepository memberDollRepository;
+ private final DollRepository dollRepository;
+
+ @Override
+ @Transactional
+ public void createMemberDoll(Member member, DollType dollType, String name) {
+ val doll = findDoll(dollType);
+ val memberDoll = new MemberDoll(member, doll, name);
+ memberDollRepository.save(memberDoll);
+ }
+
+ @Override
+ @Transactional
+ public void deleteMemberDoll(MemberDoll memberDoll) {
+ memberDollRepository.delete(memberDoll);
+ }
+
+ private Doll findDoll(DollType type) {
+ return dollRepository.findByDollType(type)
+ .orElseThrow(() -> new DollException(INVALID_TYPE));
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java b/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java
new file mode 100644
index 00000000..35521172
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java
@@ -0,0 +1,74 @@
+package com.soptie.server.memberRoutine.controller;
+
+import static com.soptie.server.common.dto.Response.*;
+import static com.soptie.server.memberRoutine.message.SuccessMessage.*;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.memberRoutine.service.MemberDailyRoutineService;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest;
+
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/routines/daily/member")
+public class MemberDailyRoutineController {
+
+ private final MemberDailyRoutineService memberDailyRoutineService;
+
+ @PostMapping
+ public ResponseEntity createMemberDailyRoutine(
+ Principal principal, @RequestBody MemberDailyRoutineRequest request) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberDailyRoutineService.createMemberDailyRoutine(memberId, request);
+ return ResponseEntity
+ .created(getURI())
+ .body(success(SUCCESS_CREATE_ROUTINE.getMessage(), response));
+ }
+
+ private URI getURI() {
+ return ServletUriComponentsBuilder
+ .fromCurrentRequest()
+ .path("/")
+ .buildAndExpand()
+ .toUri();
+ }
+
+ @DeleteMapping
+ public ResponseEntity deleteMemberDailyRoutines(Principal principal, @RequestParam List routines) {
+ val memberId = Long.parseLong(principal.getName());
+ memberDailyRoutineService.deleteMemberDailyRoutines(memberId, routines);
+ return ResponseEntity.ok(success(SUCCESS_DELETE_ROUTINE.getMessage()));
+ }
+
+ @PatchMapping("/routine/{routineId}")
+ public ResponseEntity achieveMemberDailyRoutine(Principal principal, @PathVariable Long routineId) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberDailyRoutineService.achieveMemberDailyRoutine(memberId, routineId);
+ return ResponseEntity.ok(success(SUCCESS_ACHIEVE_ROUTINE.getMessage(), response));
+ }
+
+ @GetMapping
+ public ResponseEntity getMemberDailyRoutines(Principal principal) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberDailyRoutineService.getMemberDailyRoutines(memberId);
+ return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java b/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java
new file mode 100644
index 00000000..1fbaad89
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java
@@ -0,0 +1,68 @@
+package com.soptie.server.memberRoutine.controller;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest;
+import com.soptie.server.memberRoutine.service.MemberHappinessRoutineService;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import java.net.URI;
+import java.security.Principal;
+
+import static com.soptie.server.common.dto.Response.success;
+import static com.soptie.server.memberRoutine.message.SuccessMessage.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/routines/happiness/member")
+public class MemberHappinessRoutineController {
+
+ private final MemberHappinessRoutineService memberHappinessRoutineService;
+
+ @PostMapping
+ public ResponseEntity createMemberHappinessRoutine(
+ Principal principal, @RequestBody MemberHappinessRoutineRequest request) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberHappinessRoutineService.createMemberHappinessRoutine(memberId, request);
+ return ResponseEntity
+ .created(getURI())
+ .body(success(SUCCESS_CREATE_ROUTINE.getMessage(), response));
+ }
+
+ private URI getURI() {
+ return ServletUriComponentsBuilder
+ .fromCurrentRequest()
+ .path("/")
+ .buildAndExpand()
+ .toUri();
+ }
+
+ @GetMapping
+ public ResponseEntity getMemberHappinessRoutine(@NonNull Principal principal) {
+ val memberId = Long.parseLong(principal.getName());
+ val response = memberHappinessRoutineService.getMemberHappinessRoutine(memberId);
+ return response
+ .map(result -> ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), result)))
+ .orElseGet(() -> ResponseEntity.noContent().build());
+ }
+
+ @DeleteMapping("/routine/{routineId}")
+ public ResponseEntity deleteMemberHappinessRoutine(Principal principal, @PathVariable Long routineId) {
+ val memberId = Long.parseLong(principal.getName());
+ memberHappinessRoutineService.deleteMemberHappinessRoutine(memberId, routineId);
+ return ResponseEntity.ok(success(SUCCESS_DELETE_ROUTINE.getMessage()));
+ }
+
+ @PatchMapping("/routine/{routineId}")
+ public ResponseEntity achieveMemberHappinessRoutine(Principal principal, @PathVariable Long routineId){
+ val memberId = Long.parseLong(principal.getName());
+ memberHappinessRoutineService.achieveMemberHappinessRoutine(memberId, routineId);
+ return ResponseEntity.ok(success(SUCCESS_ACHIEVE_ROUTINE.getMessage()));
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java
new file mode 100644
index 00000000..8b0d0756
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java
@@ -0,0 +1,14 @@
+package com.soptie.server.memberRoutine.dto;
+
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+public record AchievedMemberDailyRoutineResponse(
+ long routineId,
+ boolean isAchieve,
+ int achieveCount
+) {
+
+ public static AchievedMemberDailyRoutineResponse of(MemberDailyRoutine routine) {
+ return new AchievedMemberDailyRoutineResponse(routine.getId(), routine.isAchieve(), routine.getAchieveCount());
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java
new file mode 100644
index 00000000..aacd6c59
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java
@@ -0,0 +1,6 @@
+package com.soptie.server.memberRoutine.dto;
+
+public record MemberDailyRoutineRequest(
+ long routineId
+) {
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java
new file mode 100644
index 00000000..49cd282f
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java
@@ -0,0 +1,12 @@
+package com.soptie.server.memberRoutine.dto;
+
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+public record MemberDailyRoutineResponse(
+ long routineId
+) {
+
+ public static MemberDailyRoutineResponse of(MemberDailyRoutine routine) {
+ return new MemberDailyRoutineResponse(routine.getId());
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java
new file mode 100644
index 00000000..eb55cea1
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java
@@ -0,0 +1,37 @@
+package com.soptie.server.memberRoutine.dto;
+
+import java.util.List;
+
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+public record MemberDailyRoutinesResponse(
+ @NonNull List routines
+) {
+
+ public static MemberDailyRoutinesResponse of(List routines) {
+ return new MemberDailyRoutinesResponse(routines.stream().map(MemberDailyRoutineResponse::of).toList());
+ }
+
+ @Builder
+ public record MemberDailyRoutineResponse(
+ long routineId,
+ @NonNull String content,
+ @NonNull String iconImageUrl,
+ int achieveCount,
+ boolean isAchieve
+ ) {
+
+ private static MemberDailyRoutineResponse of(MemberDailyRoutine routine) {
+ return MemberDailyRoutineResponse.builder()
+ .routineId(routine.getId())
+ .content(routine.getRoutine().getContent())
+ .iconImageUrl(routine.getRoutine().getTheme().getImageInfo().getIconImageUrl())
+ .achieveCount(routine.getAchieveCount())
+ .isAchieve(routine.isAchieve())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java
new file mode 100644
index 00000000..292d3081
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java
@@ -0,0 +1,6 @@
+package com.soptie.server.memberRoutine.dto;
+
+public record MemberHappinessRoutineRequest(
+ long subRoutineId
+) {
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java
new file mode 100644
index 00000000..19902d53
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java
@@ -0,0 +1,11 @@
+package com.soptie.server.memberRoutine.dto;
+
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+
+public record MemberHappinessRoutineResponse(
+ long routineId
+) {
+ public static MemberHappinessRoutineResponse of(MemberHappinessRoutine routine) {
+ return new MemberHappinessRoutineResponse(routine.getId());
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java
new file mode 100644
index 00000000..b4bfa3cf
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java
@@ -0,0 +1,36 @@
+package com.soptie.server.memberRoutine.dto;
+
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record MemberHappinessRoutinesResponse(
+ long routineId,
+ @NonNull String iconImageUrl,
+ @NonNull String contentImageUrl,
+ @NonNull String themeName,
+ @NonNull String themeNameColor,
+ @NonNull String title,
+ @NonNull String content,
+ @NonNull String detailContent,
+ @NonNull String place,
+ @NonNull String timeTaken
+) {
+
+ public static MemberHappinessRoutinesResponse of(MemberHappinessRoutine routine) {
+ return MemberHappinessRoutinesResponse.builder()
+ .routineId(routine.getId())
+ .iconImageUrl(routine.getRoutine().getRoutine().getTheme().getImageInfo().getIconImageUrl())
+ .contentImageUrl(routine.getRoutine().getRoutine().getTheme().getImageInfo().getContentImageUrl())
+ .themeName(routine.getRoutine().getRoutine().getTheme().getName())
+ .themeNameColor(routine.getRoutine().getRoutine().getTheme().getNameColor())
+ .title(routine.getRoutine().getRoutine().getTitle())
+ .content(routine.getRoutine().getContent())
+ .detailContent(routine.getRoutine().getDetailContent())
+ .place(routine.getRoutine().getPlace())
+ .timeTaken(routine.getRoutine().getTimeTaken())
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java
new file mode 100644
index 00000000..6abce638
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java
@@ -0,0 +1,60 @@
+package com.soptie.server.memberRoutine.entity.daily;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+@EntityListeners(AuditingEntityListener.class)
+public class CompletedMemberDailyRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_routine_id")
+ private Long id;
+
+ private int achieveCount;
+
+ private Boolean isAchieve;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "routine_id")
+ private DailyRoutine routine;
+
+ @CreatedDate
+ protected LocalDateTime createdAt;
+
+ public CompletedMemberDailyRoutine(MemberDailyRoutine routine) {
+ this.achieveCount = routine.getAchieveCount();
+ this.isAchieve = routine.isAchieve();
+ setMember(routine);
+ this.routine = routine.getRoutine();
+ }
+
+ private void setMember(MemberDailyRoutine routine) {
+ routine.getMember().getDailyRoutines().remove(routine);
+ this.member = routine.getMember();
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java
new file mode 100644
index 00000000..c5dc6079
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java
@@ -0,0 +1,79 @@
+package com.soptie.server.memberRoutine.entity.daily;
+
+import java.util.Objects;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class MemberDailyRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_routine_id")
+ private Long id;
+
+ private int achieveCount;
+
+ private boolean isAchieve;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "routine_id")
+ private DailyRoutine routine;
+
+ public MemberDailyRoutine(Member member, DailyRoutine routine) {
+ this.achieveCount = 0;
+ this.isAchieve = false;
+ setMember(member);
+ this.routine = routine;
+ }
+
+ public MemberDailyRoutine(Member member, DailyRoutine routine, int achieveCount) {
+ this.achieveCount = achieveCount;
+ this.isAchieve = false;
+ setMember(member);
+ this.routine = routine;
+ }
+
+ public MemberDailyRoutine(Member member, DailyRoutine routine, int achieveCount, boolean isAchieve) {
+ this.achieveCount = achieveCount;
+ this.isAchieve = isAchieve;
+ setMember(member);
+ this.routine = routine;
+ }
+
+ private void setMember(Member member) {
+ if (Objects.nonNull(this.member)) {
+ this.member.getDailyRoutines().remove(this);
+ }
+ this.member = member;
+ member.getDailyRoutines().add(this);
+ }
+
+ public void achieveRoutine() {
+ this.isAchieve = true;
+ this.achieveCount++;
+ this.member.addDailyCotton();
+ }
+
+ public void initAchievement() {
+ this.isAchieve = false;
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java
new file mode 100644
index 00000000..69321aca
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java
@@ -0,0 +1,49 @@
+package com.soptie.server.memberRoutine.entity.happiness;
+
+import java.util.Objects;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToOne;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class MemberHappinessRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_routine_id")
+ private Long id;
+
+ @OneToOne(mappedBy = "happinessRoutine")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "routine_id")
+ private HappinessSubRoutine routine;
+
+ public MemberHappinessRoutine(Member member, HappinessSubRoutine routine) {
+ setMember(member);
+ this.routine = routine;
+ }
+
+ private void setMember(Member member) {
+ if (Objects.nonNull(this.member)) {
+ this.member.resetHappinessRoutine();
+ }
+ this.member = member;
+ member.addHappinessRoutine(this);
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java b/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java
new file mode 100644
index 00000000..c9f939b7
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java
@@ -0,0 +1,16 @@
+package com.soptie.server.memberRoutine.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+ SUCCESS_CREATE_ROUTINE("루틴 추가 성공"),
+ SUCCESS_DELETE_ROUTINE("루틴 삭제 성공"),
+ SUCCESS_ACHIEVE_ROUTINE("루틴 달성 성공"),
+ SUCCESS_GET_ROUTINE("루틴 조회 성공"),
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java
new file mode 100644
index 00000000..1931bfbb
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java
@@ -0,0 +1,14 @@
+package com.soptie.server.memberRoutine.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.entity.daily.CompletedMemberDailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+public interface CompletedMemberDailyRoutineRepository extends JpaRepository {
+ Optional findByMemberAndRoutine(Member member, DailyRoutine routine);
+ void deleteAllByMember(Member member);
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java
new file mode 100644
index 00000000..6f7b9d4d
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java
@@ -0,0 +1,11 @@
+package com.soptie.server.memberRoutine.repository;
+
+import java.util.List;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+public interface MemberDailyRoutineCustomRepository {
+ List findAllByMember(Member member);
+ List findAllByAchieved();
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java
new file mode 100644
index 00000000..bc9f1b4b
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java
@@ -0,0 +1,10 @@
+package com.soptie.server.memberRoutine.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+public interface MemberDailyRoutineRepository extends JpaRepository, MemberDailyRoutineCustomRepository {
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java
new file mode 100644
index 00000000..489f6360
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java
@@ -0,0 +1,48 @@
+package com.soptie.server.memberRoutine.repository;
+
+import static com.soptie.server.memberRoutine.entity.daily.QMemberDailyRoutine.*;
+import static com.soptie.server.routine.entity.daily.QDailyRoutine.*;
+import static com.soptie.server.routine.entity.daily.QDailyTheme.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+@Repository
+@RequiredArgsConstructor
+public class MemberDailyRoutineRepositoryImpl implements MemberDailyRoutineCustomRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findAllByMember(Member member) {
+ val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", memberDailyRoutine.routine.content);
+ return queryFactory
+ .selectFrom(memberDailyRoutine)
+ .where(memberDailyRoutine.member.eq(member))
+ .leftJoin(memberDailyRoutine.routine, dailyRoutine).fetchJoin()
+ .leftJoin(dailyRoutine.theme, dailyTheme).fetchJoin()
+ .orderBy(
+ memberDailyRoutine.isAchieve.asc(),
+ memberDailyRoutine.achieveCount.desc(),
+ contentInKRExpression.asc()
+ )
+ .fetch();
+ }
+
+ @Override
+ public List findAllByAchieved() {
+ return queryFactory
+ .selectFrom(memberDailyRoutine)
+ .where(memberDailyRoutine.isAchieve.isTrue())
+ .fetch();
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java
new file mode 100644
index 00000000..eca3a8df
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java
@@ -0,0 +1,7 @@
+package com.soptie.server.memberRoutine.repository;
+
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MemberHappinessRoutineRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java b/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java
new file mode 100644
index 00000000..eb552071
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java
@@ -0,0 +1,22 @@
+package com.soptie.server.memberRoutine.scheduler;
+
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import com.soptie.server.memberRoutine.service.MemberDailyRoutineService;
+
+import lombok.RequiredArgsConstructor;
+
+@Component
+@EnableScheduling
+@RequiredArgsConstructor
+public class MemberDailyRoutineScheduler {
+
+ private final MemberDailyRoutineService memberDailyRoutineService;
+
+ @Scheduled(cron = "${softie.cron.init.routine}")
+ public void initMemberDailyRoutines() {
+ memberDailyRoutineService.initMemberDailyRoutines();
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java
new file mode 100644
index 00000000..47908152
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java
@@ -0,0 +1,8 @@
+package com.soptie.server.memberRoutine.service;
+
+import com.soptie.server.member.entity.Member;
+
+public interface CompletedMemberDailyRoutineService {
+
+ void deleteCompletedMemberDailyRoutines(Member member);
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java
new file mode 100644
index 00000000..e1a46aaa
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java
@@ -0,0 +1,21 @@
+package com.soptie.server.memberRoutine.service;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.repository.CompletedMemberDailyRoutineRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class CompletedMemberDailyRoutineServiceImpl implements CompletedMemberDailyRoutineService {
+
+ private final CompletedMemberDailyRoutineRepository completedMemberDailyRoutineRepository;
+
+ @Override
+ @Transactional
+ public void deleteCompletedMemberDailyRoutines(Member member) {
+ completedMemberDailyRoutineRepository.deleteAllByMember(member);
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java
new file mode 100644
index 00000000..48ed47e6
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java
@@ -0,0 +1,21 @@
+package com.soptie.server.memberRoutine.service;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+
+import java.util.List;
+
+public interface MemberDailyRoutineService {
+
+ MemberDailyRoutineResponse createMemberDailyRoutine(long memberId, MemberDailyRoutineRequest request);
+ void createMemberDailyRoutines(Member member, List routines);
+ void deleteMemberDailyRoutines(long memberId, List routineIds);
+ AchievedMemberDailyRoutineResponse achieveMemberDailyRoutine(long memberId, Long routineId);
+ MemberDailyRoutinesResponse getMemberDailyRoutines(long memberId);
+ void initMemberDailyRoutines();
+ void deleteMemberDailyRoutine(MemberDailyRoutine routine);
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java
new file mode 100644
index 00000000..4ddb10ab
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java
@@ -0,0 +1,163 @@
+package com.soptie.server.memberRoutine.service;
+
+import static com.soptie.server.member.message.ErrorCode.*;
+import static com.soptie.server.routine.message.ErrorCode.*;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse;
+import com.soptie.server.memberRoutine.entity.daily.CompletedMemberDailyRoutine;
+import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine;
+import com.soptie.server.memberRoutine.repository.CompletedMemberDailyRoutineRepository;
+import com.soptie.server.memberRoutine.repository.MemberDailyRoutineRepository;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.exception.RoutineException;
+import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository;
+
+import lombok.*;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberDailyRoutineServiceImpl implements MemberDailyRoutineService {
+
+ private final MemberDailyRoutineRepository memberDailyRoutineRepository;
+ private final MemberRepository memberRepository;
+ private final DailyRoutineRepository dailyRoutineRepository;
+ private final CompletedMemberDailyRoutineRepository completedMemberDailyRoutineRepository;
+
+ @Override
+ @Transactional
+ public MemberDailyRoutineResponse createMemberDailyRoutine(long memberId, MemberDailyRoutineRequest request) {
+ val member = findMember(memberId);
+ member.checkDailyRoutineAddition();
+ val routine = findRoutine(request.routineId());
+ val savedMemberRoutine = getMemberDailyRoutine(member, routine);
+ return MemberDailyRoutineResponse.of(savedMemberRoutine);
+ }
+
+ private MemberDailyRoutine getMemberDailyRoutine(Member member, DailyRoutine routine) {
+ checkDuplicatedMemberRoutine(member, routine);
+ return completedMemberDailyRoutineRepository.findByMemberAndRoutine(member, routine)
+ .map(completedRoutine -> recreateOldRoutines(member, routine, completedRoutine))
+ .orElseGet(() -> createNewRoutine(member, routine));
+ }
+
+ private void checkDuplicatedMemberRoutine(Member member, DailyRoutine routine) {
+ if (!member.isNotExistRoutine(routine)) {
+ throw new RoutineException(DUPLICATED_ROUTINE);
+ }
+ }
+
+ private MemberDailyRoutine createNewRoutine(Member member, DailyRoutine routine) {
+ return memberDailyRoutineRepository.save(new MemberDailyRoutine(member, routine));
+ }
+
+ private MemberDailyRoutine recreateOldRoutines(
+ Member member, DailyRoutine routine, CompletedMemberDailyRoutine completedRoutine) {
+ val isTodayAchieved = isTodayAchieved(completedRoutine);
+ completedMemberDailyRoutineRepository.delete(completedRoutine);
+ val memberRoutine = new MemberDailyRoutine(member, routine, completedRoutine.getAchieveCount(), isTodayAchieved);
+ return memberDailyRoutineRepository.save(memberRoutine);
+ }
+
+ private boolean isTodayAchieved(CompletedMemberDailyRoutine completedRoutine) {
+ return completedRoutine.getIsAchieve() && isTodayCompleted(completedRoutine);
+ }
+
+ private boolean isTodayCompleted(CompletedMemberDailyRoutine completedRoutine) {
+ val now = LocalDate.now();
+ return completedRoutine.getCreatedAt().toLocalDate().equals(now);
+ }
+
+ @Override
+ @Transactional
+ public void createMemberDailyRoutines(Member member, List routines) {
+ routines.forEach(routineId -> memberDailyRoutineRepository
+ .save(new MemberDailyRoutine(member, findRoutine(routineId))));
+ }
+
+ private DailyRoutine findRoutine(long id) {
+ return dailyRoutineRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_ROUTINE));
+ }
+
+ @Override
+ @Transactional
+ public void deleteMemberDailyRoutines(long memberId, List routineIds) {
+ val member = findMember(memberId);
+ val routines = getMemberRoutines(member, routineIds);
+ deleteMemberRoutines(routines);
+ }
+
+ private List getMemberRoutines(Member member, List routineIds) {
+ return routineIds.stream()
+ .map(this::findMemberRoutine)
+ .filter(member::isDailyRoutineForMember)
+ .toList();
+ }
+
+ private void deleteMemberRoutines(List routines) {
+ routines.forEach(this::deleteMemberRoutine);
+ }
+
+ private void deleteMemberRoutine(MemberDailyRoutine routine) {
+ moveCompletedRoutine(routine);
+ memberDailyRoutineRepository.delete(routine);
+ }
+
+ private void moveCompletedRoutine(MemberDailyRoutine routine) {
+ val completedRoutine = new CompletedMemberDailyRoutine(routine);
+ completedMemberDailyRoutineRepository.save(completedRoutine);
+ }
+
+ @Override
+ @Transactional
+ public AchievedMemberDailyRoutineResponse achieveMemberDailyRoutine(long memberId, Long routineId) {
+ val member = findMember(memberId);
+ val routine = findMemberRoutine(routineId);
+ member.checkDailyRoutineForMember(routine);
+ routine.achieveRoutine();
+ return AchievedMemberDailyRoutineResponse.of(routine);
+ }
+
+ private MemberDailyRoutine findMemberRoutine(long id) {
+ return memberDailyRoutineRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_ROUTINE));
+ }
+
+ @Override
+ public MemberDailyRoutinesResponse getMemberDailyRoutines(long memberId) {
+ val member = findMember(memberId);
+ val routines = memberDailyRoutineRepository.findAllByMember(member);
+ return MemberDailyRoutinesResponse.of(routines);
+ }
+
+ private Member findMember(long id) {
+ return memberRepository.findById(id)
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ @Override
+ @Transactional
+ public void initMemberDailyRoutines() {
+ val routines = memberDailyRoutineRepository.findAllByAchieved();
+ routines.forEach(MemberDailyRoutine::initAchievement);
+ }
+
+ @Override
+ @Transactional
+ public void deleteMemberDailyRoutine(MemberDailyRoutine routine) {
+ memberDailyRoutineRepository.delete(routine);
+ }
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java
new file mode 100644
index 00000000..aa11fea7
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java
@@ -0,0 +1,15 @@
+package com.soptie.server.memberRoutine.service;
+
+import java.util.Optional;
+
+import com.soptie.server.memberRoutine.dto.*;
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+
+public interface MemberHappinessRoutineService {
+
+ MemberHappinessRoutineResponse createMemberHappinessRoutine(long memberId, MemberHappinessRoutineRequest request);
+ Optional getMemberHappinessRoutine(long memberId);
+ void deleteMemberHappinessRoutine(long memberId, long routineId);
+ void achieveMemberHappinessRoutine(long memberId, long routineId);
+ void deleteMemberHappinessRoutine(MemberHappinessRoutine routine);
+}
diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java
new file mode 100644
index 00000000..ebafb154
--- /dev/null
+++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java
@@ -0,0 +1,100 @@
+package com.soptie.server.memberRoutine.service;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse;
+import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine;
+import com.soptie.server.memberRoutine.repository.MemberHappinessRoutineRepository;
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+import com.soptie.server.routine.exception.RoutineException;
+import com.soptie.server.routine.repository.happiness.routine.HappinessSubRoutineRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import static com.soptie.server.member.message.ErrorCode.*;
+import static com.soptie.server.routine.message.ErrorCode.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberHappinessRoutineServiceImpl implements MemberHappinessRoutineService {
+
+ private final MemberHappinessRoutineRepository memberHappinessRoutineRepository;
+ private final HappinessSubRoutineRepository happinessSubRoutineRepository;
+ private final MemberRepository memberRepository;
+
+ @Override
+ @Transactional
+ public MemberHappinessRoutineResponse createMemberHappinessRoutine(long memberId, MemberHappinessRoutineRequest request) {
+ val member = findMember(memberId);
+ member.checkHappinessRoutineAddition();
+ val routine = findRoutine(request.subRoutineId());
+ val memberRoutine = new MemberHappinessRoutine(member, routine);
+ val savedMemberRoutine = memberHappinessRoutineRepository.save(memberRoutine);
+ return MemberHappinessRoutineResponse.of(savedMemberRoutine);
+ }
+
+ private HappinessSubRoutine findRoutine(long id) {
+ return happinessSubRoutineRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_ROUTINE));
+ }
+
+ @Override
+ public Optional getMemberHappinessRoutine(long memberId) {
+ val member = findMember(memberId);
+ val memberRoutine = member.getHappinessRoutine();
+ if (Objects.isNull(memberRoutine)) {
+ return Optional.empty();
+ }
+ val response = MemberHappinessRoutinesResponse.of(memberRoutine);
+ return Optional.of(response);
+ }
+
+ @Override
+ @Transactional
+ public void deleteMemberHappinessRoutine(long memberId, long routineId) {
+ val member = findMember(memberId);
+ val memberRoutine = findMemberRoutine(routineId);
+ member.checkHappinessRoutineForMember(memberRoutine);
+ deleteMemberRoutine(memberRoutine);
+ }
+
+ private Member findMember(long id) {
+ return memberRepository.findById(id)
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ private void deleteMemberRoutine(MemberHappinessRoutine routine) {
+ routine.getMember().resetHappinessRoutine();
+ memberHappinessRoutineRepository.delete(routine);
+ }
+
+ @Override
+ @Transactional
+ public void achieveMemberHappinessRoutine(long memberId, long routineId) {
+ val member = findMember(memberId);
+ val memberRoutine = findMemberRoutine(routineId);
+ member.checkHappinessRoutineForMember(memberRoutine);
+ member.addHappinessCotton();
+ deleteMemberRoutine(memberRoutine);
+ }
+
+ private MemberHappinessRoutine findMemberRoutine(long id) {
+ return memberHappinessRoutineRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_ROUTINE));
+ }
+
+ @Override
+ @Transactional
+ public void deleteMemberHappinessRoutine(MemberHappinessRoutine routine) {
+ memberHappinessRoutineRepository.delete(routine);
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java b/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java
new file mode 100644
index 00000000..f5fc7b94
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java
@@ -0,0 +1,46 @@
+package com.soptie.server.routine.controller;
+
+import static com.soptie.server.common.dto.Response.*;
+import static com.soptie.server.routine.message.SuccessMessage.*;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.routine.service.DailyRoutineService;
+
+import lombok.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/routines/daily")
+public class DailyRoutineController {
+
+ private final DailyRoutineService dailyRoutineService;
+
+ @GetMapping("/themes")
+ public ResponseEntity getThemes() {
+ val response = dailyRoutineService.getThemes();
+ return ResponseEntity.ok(success(SUCCESS_GET_THEME.getMessage(), response));
+ }
+
+ @GetMapping
+ public ResponseEntity getRoutinesByThemes(@RequestParam List themes) {
+ val response = dailyRoutineService.getRoutinesByThemes(themes);
+ return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response));
+ }
+
+ @GetMapping("/theme/{themeId}")
+ public ResponseEntity getRoutinesByTheme(Principal principal, @PathVariable long themeId) {
+ val memberId = Long.valueOf(principal.getName());
+ val response = dailyRoutineService.getRoutinesByTheme(memberId, themeId);
+ return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java b/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java
new file mode 100644
index 00000000..9de994d6
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java
@@ -0,0 +1,37 @@
+package com.soptie.server.routine.controller;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.routine.service.HappinessRoutineService;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import static com.soptie.server.common.dto.Response.success;
+import static com.soptie.server.routine.message.SuccessMessage.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/routines/happiness")
+public class HappinessRoutineController {
+
+ private final HappinessRoutineService happinessRoutineService;
+
+ @GetMapping("/themes")
+ public ResponseEntity getHappinessThemes() {
+ val response = happinessRoutineService.getHappinessThemes();
+ return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_THEME.getMessage(), response));
+ }
+
+ @GetMapping
+ public ResponseEntity getHappinessRoutinesByThemes(@RequestParam(required = false) Long themeId) {
+ val response = happinessRoutineService.getHappinessRoutinesByTheme(themeId);
+ return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_ROUTINE.getMessage(), response));
+ }
+
+ @GetMapping("/routine/{routineId}")
+ public ResponseEntity getHappinessSubRoutinesByRoutineOfTheme(@PathVariable long routineId) {
+ val response = happinessRoutineService.getHappinessSubRoutines(routineId);
+ return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_SUB_ROUTINES.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java
new file mode 100644
index 00000000..c3d55a18
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java
@@ -0,0 +1,21 @@
+package com.soptie.server.routine.dto;
+
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder(access = AccessLevel.PROTECTED)
+public record DailyRoutineResponse(
+ Long routineId,
+ @NonNull String content
+) {
+
+ protected static DailyRoutineResponse of(DailyRoutine routine) {
+ return DailyRoutineResponse.builder()
+ .routineId(routine.getId())
+ .content(routine.getContent())
+ .build();
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java
new file mode 100644
index 00000000..2245ac81
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java
@@ -0,0 +1,22 @@
+package com.soptie.server.routine.dto;
+
+import java.util.List;
+
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record DailyRoutinesByThemeResponse(
+ @NonNull String backgroundImageUrl,
+ @NonNull List routines
+) {
+
+ public static DailyRoutinesByThemeResponse of(DailyTheme theme, List routines) {
+ return new DailyRoutinesByThemeResponse(
+ theme.getImageInfo().getBackgroundImageUrl(),
+ routines.stream().map(DailyRoutineResponse::of).toList());
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java
new file mode 100644
index 00000000..595b51f4
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java
@@ -0,0 +1,16 @@
+package com.soptie.server.routine.dto;
+
+import java.util.List;
+
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+import lombok.NonNull;
+
+public record DailyRoutinesByThemesResponse(
+ @NonNull List routines
+) {
+
+ public static DailyRoutinesByThemesResponse of(List routines) {
+ return new DailyRoutinesByThemesResponse(routines.stream().map(DailyRoutineResponse::of).toList());
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java
new file mode 100644
index 00000000..4dae3076
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java
@@ -0,0 +1,34 @@
+package com.soptie.server.routine.dto;
+
+import java.util.List;
+
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+public record DailyThemesResponse(
+ @NonNull List themes
+) {
+
+ public static DailyThemesResponse of(List themes) {
+ return new DailyThemesResponse(themes.stream().map(DailyThemeResponse::of).toList());
+ }
+
+ @Builder
+ public record DailyThemeResponse(
+ long themeId,
+ @NonNull String name,
+ @NonNull String iconImageUrl,
+ @NonNull String backgroundImageUrl
+ ) {
+ private static DailyThemeResponse of(DailyTheme theme) {
+ return DailyThemeResponse.builder()
+ .themeId(theme.getId())
+ .name(theme.getName())
+ .iconImageUrl(theme.getImageInfo().getIconImageUrl())
+ .backgroundImageUrl(theme.getImageInfo().getBackgroundImageUrl())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java
new file mode 100644
index 00000000..db24ea30
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java
@@ -0,0 +1,36 @@
+package com.soptie.server.routine.dto;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import lombok.Builder;
+import lombok.NonNull;
+
+import java.util.List;
+
+public record HappinessRoutinesResponse (
+ @NonNull List routines
+) {
+
+ public static HappinessRoutinesResponse of(List routines) {
+ return new HappinessRoutinesResponse(routines.stream().map(HappinessRoutineResponse::of).toList());
+ }
+
+ @Builder
+ public record HappinessRoutineResponse(
+ long routineId,
+ @NonNull String name,
+ @NonNull String nameColor,
+ @NonNull String title,
+ @NonNull String iconImageUrl
+ ) {
+
+ private static HappinessRoutineResponse of(HappinessRoutine routine) {
+ return HappinessRoutineResponse.builder()
+ .routineId(routine.getId())
+ .name(routine.getTheme().getName())
+ .nameColor(routine.getTheme().getNameColor())
+ .title(routine.getTitle())
+ .iconImageUrl(routine.getTheme().getImageInfo().getIconImageUrl())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java
new file mode 100644
index 00000000..0f0e3c5d
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java
@@ -0,0 +1,50 @@
+package com.soptie.server.routine.dto;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+import lombok.Builder;
+import lombok.NonNull;
+
+import java.util.List;
+
+@Builder
+public record HappinessSubRoutinesResponse(
+ @NonNull String title,
+ @NonNull String name,
+ @NonNull String nameColor,
+ @NonNull String iconImageUrl,
+ @NonNull String contentImageUrl,
+ @NonNull List subRoutines
+) {
+
+ public static HappinessSubRoutinesResponse of(HappinessRoutine routine, List subRoutines) {
+ return HappinessSubRoutinesResponse.builder()
+ .title(routine.getTitle())
+ .name(routine.getTheme().getName())
+ .nameColor(routine.getTheme().getNameColor())
+ .iconImageUrl(routine.getTheme().getImageInfo().getTwinkleIconImageUrl())
+ .contentImageUrl(routine.getTheme().getImageInfo().getContentImageUrl())
+ .subRoutines(subRoutines.stream().map(HappinessSubRoutineResponse::of).toList())
+ .build();
+ }
+
+ @Builder
+ public record HappinessSubRoutineResponse(
+ long subRoutineId,
+ @NonNull String content,
+ @NonNull String detailContent,
+ @NonNull String timeTaken,
+ @NonNull String place
+ ) {
+
+ private static HappinessSubRoutinesResponse.HappinessSubRoutineResponse of(HappinessSubRoutine subRoutine) {
+ return HappinessSubRoutineResponse.builder()
+ .subRoutineId(subRoutine.getId())
+ .content(subRoutine.getContent())
+ .detailContent(subRoutine.getDetailContent())
+ .timeTaken(subRoutine.getTimeTaken())
+ .place(subRoutine.getPlace())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java
new file mode 100644
index 00000000..4c8a0fd3
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java
@@ -0,0 +1,28 @@
+package com.soptie.server.routine.dto;
+
+import com.soptie.server.routine.entity.happiness.HappinessTheme;
+import lombok.Builder;
+import lombok.NonNull;
+
+import java.util.List;
+
+public record HappinessThemesResponse(
+ @NonNull List themes
+) {
+ public static HappinessThemesResponse of(List themes) {
+ return new HappinessThemesResponse(themes.stream().map(HappinessThemeResponse::of).toList());
+ }
+
+ @Builder
+ public record HappinessThemeResponse(
+ long themeId,
+ @NonNull String name
+ ) {
+ private static HappinessThemeResponse of(HappinessTheme theme) {
+ return HappinessThemeResponse.builder()
+ .themeId(theme.getId())
+ .name(theme.getName())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java b/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java
new file mode 100644
index 00000000..0af75b17
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java
@@ -0,0 +1,28 @@
+package com.soptie.server.routine.entity.daily;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class DailyRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "routine_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "theme_id")
+ private DailyTheme theme;
+
+ @Builder
+ public DailyRoutine(String content, DailyTheme theme) {
+ this.content = content;
+ this.theme = theme;
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java b/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java
new file mode 100644
index 00000000..ab6fea5c
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java
@@ -0,0 +1,27 @@
+package com.soptie.server.routine.entity.daily;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class DailyTheme {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "theme_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Embedded
+ private RoutineImage imageInfo;
+
+ @Builder
+ public DailyTheme(String name, RoutineImage imageInfo) {
+ this.name = name;
+ this.imageInfo = imageInfo;
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java b/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java
new file mode 100644
index 00000000..36c7ae36
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java
@@ -0,0 +1,19 @@
+package com.soptie.server.routine.entity.daily;
+
+import jakarta.persistence.Embeddable;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Getter
+public class RoutineImage {
+
+ private String iconImageUrl;
+
+ private String backgroundImageUrl;
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java
new file mode 100644
index 00000000..20e5c6b3
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java
@@ -0,0 +1,23 @@
+package com.soptie.server.routine.entity.happiness;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class HappinessRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "routine_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String title;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "theme_id")
+ private HappinessTheme theme;
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java
new file mode 100644
index 00000000..1744fb7e
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java
@@ -0,0 +1,30 @@
+package com.soptie.server.routine.entity.happiness;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class HappinessSubRoutine {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "sub_routine_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String content;
+
+ @Column(columnDefinition = "TEXT")
+ private String detailContent;
+
+ private String timeTaken;
+
+ private String place;
+
+ @ManyToOne
+ @JoinColumn(name = "routine_id")
+ private HappinessRoutine routine;
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java
new file mode 100644
index 00000000..56b9530c
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java
@@ -0,0 +1,30 @@
+package com.soptie.server.routine.entity.happiness;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+public class HappinessTheme {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "theme_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private String nameColor;
+
+ @Embedded
+ private RoutineImage imageInfo;
+}
diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java b/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java
new file mode 100644
index 00000000..cbb821b2
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java
@@ -0,0 +1,17 @@
+package com.soptie.server.routine.entity.happiness;
+
+import jakarta.persistence.Embeddable;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor
+@Getter
+public class RoutineImage {
+
+ private String iconImageUrl;
+
+ private String contentImageUrl;
+
+ private String twinkleIconImageUrl;
+}
diff --git a/src/main/java/com/soptie/server/routine/exception/RoutineException.java b/src/main/java/com/soptie/server/routine/exception/RoutineException.java
new file mode 100644
index 00000000..09ba5c35
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/exception/RoutineException.java
@@ -0,0 +1,16 @@
+package com.soptie.server.routine.exception;
+
+import com.soptie.server.routine.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class RoutineException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public RoutineException(ErrorCode errorCode) {
+ super("[RoutineException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/message/ErrorCode.java b/src/main/java/com/soptie/server/routine/message/ErrorCode.java
new file mode 100644
index 00000000..be52cb40
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/message/ErrorCode.java
@@ -0,0 +1,27 @@
+package com.soptie.server.routine.message;
+
+import static org.springframework.http.HttpStatus.*;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum ErrorCode {
+
+ /* 400 BAD_REQUEST : 잘못된 요청 */
+ CANNOT_ADD_MEMBER_ROUTINE(BAD_REQUEST, "더 이상 루틴을 추가할 수 없는 회원입니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_THEME(NOT_FOUND, "유효하지 않은 테마입니다."),
+ INVALID_ROUTINE(NOT_FOUND, "유효하지 않은 루틴입니다."),
+
+ /* 409 CONFLICT : 중복된 데이터 존재 */
+ DUPLICATED_ROUTINE(CONFLICT, "이미 추가한 루틴입니다."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/routine/message/SuccessMessage.java b/src/main/java/com/soptie/server/routine/message/SuccessMessage.java
new file mode 100644
index 00000000..1a316a66
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/message/SuccessMessage.java
@@ -0,0 +1,18 @@
+package com.soptie.server.routine.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+
+ SUCCESS_GET_THEME("데일리 루틴 테마 조회 성공"),
+ SUCCESS_GET_ROUTINE("데일리 루틴 조회 성공"),
+ SUCCESS_GET_HAPPINESS_THEME("행복 루틴 테마 조회 성공"),
+ SUCCESS_GET_HAPPINESS_ROUTINE("행복 루틴 조회 성공"),
+ SUCCESS_GET_HAPPINESS_SUB_ROUTINES("행복 루틴 별 서브 루틴 리스트 조회 성공"),
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java
new file mode 100644
index 00000000..4f59938d
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java
@@ -0,0 +1,11 @@
+package com.soptie.server.routine.repository.daily.routine;
+
+import java.util.List;
+
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+public interface DailyRoutineCustomRepository {
+ List findAllByThemes(List themeIds);
+ List findAllByTheme(DailyTheme theme);
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java
new file mode 100644
index 00000000..66f34373
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java
@@ -0,0 +1,8 @@
+package com.soptie.server.routine.repository.daily.routine;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+
+public interface DailyRoutineRepository extends JpaRepository, DailyRoutineCustomRepository {
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java
new file mode 100644
index 00000000..961baa6a
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java
@@ -0,0 +1,42 @@
+package com.soptie.server.routine.repository.daily.routine;
+
+import static com.soptie.server.routine.entity.daily.QDailyRoutine.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+@Repository
+@RequiredArgsConstructor
+public class DailyRoutineRepositoryImpl implements DailyRoutineCustomRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findAllByThemes(List themeIds) {
+ val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyRoutine.content);
+ return queryFactory
+ .selectFrom(dailyRoutine)
+ .where(dailyRoutine.theme.id.in(themeIds))
+ .orderBy(contentInKRExpression.asc())
+ .fetch();
+ }
+
+ @Override
+ public List findAllByTheme(DailyTheme theme) {
+ val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyRoutine.content);
+ return queryFactory
+ .selectFrom(dailyRoutine)
+ .where(dailyRoutine.theme.eq(theme))
+ .orderBy(contentInKRExpression.asc())
+ .fetch();
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java
new file mode 100644
index 00000000..a26b1018
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java
@@ -0,0 +1,9 @@
+package com.soptie.server.routine.repository.daily.theme;
+
+import java.util.List;
+
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+public interface DailyThemeCustomRepository {
+ List findAllOrderByNameAsc();
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java
new file mode 100644
index 00000000..6fde0ace
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java
@@ -0,0 +1,8 @@
+package com.soptie.server.routine.repository.daily.theme;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+public interface DailyThemeRepository extends JpaRepository, DailyThemeCustomRepository {
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java
new file mode 100644
index 00000000..8478446b
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java
@@ -0,0 +1,31 @@
+package com.soptie.server.routine.repository.daily.theme;
+
+import static com.soptie.server.routine.entity.daily.QDailyRoutine.*;
+import static com.soptie.server.routine.entity.daily.QDailyTheme.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+@Repository
+@RequiredArgsConstructor
+public class DailyThemeRepositoryImpl implements DailyThemeCustomRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findAllOrderByNameAsc() {
+ val nameInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyTheme.name);
+ return queryFactory
+ .selectFrom(dailyTheme)
+ .orderBy(nameInKRExpression.asc())
+ .fetch();
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java
new file mode 100644
index 00000000..ed7d7c75
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java
@@ -0,0 +1,9 @@
+package com.soptie.server.routine.repository.happiness.routine;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+
+import java.util.List;
+
+public interface HappinessRoutineCustomRepository {
+ List findAllByThemeId(Long themeId);
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java
new file mode 100644
index 00000000..117b5c2a
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java
@@ -0,0 +1,7 @@
+package com.soptie.server.routine.repository.happiness.routine;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface HappinessRoutineRepository extends JpaRepository, HappinessRoutineCustomRepository {
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java
new file mode 100644
index 00000000..6eaee910
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java
@@ -0,0 +1,38 @@
+package com.soptie.server.routine.repository.happiness.routine;
+
+
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+import static com.soptie.server.routine.entity.happiness.QHappinessRoutine.happinessRoutine;
+import static com.soptie.server.routine.entity.happiness.QHappinessTheme.happinessTheme;
+import static java.util.Objects.*;
+
+@Repository
+@RequiredArgsConstructor
+public class HappinessRoutineRepositoryImpl implements HappinessRoutineCustomRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findAllByThemeId(Long themeId) {
+ val titleInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", happinessRoutine.title);
+ return queryFactory
+ .selectFrom(happinessRoutine)
+ .where(themeIdEq(themeId))
+ .leftJoin(happinessRoutine.theme, happinessTheme).fetchJoin()
+ .orderBy(titleInKRExpression.asc())
+ .fetch();
+ }
+
+ private BooleanExpression themeIdEq(Long themeId) {
+ return (isNull(themeId) || themeId == 0L) ? null : happinessRoutine.theme.id.eq(themeId);
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java
new file mode 100644
index 00000000..6dfbc0c3
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java
@@ -0,0 +1,12 @@
+package com.soptie.server.routine.repository.happiness.routine;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+
+import java.util.List;
+
+public interface HappinessSubRoutineRepository extends JpaRepository {
+ List findAllByRoutine(HappinessRoutine routine);
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java
new file mode 100644
index 00000000..c25ef1dc
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java
@@ -0,0 +1,9 @@
+package com.soptie.server.routine.repository.happiness.theme;
+
+import com.soptie.server.routine.entity.happiness.HappinessTheme;
+
+import java.util.List;
+
+public interface HappinessThemeCustomRepository {
+ List findAllOrderByNameAsc();
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java
new file mode 100644
index 00000000..3d17c1d9
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java
@@ -0,0 +1,8 @@
+package com.soptie.server.routine.repository.happiness.theme;
+
+import com.soptie.server.routine.entity.happiness.HappinessTheme;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface HappinessThemeRepository extends JpaRepository, HappinessThemeCustomRepository {
+}
diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java
new file mode 100644
index 00000000..04055486
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java
@@ -0,0 +1,26 @@
+package com.soptie.server.routine.repository.happiness.theme;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.soptie.server.routine.entity.happiness.HappinessTheme;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+import static com.soptie.server.routine.entity.happiness.QHappinessTheme.happinessTheme;
+
+
+@Repository
+@RequiredArgsConstructor
+public class HappinessThemeRepositoryImpl implements HappinessThemeCustomRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findAllOrderByNameAsc() {
+ return queryFactory
+ .selectFrom(happinessTheme)
+ .orderBy(happinessTheme.name.asc())
+ .fetch();
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java b/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java
new file mode 100644
index 00000000..7402a0ce
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java
@@ -0,0 +1,14 @@
+package com.soptie.server.routine.service;
+
+import java.util.List;
+
+import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse;
+import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse;
+import com.soptie.server.routine.dto.DailyThemesResponse;
+
+public interface DailyRoutineService {
+
+ DailyThemesResponse getThemes();
+ DailyRoutinesByThemesResponse getRoutinesByThemes(List themeIds);
+ DailyRoutinesByThemeResponse getRoutinesByTheme(long memberId, long themeId);
+}
diff --git a/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java
new file mode 100644
index 00000000..1ef489a0
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java
@@ -0,0 +1,69 @@
+package com.soptie.server.routine.service;
+
+import static com.soptie.server.member.message.ErrorCode.*;
+import static com.soptie.server.routine.message.ErrorCode.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.exception.MemberException;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse;
+import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse;
+import com.soptie.server.routine.dto.DailyThemesResponse;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+import com.soptie.server.routine.exception.RoutineException;
+import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository;
+import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository;
+
+import lombok.*;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class DailyRoutineServiceImpl implements DailyRoutineService {
+
+ private final DailyThemeRepository dailyThemeRepository;
+ private final DailyRoutineRepository dailyRoutineRepository;
+ private final MemberRepository memberRepository;
+
+ @Override
+ public DailyThemesResponse getThemes() {
+ val themes = dailyThemeRepository.findAllOrderByNameAsc();
+ return DailyThemesResponse.of(themes);
+ }
+
+ @Override
+ public DailyRoutinesByThemesResponse getRoutinesByThemes(List themeIds) {
+ val routines = dailyRoutineRepository.findAllByThemes(themeIds);
+ return DailyRoutinesByThemesResponse.of(routines);
+ }
+
+ @Override
+ public DailyRoutinesByThemeResponse getRoutinesByTheme(long memberId, long themeId) {
+ val member = findMember(memberId);
+ val theme = findTheme(themeId);
+ val routines = getRoutines(member, theme);
+ return DailyRoutinesByThemeResponse.of(theme, routines);
+ }
+
+ private Member findMember(long id) {
+ return memberRepository.findById(id)
+ .orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ private DailyTheme findTheme(long id) {
+ return dailyThemeRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_THEME));
+ }
+
+ private List getRoutines(Member member, DailyTheme theme) {
+ return dailyRoutineRepository.findAllByTheme(theme).stream()
+ .filter(member::isNotExistRoutine)
+ .toList();
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java b/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java
new file mode 100644
index 00000000..e1ad67a8
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java
@@ -0,0 +1,11 @@
+package com.soptie.server.routine.service;
+
+import com.soptie.server.routine.dto.HappinessRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessSubRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessThemesResponse;
+
+public interface HappinessRoutineService {
+ HappinessThemesResponse getHappinessThemes();
+ HappinessRoutinesResponse getHappinessRoutinesByTheme(Long themeId);
+ HappinessSubRoutinesResponse getHappinessSubRoutines(long routineId);
+}
diff --git a/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java
new file mode 100644
index 00000000..5588a4e8
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java
@@ -0,0 +1,49 @@
+package com.soptie.server.routine.service;
+
+import static com.soptie.server.routine.message.ErrorCode.*;
+
+import com.soptie.server.routine.dto.HappinessRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessSubRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessThemesResponse;
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import com.soptie.server.routine.exception.RoutineException;
+import com.soptie.server.routine.repository.happiness.routine.HappinessRoutineRepository;
+import com.soptie.server.routine.repository.happiness.theme.HappinessThemeRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class HappinessRoutineServiceImpl implements HappinessRoutineService {
+
+ private final HappinessRoutineRepository happinessRoutineRepository;
+ private final HappinessThemeRepository happinessThemeRepository;
+ private final HappinessSubRoutineService happinessSubRoutineService;
+
+ @Override
+ public HappinessThemesResponse getHappinessThemes() {
+ val themes = happinessThemeRepository.findAllOrderByNameAsc();
+ return HappinessThemesResponse.of(themes);
+ }
+
+ @Override
+ public HappinessRoutinesResponse getHappinessRoutinesByTheme(Long themeId) {
+ val routines = happinessRoutineRepository.findAllByThemeId(themeId);
+ return HappinessRoutinesResponse.of(routines);
+ }
+
+ @Override
+ public HappinessSubRoutinesResponse getHappinessSubRoutines(long routineId) {
+ val routine = findRoutine(routineId);
+ val happinessSubRoutines = happinessSubRoutineService.getHappinessSubRoutines(routine);
+ return HappinessSubRoutinesResponse.of(routine, happinessSubRoutines);
+ }
+
+ private HappinessRoutine findRoutine(long id) {
+ return happinessRoutineRepository.findById(id)
+ .orElseThrow(() -> new RoutineException(INVALID_ROUTINE));
+ }
+}
diff --git a/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java
new file mode 100644
index 00000000..a094d638
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java
@@ -0,0 +1,10 @@
+package com.soptie.server.routine.service;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+
+import java.util.List;
+
+public interface HappinessSubRoutineService {
+ List getHappinessSubRoutines(HappinessRoutine routine);
+}
diff --git a/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java
new file mode 100644
index 00000000..6f5bccbc
--- /dev/null
+++ b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java
@@ -0,0 +1,23 @@
+package com.soptie.server.routine.service;
+
+import com.soptie.server.routine.entity.happiness.HappinessRoutine;
+import com.soptie.server.routine.entity.happiness.HappinessSubRoutine;
+import com.soptie.server.routine.repository.happiness.routine.HappinessSubRoutineRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class HappinessSubRoutineServiceImpl implements HappinessSubRoutineService {
+
+ private final HappinessSubRoutineRepository happinessSubRoutineRepository;
+
+ @Override
+ public List getHappinessSubRoutines(HappinessRoutine routine) {
+ return happinessSubRoutineRepository.findAllByRoutine(routine);
+ }
+}
diff --git a/src/main/java/com/soptie/server/test/TestController.java b/src/main/java/com/soptie/server/test/TestController.java
new file mode 100644
index 00000000..5c5a53ef
--- /dev/null
+++ b/src/main/java/com/soptie/server/test/TestController.java
@@ -0,0 +1,16 @@
+package com.soptie.server.test;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/test")
+public class TestController {
+
+ @GetMapping
+ public ResponseEntity test() {
+ return ResponseEntity.ok("Success to server connect.");
+ }
+}
diff --git a/src/main/java/com/soptie/server/version/controller/VersionController.java b/src/main/java/com/soptie/server/version/controller/VersionController.java
new file mode 100644
index 00000000..be5c49f8
--- /dev/null
+++ b/src/main/java/com/soptie/server/version/controller/VersionController.java
@@ -0,0 +1,28 @@
+package com.soptie.server.version.controller;
+
+import static com.soptie.server.version.message.SuccessMessage.*;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.version.service.VersionService;
+
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/versions")
+public class VersionController {
+
+ private final VersionService versionService;
+
+ @GetMapping("/client/app")
+ public ResponseEntity getClientAppVersion() {
+ val response = versionService.getClientAppVersion();
+ return ResponseEntity.ok(Response.success(SUCCESS_GET_APP_VERSION.getMessage(), response));
+ }
+}
diff --git a/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java b/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java
new file mode 100644
index 00000000..2445e820
--- /dev/null
+++ b/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java
@@ -0,0 +1,29 @@
+package com.soptie.server.version.dto;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+
+@Builder(access = AccessLevel.PRIVATE)
+public record AppVersionResponse(
+ VersionResponse iosVersion,
+ VersionResponse androidVersion,
+ String notificationTitle,
+ String notificationContent
+) {
+
+ public static AppVersionResponse of(String iOSAppVersion, String iosForceUpdateVersion, String androidAppVersion,
+ String androidForceUpdateVersion, String notificationTitle, String notificationContent) {
+ return AppVersionResponse.builder()
+ .iosVersion(new VersionResponse(iOSAppVersion, iosForceUpdateVersion))
+ .androidVersion(new VersionResponse(androidAppVersion, androidForceUpdateVersion))
+ .notificationTitle(notificationTitle)
+ .notificationContent(notificationContent)
+ .build();
+ }
+
+ private record VersionResponse(
+ String appVersion,
+ String forceUpdateVersion
+ ) {
+ }
+}
diff --git a/src/main/java/com/soptie/server/version/message/SuccessMessage.java b/src/main/java/com/soptie/server/version/message/SuccessMessage.java
new file mode 100644
index 00000000..303516c4
--- /dev/null
+++ b/src/main/java/com/soptie/server/version/message/SuccessMessage.java
@@ -0,0 +1,14 @@
+package com.soptie.server.version.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum SuccessMessage {
+
+ SUCCESS_GET_APP_VERSION("버전 정보 조회 성공"),
+ ;
+
+ private final String message;
+}
diff --git a/src/main/java/com/soptie/server/version/service/VersionService.java b/src/main/java/com/soptie/server/version/service/VersionService.java
new file mode 100644
index 00000000..c35f5e20
--- /dev/null
+++ b/src/main/java/com/soptie/server/version/service/VersionService.java
@@ -0,0 +1,7 @@
+package com.soptie.server.version.service;
+
+import com.soptie.server.version.dto.AppVersionResponse;
+
+public interface VersionService {
+ AppVersionResponse getClientAppVersion();
+}
diff --git a/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java b/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java
new file mode 100644
index 00000000..42c24f16
--- /dev/null
+++ b/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java
@@ -0,0 +1,28 @@
+package com.soptie.server.version.service;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.version.dto.AppVersionResponse;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class VersionServiceImpl implements VersionService {
+
+ private final ValueConfig valueConfig;
+
+ @Override
+ public AppVersionResponse getClientAppVersion() {
+ return AppVersionResponse.of(
+ valueConfig.getIOS_APP_VERSION(),
+ valueConfig.getIOS_FORCE_UPDATE_VERSION(),
+ valueConfig.getANDROID_APP_VERSION(),
+ valueConfig.getANDROID_FORCE_UPDATE_VERSION(),
+ valueConfig.getNOTIFICATION_TITLE(),
+ valueConfig.getNOTIFICATION_CONTENT());
+ }
+}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
new file mode 100644
index 00000000..53e3b204
--- /dev/null
+++ b/src/main/resources/application-dev.yml
@@ -0,0 +1,62 @@
+spring:
+ config:
+ import: application-secret.yml
+ activate:
+ on-profile: dev
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.dev}:5432/postgres?currentSchema=${DATABASE.NAME.dev}
+ username: ${DATABASE.USERNAME.dev}
+ password: ${DATABASE.PASSWORD.dev}
+ jpa:
+ hibernate:
+ ddl-auto: none
+ properties:
+ hibernate:
+ format_sql: true
+ default_batch_fetch_size: 1000
+ auto_quote_keyword: true
+ security:
+ oauth2:
+ client:
+ registration:
+ kakao:
+ client-id: ${KAKAO.CLIENT_ID}
+ redirect-uri: ${KAKAO.REDIRECT_URI}
+ authorization-grant-type: authorization_code
+ scope: profile_nickname
+ client-name: Kakao
+ client-secret: ${KAKAO.CLIENT_SECRET}
+ client-authentication-method: POST
+ provider:
+ kakao:
+ authorization-uri: https://kauth.kakao.com/oauth/authorize
+ token-uri: https://kauth.kakao.com/oauth/token
+ user-info-uri: https://kapi.kakao.com/v2/user/me
+ user-name-attribute: id
+
+logging:
+ level:
+ org.hibernate.SQL: debug
+ slack:
+ webhook_url: ${SLACK.WEBHOOK_URL.dev}
+ config: classpath:logback-spring.xml
+
+jwt:
+ secret: ${JWT.SECRET}
+ KAKAO_URL: https://kapi.kakao.com/v2/user/me
+ APPLE_URL: https://appleid.apple.com/auth/keys
+ ACCESS_TOKEN_EXPIRED: 7200000
+ REFRESH_TOKEN_EXPIRED: 1209600000
+
+springdoc:
+ default-consumes-media-type: application/json;charset=UTF-8
+ default-produces-media-type: application/json;charset=UTF-8
+ swagger-ui:
+ url: /docs/open-api-3.0.1.json
+ path: /swagger
+
+softie:
+ cron:
+ init:
+ routine: "0 0 0 * * *"
\ No newline at end of file
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
new file mode 100644
index 00000000..60ec89ec
--- /dev/null
+++ b/src/main/resources/application-prod.yml
@@ -0,0 +1,55 @@
+spring:
+ config:
+ import: application-secret.yml
+ activate:
+ on-profile: prod
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.prod}:5432/postgres?currentSchema=${DATABASE.NAME.prod}
+ username: ${DATABASE.USERNAME.prod}
+ password: ${DATABASE.PASSWORD.prod}
+ jpa:
+ hibernate:
+ ddl-auto: none
+ properties:
+ hibernate:
+ format_sql: true
+ default_batch_fetch_size: 1000
+ auto_quote_keyword: true
+ security:
+ oauth2:
+ client:
+ registration:
+ kakao:
+ client-id: ${KAKAO.CLIENT_ID}
+ redirect-uri: ${KAKAO.REDIRECT_URI}
+ authorization-grant-type: authorization_code
+ scope: profile_nickname
+ client-name: Kakao
+ client-secret: ${KAKAO.CLIENT_SECRET}
+ client-authentication-method: POST
+ provider:
+ kakao:
+ authorization-uri: https://kauth.kakao.com/oauth/authorize
+ token-uri: https://kauth.kakao.com/oauth/token
+ user-info-uri: https://kapi.kakao.com/v2/user/me
+ user-name-attribute: id
+
+logging:
+ level:
+ org.hibernate.SQL: debug
+ slack:
+ webhook_url: ${SLACK.WEBHOOK_URL.prod}
+ config: classpath:logback-spring.xml
+
+jwt:
+ secret: ${JWT.SECRET}
+ KAKAO_URL: https://kapi.kakao.com/v2/user/me
+ APPLE_URL: https://appleid.apple.com/auth/keys
+ ACCESS_TOKEN_EXPIRED: 7200000
+ REFRESH_TOKEN_EXPIRED: 1209600000
+
+softie:
+ cron:
+ init:
+ routine: "0 0 0 * * *"
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 8b137891..00000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
new file mode 100644
index 00000000..305b57ec
--- /dev/null
+++ b/src/main/resources/logback-spring.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ ${SLACK_WEBHOOK_URI}
+
+ %d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n
+
+ Cake-Server-log
+ :stuck_out_tongue_winking_eye:
+ true
+
+
+
+
+ %d %-5level %logger{35} - %msg%n
+
+
+
+
+
+
+ ERROR
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/static/docs/open-api-3.0.1.json b/src/main/resources/static/docs/open-api-3.0.1.json
new file mode 100644
index 00000000..4bae6b80
--- /dev/null
+++ b/src/main/resources/static/docs/open-api-3.0.1.json
@@ -0,0 +1,1499 @@
+{
+ "openapi" : "3.0.1",
+ "info" : {
+ "title" : "소프티 API",
+ "description" : "소프티 API 명세서",
+ "version" : "0.0.1"
+ },
+ "servers" : [ {
+ "url" : "http://localhost:8080"
+ } ],
+ "tags" : [ ],
+ "paths" : {
+ "/api/v1/auth" : {
+ "post" : {
+ "tags" : [ "AUTH" ],
+ "summary" : "소셜 로그인",
+ "description" : "소셜 로그인",
+ "operationId" : "post-token-docs",
+ "parameters" : [ {
+ "name" : "Authorization",
+ "in" : "header",
+ "description" : "Social Access Token",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ },
+ "example" : "Bearer softietoken"
+ } ],
+ "requestBody" : {
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth-1040425838"
+ },
+ "examples" : {
+ "post-token-docs" : {
+ "value" : "{\n \"socialType\" : \"KAKAO\"\n}"
+ }
+ }
+ }
+ }
+ },
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth123505361"
+ },
+ "examples" : {
+ "post-token-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"소셜로그인 성공\",\n \"data\" : {\n \"accessToken\" : \"softie\",\n \"refreshToken\" : \"token\",\n \"isMemberDollExist\" : false\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete" : {
+ "tags" : [ "AUTH" ],
+ "summary" : "회원탈퇴",
+ "description" : "회원탈퇴",
+ "operationId" : "delete-withdrawal-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "delete-withdrawal-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"회원 탈퇴 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/members" : {
+ "get" : {
+ "tags" : [ "MEMBER" ],
+ "summary" : "홈 화면 불러오기",
+ "description" : "홈 화면 불러오기",
+ "operationId" : "get-member-home-screen-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-members-980081866"
+ },
+ "examples" : {
+ "get-member-home-screen-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"홈 화면 불러오기 성공\",\n \"data\" : {\n \"name\" : \"softie\",\n \"dollType\" : \"BROWN\",\n \"frameImageUrl\" : \"https://...\",\n \"dailyCottonCount\" : 0,\n \"happinessCottonCount\" : 0,\n \"conversations\" : [ \"안녕\", \"하이\", \"봉쥬르\" ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post" : {
+ "tags" : [ "MEMBER" ],
+ "summary" : "프로필 생성",
+ "description" : "프로필 생성",
+ "operationId" : "post-member-profile-docs",
+ "requestBody" : {
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-members-2061462562"
+ },
+ "examples" : {
+ "post-member-profile-docs" : {
+ "value" : "{\n \"dollType\" : \"BROWN\",\n \"name\" : \"소프티\",\n \"routines\" : [ 1, 2, 3 ]\n}"
+ }
+ }
+ }
+ }
+ },
+ "responses" : {
+ "201" : {
+ "description" : "201",
+ "headers" : {
+ "Location" : {
+ "description" : "Redirect URI",
+ "schema" : {
+ "type" : "string"
+ }
+ }
+ },
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "post-member-profile-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"프로필 생성 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/test" : {
+ "get" : {
+ "tags" : [ "TEST" ],
+ "summary" : "서버 연결 테스트",
+ "description" : "서버 연결 테스트",
+ "operationId" : "test-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-test486549215"
+ },
+ "examples" : {
+ "test-docs" : {
+ "value" : "Success to server connect."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/auth/logout" : {
+ "post" : {
+ "tags" : [ "AUTH" ],
+ "summary" : "로그아웃",
+ "description" : "로그아웃",
+ "operationId" : "post-logout-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "post-logout-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"로그아웃 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/auth/token" : {
+ "post" : {
+ "tags" : [ "AUTH" ],
+ "summary" : "토큰 재발급",
+ "description" : "토큰 재발급",
+ "operationId" : "post-recreate-token-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth-token-831815960"
+ },
+ "examples" : {
+ "post-recreate-token-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"토큰 재발급 성공\",\n \"data\" : {\n \"accessToken\" : \"accessToken\"\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/daily" : {
+ "get" : {
+ "tags" : [ "DAILY ROUTINE" ],
+ "summary" : "테마 리스트 별 데일리 루틴 리스트 조회",
+ "description" : "테마 리스트 별 데일리 루틴 리스트 조회",
+ "operationId" : "get-themes-daily-routines-docs",
+ "parameters" : [ {
+ "name" : "themes",
+ "in" : "query",
+ "description" : "조회할 테마 id 정보",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-54531209"
+ },
+ "examples" : {
+ "get-themes-daily-routines-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\"\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\"\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\"\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\"\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/happiness" : {
+ "get" : {
+ "tags" : [ "HAPPINESS ROUTINE" ],
+ "summary" : "테마 별 행복 루틴 리스트 조회",
+ "description" : "테마 별 행복 루틴 리스트 조회",
+ "operationId" : "get-theme-happiness-routines-docs",
+ "parameters" : [ {
+ "name" : "themeId",
+ "in" : "query",
+ "description" : "조회할 테마 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-happiness1133934080"
+ },
+ "examples" : {
+ "get-theme-happiness-routines-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"name\" : \"Happiness routine Theme1\",\n \"nameColor\" : \"Happiness routine Theme1\",\n \"title\" : \"Happiness routine Theme1\",\n \"iconImageUrl\" : \"icon_image_url1\"\n }, {\n \"routineId\" : 2,\n \"name\" : \"Happiness routine Theme2\",\n \"nameColor\" : \"Happiness routine Theme2\",\n \"title\" : \"Happiness routine Theme2\",\n \"iconImageUrl\" : \"icon_image_url2\"\n }, {\n \"routineId\" : 3,\n \"name\" : \"Happiness routine Theme3\",\n \"nameColor\" : \"Happiness routine Theme3\",\n \"title\" : \"Happiness routine Theme3\",\n \"iconImageUrl\" : \"icon_image_url3\"\n }, {\n \"routineId\" : 4,\n \"name\" : \"Happiness routine Theme4\",\n \"nameColor\" : \"Happiness routine Theme4\",\n \"title\" : \"Happiness routine Theme4\",\n \"iconImageUrl\" : \"icon_image_url4\"\n }, {\n \"routineId\" : 5,\n \"name\" : \"Happiness routine Theme5\",\n \"nameColor\" : \"Happiness routine Theme5\",\n \"title\" : \"Happiness routine Theme5\",\n \"iconImageUrl\" : \"icon_image_url5\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/dolls/image/{type}" : {
+ "get" : {
+ "tags" : [ "DOLL" ],
+ "summary" : "인형 이미지 조회",
+ "description" : "인형 이미지 조회",
+ "operationId" : "get-doll-image-docs",
+ "parameters" : [ {
+ "name" : "type",
+ "in" : "path",
+ "description" : "인형 타입",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-dolls-image-type-1785135499"
+ },
+ "examples" : {
+ "get-doll-image-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"인형 이미지 조회 성공\",\n \"data\" : {\n \"faceImageUrl\" : \"face-image-url\"\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/members/cotton/{cottonType}" : {
+ "patch" : {
+ "tags" : [ "MEMBER" ],
+ "summary" : "솜뭉치 주기",
+ "description" : "솜뭉치 주기",
+ "operationId" : "give-cotton-docs",
+ "parameters" : [ {
+ "name" : "cottonType",
+ "in" : "path",
+ "description" : "솜뭉치 종류",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-members-cotton-cottonType-1561973557"
+ },
+ "examples" : {
+ "give-cotton-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"솜뭉치 주기 성공\",\n \"data\" : {\n \"cottonCount\" : 0\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/daily/member" : {
+ "get" : {
+ "tags" : [ "MEMBER DAILY ROUTINE" ],
+ "summary" : "회원 별 데일리 루틴 리스트 조회",
+ "description" : "회원 별 데일리 루틴 리스트 조회",
+ "operationId" : "get-member-daily-routines-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-member-336344243"
+ },
+ "examples" : {
+ "get-member-daily-routines-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 1,\n \"isAchieve\" : false\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 2,\n \"isAchieve\" : true\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 3,\n \"isAchieve\" : false\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 4,\n \"isAchieve\" : true\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 5,\n \"isAchieve\" : false\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post" : {
+ "tags" : [ "MEMBER DAILY ROUTINE" ],
+ "summary" : "회원 데일리 루틴 추가 성공",
+ "description" : "회원 데일리 루틴 추가 성공",
+ "operationId" : "post-member-daily-routine-docs",
+ "requestBody" : {
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-member1721287602"
+ },
+ "examples" : {
+ "post-member-daily-routine-docs" : {
+ "value" : "{\n \"routineId\" : 1\n}"
+ }
+ }
+ }
+ }
+ },
+ "responses" : {
+ "201" : {
+ "description" : "201",
+ "headers" : {
+ "Location" : {
+ "description" : "Redirect URI",
+ "schema" : {
+ "type" : "string"
+ }
+ }
+ },
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-member1327124516"
+ },
+ "examples" : {
+ "post-member-daily-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 추가 성공\",\n \"data\" : {\n \"routineId\" : 1\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete" : {
+ "tags" : [ "MEMBER DAILY ROUTINE" ],
+ "summary" : "회원 데일리 루틴 삭제",
+ "description" : "회원 데일리 루틴 삭제",
+ "operationId" : "delete-member-daily-routines-docs",
+ "parameters" : [ {
+ "name" : "routines",
+ "in" : "query",
+ "description" : "삭제할 루틴 id 리스트",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "delete-member-daily-routines-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 삭제 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/daily/themes" : {
+ "get" : {
+ "tags" : [ "DAILY ROUTINE" ],
+ "summary" : "데일리 루틴 테마 리스트 조회",
+ "description" : "데일리 루틴 테마 리스트 조회",
+ "operationId" : "get-daily-themes-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-themes1975236624"
+ },
+ "examples" : {
+ "get-daily-themes-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"테마 조회 성공\",\n \"data\" : {\n \"themes\" : [ {\n \"themeId\" : 1,\n \"name\" : \"theme name1\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 2,\n \"name\" : \"theme name2\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 3,\n \"name\" : \"theme name3\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 4,\n \"name\" : \"theme name4\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 5,\n \"name\" : \"theme name5\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/happiness/member" : {
+ "get" : {
+ "tags" : [ "MEMBER HAPPINESS ROUTINE" ],
+ "summary" : "회원 별 행복 루틴 리스트 조회",
+ "description" : "회원 별 행복 루틴 리스트 조회",
+ "operationId" : "get-member-happiness-routine-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-happiness-member-160843619"
+ },
+ "examples" : {
+ "get-member-happiness-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routineId\" : 1,\n \"iconImageUrl\" : \"icon-image-url\",\n \"contentImageUrl\" : \"content-image-url\",\n \"themeName\" : \"태마 이름\",\n \"themeNameColor\" : \"#ffcdc5\",\n \"title\" : \"루틴 제목\",\n \"content\" : \"routine-content\",\n \"detailContent\" : \"routine-detail-content\",\n \"place\" : \"루틴 장소\",\n \"timeTaken\" : \"5~10분 소요\"\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post" : {
+ "tags" : [ "MEMBER HAPPINESS ROUTINE" ],
+ "summary" : "회원 행복 루틴 추가 성공",
+ "description" : "회원 행복 루틴 추가 성공",
+ "operationId" : "post-member-happiness-routine-docs",
+ "requestBody" : {
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-happiness-member1502060882"
+ },
+ "examples" : {
+ "post-member-happiness-routine-docs" : {
+ "value" : "{\n \"subRoutineId\" : 1\n}"
+ }
+ }
+ }
+ }
+ },
+ "responses" : {
+ "201" : {
+ "description" : "201",
+ "headers" : {
+ "Location" : {
+ "description" : "Redirect URI",
+ "schema" : {
+ "type" : "string"
+ }
+ }
+ },
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-member1327124516"
+ },
+ "examples" : {
+ "post-member-happiness-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 추가 성공\",\n \"data\" : {\n \"routineId\" : 1\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/happiness/themes" : {
+ "get" : {
+ "tags" : [ "HAPPINESS ROUTINE" ],
+ "summary" : "행복 루틴 테마 리스트 조회",
+ "description" : "행복 루틴 테마 리스트 조회",
+ "operationId" : "get-happiness-themes-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-happiness-themes460193954"
+ },
+ "examples" : {
+ "get-happiness-themes-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"테마 조회 성공\",\n \"data\" : {\n \"themes\" : [ {\n \"themeId\" : 1,\n \"name\" : \"Happiness routine Theme1\"\n }, {\n \"themeId\" : 2,\n \"name\" : \"Happiness routine Theme2\"\n }, {\n \"themeId\" : 3,\n \"name\" : \"Happiness routine Theme3\"\n }, {\n \"themeId\" : 4,\n \"name\" : \"Happiness routine Theme4\"\n }, {\n \"themeId\" : 5,\n \"name\" : \"Happiness routine Theme5\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/versions/client/app" : {
+ "get" : {
+ "tags" : [ "VERSION" ],
+ "summary" : "앱 버전 정보 조회",
+ "description" : "앱 버전 정보 조회",
+ "operationId" : "get-client-app-version-docs",
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-versions-client-app2103188655"
+ },
+ "examples" : {
+ "get-client-app-version-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"버전 조회 성공\",\n \"data\" : {\n \"iosVersion\" : {\n \"appVersion\" : \"1.0.0\",\n \"forceUpdateVersion\" : \"1.0.0\"\n },\n \"androidVersion\" : {\n \"appVersion\" : \"1.0.0\",\n \"forceUpdateVersion\" : \"1.0.0\"\n },\n \"notificationTitle\" : \"업데이트 안내\",\n \"notificationContent\" : \"업데이트가 필요합니다.\"\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/daily/theme/{themeId}" : {
+ "get" : {
+ "tags" : [ "DAILY ROUTINE" ],
+ "summary" : "테마 별 데일리 루틴 리스트 조회",
+ "description" : "테마 별 데일리 루틴 리스트 조회",
+ "operationId" : "get-theme-daily-routines-docs",
+ "parameters" : [ {
+ "name" : "themeId",
+ "in" : "path",
+ "description" : "테마 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-theme-themeId-1869963578"
+ },
+ "examples" : {
+ "get-theme-daily-routines-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"backgroundImageUrl\" : \"https://...\",\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\"\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\"\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\"\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\"\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/happiness/routine/{routineId}" : {
+ "get" : {
+ "tags" : [ "HAPPINESS ROUTINE" ],
+ "summary" : "행복 루틴 별 서브 행복 루틴 리스트 조회",
+ "description" : "행복 루틴 별 서브 행복 루틴 리스트 조회",
+ "operationId" : "get-sub-happiness-routines-by-routine-of-theme-docs",
+ "parameters" : [ {
+ "name" : "routineId",
+ "in" : "path",
+ "description" : "루틴 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-happiness-routine-routineId2107530734"
+ },
+ "examples" : {
+ "get-sub-happiness-routines-by-routine-of-theme-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"행복 루틴 별 서브 루틴 리스트 조회 성공\",\n \"data\" : {\n \"title\" : \"타이틀\",\n \"name\" : \"테마 이름\",\n \"nameColor\" : \"#999\",\n \"iconImageUrl\" : \"https://...\",\n \"contentImageUrl\" : \"https://...\",\n \"subRoutines\" : [ {\n \"subRoutineId\" : 1,\n \"content\" : \"소프티 숙소\",\n \"detailContent\" : \"어쩌구 저쩌구\",\n \"timeTaken\" : \"아무때\",\n \"place\" : \"소프티 만나러 가는 길\"\n } ]\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/daily/member/routine/{routineId}" : {
+ "patch" : {
+ "tags" : [ "MEMBER DAILY ROUTINE" ],
+ "summary" : "회원 데일리 루틴 달성 성공",
+ "description" : "회원 데일리 루틴 달성 성공",
+ "operationId" : "achieve-member-daily-routine-docs",
+ "parameters" : [ {
+ "name" : "routineId",
+ "in" : "path",
+ "description" : "루틴 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-routines-daily-member-routine-routineId-2021368021"
+ },
+ "examples" : {
+ "achieve-member-daily-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 달성 성공\",\n \"data\" : {\n \"routineId\" : 1,\n \"isAchieve\" : true,\n \"achieveCount\" : 1\n }\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/routines/happiness/member/routine/{routineId}" : {
+ "delete" : {
+ "tags" : [ "MEMBER HAPPINESS ROUTINE" ],
+ "summary" : "회원 행복 루틴 삭제 성공",
+ "description" : "회원 행복 루틴 삭제 성공",
+ "operationId" : "delete-member-happiness-routine-docs",
+ "parameters" : [ {
+ "name" : "routineId",
+ "in" : "path",
+ "description" : "루틴 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "delete-member-happiness-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 삭제 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch" : {
+ "tags" : [ "MEMBER HAPPINESS ROUTINE" ],
+ "summary" : "회원 행복 루틴 달성 성공",
+ "description" : "회원 행복 루틴 달성 성공",
+ "operationId" : "achieve-member-happiness-routine-docs",
+ "parameters" : [ {
+ "name" : "routineId",
+ "in" : "path",
+ "description" : "루틴 id",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "200",
+ "content" : {
+ "application/json;charset=UTF-8" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/api-v1-auth594740350"
+ },
+ "examples" : {
+ "achieve-member-happiness-routine-docs" : {
+ "value" : "{\n \"success\" : true,\n \"message\" : \"회원 행복 루틴 달성 성공\",\n \"data\" : null\n}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components" : {
+ "schemas" : {
+ "api-v1-members-980081866" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "dailyCottonCount" : {
+ "type" : "number",
+ "description" : "솜뭉치 개수"
+ },
+ "name" : {
+ "type" : "string",
+ "description" : "인형 이름"
+ },
+ "frameImageUrl" : {
+ "type" : "string",
+ "description" : "인형 배경 이미지 url"
+ },
+ "dollType" : {
+ "type" : "string",
+ "description" : "인형 종류"
+ },
+ "conversations" : {
+ "type" : "array",
+ "description" : "인형 대화 리스트",
+ "items" : {
+ "oneOf" : [ {
+ "type" : "object"
+ }, {
+ "type" : "boolean"
+ }, {
+ "type" : "string"
+ }, {
+ "type" : "number"
+ } ]
+ }
+ },
+ "happinessCottonCount" : {
+ "type" : "number",
+ "description" : "행운 솜뭉치 개수"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-auth-token-831815960" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "accessToken" : {
+ "type" : "string",
+ "description" : "Access Token"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-daily-54531209" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "routines" : {
+ "type" : "array",
+ "description" : "루틴 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "routineId" : {
+ "type" : "number",
+ "description" : "루틴 id"
+ },
+ "content" : {
+ "type" : "string",
+ "description" : "테마 내용"
+ }
+ }
+ }
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-daily-member1721287602" : {
+ "type" : "object",
+ "properties" : {
+ "routineId" : {
+ "type" : "number",
+ "description" : "추가할 루틴 id"
+ }
+ }
+ },
+ "api-v1-routines-daily-themes1975236624" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "themes" : {
+ "type" : "array",
+ "description" : "테마 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "iconImageUrl" : {
+ "type" : "string",
+ "description" : "아이콘 이미지 url"
+ },
+ "name" : {
+ "type" : "string",
+ "description" : "테마 이름"
+ },
+ "themeId" : {
+ "type" : "number",
+ "description" : "테마 id"
+ },
+ "backgroundImageUrl" : {
+ "type" : "string",
+ "description" : "배경 이미지 url"
+ }
+ }
+ }
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-happiness-themes460193954" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "themes" : {
+ "type" : "array",
+ "description" : "테마 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "name" : {
+ "type" : "string",
+ "description" : "테마 이름"
+ },
+ "themeId" : {
+ "type" : "number",
+ "description" : "테마 id"
+ }
+ }
+ }
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-daily-theme-themeId-1869963578" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "routines" : {
+ "type" : "array",
+ "description" : "루틴 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "routineId" : {
+ "type" : "number",
+ "description" : "루틴 id"
+ },
+ "content" : {
+ "type" : "string",
+ "description" : "테마 내용"
+ }
+ }
+ }
+ },
+ "backgroundImageUrl" : {
+ "type" : "string",
+ "description" : "테마 배경 이미지 url"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-auth594740350" : {
+ "type" : "object",
+ "properties" : {
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-members-cotton-cottonType-1561973557" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "cottonCount" : {
+ "type" : "number",
+ "description" : "남은 솜뭉치 개수"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-auth123505361" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "isMemberDollExist" : {
+ "type" : "boolean",
+ "description" : "프로필 존재 여부"
+ },
+ "accessToken" : {
+ "type" : "string",
+ "description" : "Access Token"
+ },
+ "refreshToken" : {
+ "type" : "string",
+ "description" : "Refresh Token"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-dolls-image-type-1785135499" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "faceImageUrl" : {
+ "type" : "string",
+ "description" : "인형 얼굴 이미지 url"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-auth-1040425838" : {
+ "type" : "object",
+ "properties" : {
+ "socialType" : {
+ "type" : "string",
+ "description" : "소셜 종류"
+ }
+ }
+ },
+ "api-v1-routines-daily-member-routine-routineId-2021368021" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "achieveCount" : {
+ "type" : "number",
+ "description" : "달성 횟수"
+ },
+ "routineId" : {
+ "type" : "number",
+ "description" : "루틴 id"
+ },
+ "isAchieve" : {
+ "type" : "boolean",
+ "description" : "달성 여부"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-happiness-routine-routineId2107530734" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "subRoutines" : {
+ "type" : "array",
+ "description" : "서브 루틴 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "timeTaken" : {
+ "type" : "string",
+ "description" : "소요 시간"
+ },
+ "detailContent" : {
+ "type" : "string",
+ "description" : "서브 루틴 세부 내용"
+ },
+ "place" : {
+ "type" : "string",
+ "description" : "장소"
+ },
+ "subRoutineId" : {
+ "type" : "number",
+ "description" : "서브 루틴 id"
+ },
+ "content" : {
+ "type" : "string",
+ "description" : "서브 루틴 내용"
+ }
+ }
+ }
+ },
+ "contentImageUrl" : {
+ "type" : "string",
+ "description" : "카드 이미지 url"
+ },
+ "iconImageUrl" : {
+ "type" : "string",
+ "description" : "아이콘 이미지 url"
+ },
+ "nameColor" : {
+ "type" : "string",
+ "description" : "색깔"
+ },
+ "name" : {
+ "type" : "string",
+ "description" : "테마 이름"
+ },
+ "title" : {
+ "type" : "string",
+ "description" : "행복 루틴 주제"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-versions-client-app2103188655" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "notificationTitle" : {
+ "type" : "string",
+ "description" : "업데이트 알림 제목"
+ },
+ "androidVersion" : {
+ "type" : "object",
+ "properties" : {
+ "appVersion" : {
+ "type" : "string",
+ "description" : "안드로이드 앱 버전"
+ },
+ "forceUpdateVersion" : {
+ "type" : "string",
+ "description" : "안드로이드 강제 업데이트 버전"
+ }
+ },
+ "description" : "안드로이드 버전 정보"
+ },
+ "iosVersion" : {
+ "type" : "object",
+ "properties" : {
+ "appVersion" : {
+ "type" : "string",
+ "description" : "iOS 앱 버전"
+ },
+ "forceUpdateVersion" : {
+ "type" : "string",
+ "description" : "iOS 강제 업데이트 버전"
+ }
+ },
+ "description" : "iOS 버전 정보"
+ },
+ "notificationContent" : {
+ "type" : "string",
+ "description" : "업데이트 알림 내용"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-happiness-member-160843619" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "timeTaken" : {
+ "type" : "string",
+ "description" : "소요 시간"
+ },
+ "detailContent" : {
+ "type" : "string",
+ "description" : "루틴 상세 내용"
+ },
+ "themeName" : {
+ "type" : "string",
+ "description" : "테마 이름"
+ },
+ "contentImageUrl" : {
+ "type" : "string",
+ "description" : "카드 이미지 url"
+ },
+ "themeNameColor" : {
+ "type" : "string",
+ "description" : "테마 이름 색상"
+ },
+ "iconImageUrl" : {
+ "type" : "string",
+ "description" : "아이콘 이미지 url"
+ },
+ "place" : {
+ "type" : "string",
+ "description" : "장소"
+ },
+ "title" : {
+ "type" : "string",
+ "description" : "루틴 제목"
+ },
+ "routineId" : {
+ "type" : "number",
+ "description" : "루틴 id"
+ },
+ "content" : {
+ "type" : "string",
+ "description" : "루틴 내용"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-happiness-member1502060882" : {
+ "type" : "object",
+ "properties" : {
+ "subRoutineId" : {
+ "type" : "number",
+ "description" : "추가할 루틴 id"
+ }
+ }
+ },
+ "api-v1-routines-happiness1133934080" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "routines" : {
+ "type" : "array",
+ "description" : "루틴 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "iconImageUrl" : {
+ "type" : "string",
+ "description" : "이미지 url"
+ },
+ "nameColor" : {
+ "type" : "string",
+ "description" : "행복 루틴 제목 색상"
+ },
+ "name" : {
+ "type" : "string",
+ "description" : "행복 루틴 제목"
+ },
+ "title" : {
+ "type" : "string",
+ "description" : "행복 루틴 설명"
+ },
+ "routineId" : {
+ "type" : "number",
+ "description" : "행복 루틴 id"
+ }
+ }
+ }
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-routines-daily-member1327124516" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "routineId" : {
+ "type" : "number",
+ "description" : "생성한 루틴 id"
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-members-2061462562" : {
+ "type" : "object",
+ "properties" : {
+ "name" : {
+ "type" : "string",
+ "description" : "인형 이름"
+ },
+ "routines" : {
+ "type" : "array",
+ "description" : "인형 종류",
+ "items" : {
+ "oneOf" : [ {
+ "type" : "object"
+ }, {
+ "type" : "boolean"
+ }, {
+ "type" : "string"
+ }, {
+ "type" : "number"
+ } ]
+ }
+ },
+ "dollType" : {
+ "type" : "string",
+ "description" : "인형 종류"
+ }
+ }
+ },
+ "api-v1-routines-daily-member-336344243" : {
+ "type" : "object",
+ "properties" : {
+ "data" : {
+ "type" : "object",
+ "properties" : {
+ "routines" : {
+ "type" : "array",
+ "description" : "루틴 정보 리스트",
+ "items" : {
+ "type" : "object",
+ "properties" : {
+ "achieveCount" : {
+ "type" : "number",
+ "description" : "달성 횟수"
+ },
+ "iconImageUrl" : {
+ "type" : "string",
+ "description" : "아이콘 이미지 url"
+ },
+ "routineId" : {
+ "type" : "number",
+ "description" : "루틴 id"
+ },
+ "content" : {
+ "type" : "string",
+ "description" : "테마 내용"
+ },
+ "isAchieve" : {
+ "type" : "boolean",
+ "description" : "달성 여부"
+ }
+ }
+ }
+ }
+ },
+ "description" : "응답 데이터"
+ },
+ "success" : {
+ "type" : "boolean",
+ "description" : "응답 성공 여부"
+ },
+ "message" : {
+ "type" : "string",
+ "description" : "응답 메시지"
+ }
+ }
+ },
+ "api-v1-test486549215" : {
+ "type" : "object"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/ServerApplicationTests.java b/src/test/java/com/soptie/server/ServerApplicationTests.java
deleted file mode 100644
index f6bbae6b..00000000
--- a/src/test/java/com/soptie/server/ServerApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.soptie.server;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class ServerApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java b/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java
new file mode 100644
index 00000000..4f5d6f7c
--- /dev/null
+++ b/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java
@@ -0,0 +1,208 @@
+package com.soptie.server.auth.controller;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.auth.dto.SignInRequest;
+import com.soptie.server.auth.dto.SignInResponse;
+import com.soptie.server.auth.dto.TokenResponse;
+import com.soptie.server.auth.vo.Token;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.member.entity.SocialType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.net.URI;
+import java.security.Principal;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static com.soptie.server.auth.message.SuccessMessage.*;
+import static com.soptie.server.common.dto.Response.success;
+import static org.mockito.Mockito.when;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.JsonFieldType.STRING;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(AuthController.class)
+class AuthControllerTest extends BaseControllerTest {
+
+ @MockBean
+ AuthController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/auth";
+ private final String TAG = "AUTH";
+
+ @Test
+ @DisplayName("소셜 로그인 성공")
+ void success_getTokenBySocialAccessToken() throws Exception {
+ // given
+ String socialAccessToken = "Bearer softietoken";
+ SignInRequest request = SignInRequest.of(SocialType.KAKAO);
+ SignInResponse response = SignInResponse.of(
+ Token.builder()
+ .accessToken("softie")
+ .refreshToken("token")
+ .build(), false
+ );
+ ResponseEntity result = ResponseEntity.ok(success(SUCCESS_SIGN_IN.getMessage(), response));
+
+ // when
+ when(controller.signIn(socialAccessToken, request)).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ post(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .header("Authorization", socialAccessToken)
+ .content(objectMapper.writeValueAsString(request)))
+ .andDo(
+ document(
+ "post-token-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("소셜 로그인")
+ .requestHeaders(
+ headerWithName("Authorization").description("Social Access Token")
+ )
+ .requestFields(
+ fieldWithPath("socialType").type(STRING).description("소셜 종류"))
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.accessToken").type(STRING).description("Access Token"),
+ fieldWithPath("data.refreshToken").type(STRING).description("Refresh Token"),
+ fieldWithPath("data.isMemberDollExist").type(BOOLEAN).description("프로필 존재 여부")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("토큰 재발급 성공")
+ void success_reissueToken() throws Exception {
+ // given
+ String refreshToken = "refreshToken";
+ String accessToken = "accessToken";
+ TokenResponse response = TokenResponse.of(accessToken);
+ ResponseEntity result = ResponseEntity.ok(success(SUCCESS_RECREATE_TOKEN.getMessage(), response));
+
+ // when
+ when(controller.reissueToken(refreshToken)).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ post(DEFAULT_URL + "/token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .header("Authorization", refreshToken)
+ )
+ .andDo(
+ document(
+ "post-recreate-token-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("토큰 재발급")
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.accessToken").type(STRING).description("Access Token")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("로그아웃 성공")
+ void success_signOut() throws Exception {
+ // given
+ ResponseEntity result = ResponseEntity.ok(success(SUCCESS_SIGN_OUT.getMessage(), null));
+
+ // when
+ when(controller.signOut(principal)).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ post(DEFAULT_URL + "/logout")
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal)
+ )
+ .andDo(
+ document(
+ "post-logout-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("로그아웃")
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("회원탈퇴 성공")
+ void success_withdrawal() throws Exception {
+ // given
+ ResponseEntity result = ResponseEntity.ok(success(SUCCESS_WITHDRAWAL.getMessage()));
+
+ // when
+ when(controller.withdrawal(principal)).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ delete(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal)
+ )
+ .andDo(
+ document(
+ "delete-withdrawal-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원탈퇴")
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/src/test/java/com/soptie/server/base/BaseControllerTest.java b/src/test/java/com/soptie/server/base/BaseControllerTest.java
new file mode 100644
index 00000000..010901fa
--- /dev/null
+++ b/src/test/java/com/soptie/server/base/BaseControllerTest.java
@@ -0,0 +1,50 @@
+package com.soptie.server.base;
+
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.restdocs.RestDocumentationContextProvider;
+import org.springframework.restdocs.RestDocumentationExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.filter.CharacterEncodingFilter;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.soptie.server.auth.jwt.CustomJwtAuthenticationEntryPoint;
+import com.soptie.server.auth.jwt.JwtTokenProvider;
+
+@AutoConfigureMockMvc
+@AutoConfigureRestDocs
+@ExtendWith({RestDocumentationExtension.class})
+@WebMvcTest(properties = "spring.config.location=classpath:/application.yml")
+public abstract class BaseControllerTest {
+
+ @Autowired
+ protected WebApplicationContext context;
+ @Autowired
+ protected ObjectMapper objectMapper;
+ @Autowired
+ protected MockMvc mockMvc;
+
+ @MockBean
+ private JwtTokenProvider jwtTokenProvider;
+ @MockBean
+ private CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
+
+ @BeforeEach
+ void setUp(final RestDocumentationContextProvider restDocumentation) {
+ mockMvc = MockMvcBuilders.webAppContextSetup(context)
+ .apply(documentationConfiguration(restDocumentation))
+ .addFilters(new CharacterEncodingFilter("UTF-8", true))
+ .alwaysDo(print())
+ .build();
+ }
+}
diff --git a/src/test/java/com/soptie/server/base/BaseRepositoryTest.java b/src/test/java/com/soptie/server/base/BaseRepositoryTest.java
new file mode 100644
index 00000000..84db856a
--- /dev/null
+++ b/src/test/java/com/soptie/server/base/BaseRepositoryTest.java
@@ -0,0 +1,11 @@
+package com.soptie.server.base;
+
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.context.annotation.Import;
+
+import com.soptie.server.config.TestJpaQueryFactoryConfig;
+
+@Import(TestJpaQueryFactoryConfig.class)
+@DataJpaTest
+public abstract class BaseRepositoryTest {
+}
diff --git a/src/test/java/com/soptie/server/base/BaseServiceTest.java b/src/test/java/com/soptie/server/base/BaseServiceTest.java
new file mode 100644
index 00000000..f1d2ce93
--- /dev/null
+++ b/src/test/java/com/soptie/server/base/BaseServiceTest.java
@@ -0,0 +1,8 @@
+package com.soptie.server.base;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public abstract class BaseServiceTest {
+}
diff --git a/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java b/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java
new file mode 100644
index 00000000..e62ff56d
--- /dev/null
+++ b/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java
@@ -0,0 +1,23 @@
+package com.soptie.server.config;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+
+@EnableJpaAuditing
+@TestConfiguration
+public class TestJpaQueryFactoryConfig {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Bean
+ JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
diff --git a/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java b/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java
new file mode 100644
index 00000000..5d9c0f7b
--- /dev/null
+++ b/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java
@@ -0,0 +1,78 @@
+package com.soptie.server.doll.controller;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.doll.dto.DollImageResponse;
+import com.soptie.server.doll.entity.DollType;
+import com.soptie.server.doll.fixture.DollFixture;
+
+@WebMvcTest(DollController.class)
+class DollControllerTest extends BaseControllerTest {
+
+ @MockBean
+ DollController controller;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/dolls";
+ private final String TAG = "DOLL";
+
+ @Test
+ @DisplayName("인형 이미지 조회 성공")
+ void success_getDollImage() throws Exception {
+ // given
+ DollType dollType = DollType.BROWN;
+ DollImageResponse dollImage = DollFixture.createDollImageResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("인형 이미지 조회 성공", dollImage));
+
+ // when
+ when(controller.getDollImages(dollType)).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/image/{type}", dollType)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-doll-image-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("인형 이미지 조회")
+ .pathParameters(
+ parameterWithName("type").description("인형 타입")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.faceImageUrl").type(STRING).description("인형 얼굴 이미지 url")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/doll/fixture/DollFixture.java b/src/test/java/com/soptie/server/doll/fixture/DollFixture.java
new file mode 100644
index 00000000..b27e7c2b
--- /dev/null
+++ b/src/test/java/com/soptie/server/doll/fixture/DollFixture.java
@@ -0,0 +1,12 @@
+package com.soptie.server.doll.fixture;
+
+import com.soptie.server.doll.dto.DollImageResponse;
+
+public class DollFixture {
+
+ private static final String FACE_IMAGE_URL = "face-image-url";
+
+ public static DollImageResponse createDollImageResponseDTO() {
+ return new DollImageResponse(FACE_IMAGE_URL);
+ }
+}
diff --git a/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java b/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java
new file mode 100644
index 00000000..ecd3d6b3
--- /dev/null
+++ b/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java
@@ -0,0 +1,181 @@
+package com.soptie.server.member.controller;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.member.dto.CottonCountResponse;
+import com.soptie.server.member.dto.MemberProfileRequest;
+import com.soptie.server.member.dto.MemberHomeInfoResponse;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.List;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static com.soptie.server.common.dto.Response.success;
+import static com.soptie.server.doll.entity.DollType.BROWN;
+import static com.soptie.server.member.entity.CottonType.DAILY;
+import static com.soptie.server.member.message.SuccessMessage.SUCCESS_CREATE_PROFILE;
+import static com.soptie.server.member.message.SuccessMessage.SUCCESS_GIVE_COTTON;
+import static com.soptie.server.member.message.SuccessMessage.*;
+import static org.mockito.Mockito.when;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.JsonFieldType.STRING;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(MemberController.class)
+class MemberControllerTest extends BaseControllerTest {
+
+ @MockBean
+ MemberController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/members";
+ private final String TAG = "MEMBER";
+
+ @Test
+ @DisplayName("프로필을 생성한다.")
+ void success_createMemberProfile() throws Exception {
+ // given
+ MemberProfileRequest request = new MemberProfileRequest(BROWN, "소프티", List.of(1L, 2L, 3L));
+ ResponseEntity response = ResponseEntity.created(URI.create("redirect_uri"))
+ .body(success(SUCCESS_CREATE_PROFILE.getMessage(), null));
+
+ // when
+ when(controller.createMemberProfile(principal, request)).thenReturn(response);
+
+ // then
+ mockMvc.perform(post(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal)
+ .content(objectMapper.writeValueAsString(request)))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "post-member-profile-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("프로필 생성")
+ .requestFields(
+ fieldWithPath("dollType").type(STRING).description("인형 종류"),
+ fieldWithPath("name").type(STRING).description("인형 이름"),
+ fieldWithPath("routines").type(ARRAY).description("인형 종류")
+ )
+ .responseHeaders(
+ headerWithName("Location").description("Redirect URI")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build())))
+ .andExpect(status().isCreated());
+ }
+
+ @Test
+ @DisplayName("홈 화면을 불러온다.")
+ void success_getMemberHomeInfo() throws Exception {
+ // given
+ MemberHomeInfoResponse response = MemberHomeInfoResponse.builder()
+ .name("softie")
+ .dollType(BROWN)
+ .frameImageUrl("https://...")
+ .dailyCottonCount(0)
+ .happinessCottonCount(0)
+ .conversations(List.of("안녕", "하이", "봉쥬르"))
+ .build();
+ ResponseEntity result = ResponseEntity.ok((success(SUCCESS_HOME_INFO.getMessage(), response)));
+
+ // when
+ when(controller.getMemberHomeInfo(principal)).thenReturn(result);
+
+ // then
+ mockMvc.perform(get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-member-home-screen-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("홈 화면 불러오기")
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.name").type(STRING).description("인형 이름"),
+ fieldWithPath("data.dollType").type(STRING).description("인형 종류"),
+ fieldWithPath("data.frameImageUrl").type(STRING).description("인형 배경 이미지 url"),
+ fieldWithPath("data.dailyCottonCount").type(NUMBER).description("솜뭉치 개수"),
+ fieldWithPath("data.happinessCottonCount").type(NUMBER).description("행운 솜뭉치 개수"),
+ fieldWithPath("data.conversations").type(ARRAY).description("인형 대화 리스트")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("솜뭉치 주기")
+ void success_giveCotton() throws Exception {
+ // given
+ CottonCountResponse response = CottonCountResponse.of(0);
+ ResponseEntity result = ResponseEntity.ok(Response.success(SUCCESS_GIVE_COTTON.getMessage(), response));
+
+ // when
+ when(controller.giveCotton(principal, DAILY)).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ patch(DEFAULT_URL + "/cotton/{cottonType}", DAILY)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "give-cotton-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("솜뭉치 주기")
+ .pathParameters(
+ parameterWithName("cottonType").description("솜뭉치 종류")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.cottonCount").type(NUMBER).description("남은 솜뭉치 개수")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java b/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java
new file mode 100644
index 00000000..06cbb312
--- /dev/null
+++ b/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java
@@ -0,0 +1,220 @@
+package com.soptie.server.memberRoutine.controller;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.*;
+import static com.soptie.server.common.dto.Response.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.http.MediaType.*;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse;
+import com.soptie.server.memberRoutine.fixture.MemberDailyRoutineFixture;
+
+@WebMvcTest(MemberDailyRoutineController.class)
+class MemberDailyRoutineControllerTest extends BaseControllerTest {
+
+ @MockBean
+ MemberDailyRoutineController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/routines/daily/member";
+ private final String TAG = "MEMBER DAILY ROUTINE";
+
+ @Test
+ @DisplayName("회원 데일리 루틴 추가 성공")
+ void success_createMemberDailyRoutine() throws Exception {
+ // given
+ MemberDailyRoutineRequest request = new MemberDailyRoutineRequest(1L);
+ MemberDailyRoutineResponse savedMemberRoutine = MemberDailyRoutineFixture.createMemberDailyRoutineResponseDTO();
+ ResponseEntity response = ResponseEntity
+ .created(URI.create("redirect_uri"))
+ .body(success("루틴 추가 성공", savedMemberRoutine));
+
+ // when
+ when(controller.createMemberDailyRoutine(principal, request)).thenReturn(response);
+
+ // then
+ mockMvc.perform(post(DEFAULT_URL)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal)
+ .content(objectMapper.writeValueAsString(request))
+ )
+ .andDo(
+ document("post-member-daily-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 데일리 루틴 추가 성공")
+ .requestFields(
+ fieldWithPath("routineId").type(NUMBER).description("추가할 루틴 id")
+ )
+ .responseHeaders(
+ headerWithName("Location").description("Redirect URI")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routineId").type(NUMBER).description("생성한 루틴 id")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isCreated());
+ }
+
+ @Test
+ @DisplayName("회원 데일리 루틴 삭제")
+ void success_deleteMemberDailyRoutines() throws Exception {
+ // given
+ ResponseEntity response = ResponseEntity.ok(success("루틴 삭제 성공"));
+ MultiValueMap queries = new LinkedMultiValueMap<>();
+ String routines = "1,2,3";
+ queries.add("routines", routines);
+
+ // when
+ when(controller.deleteMemberDailyRoutines(principal, List.of(1L, 2L, 3L))).thenReturn(response);
+
+ // then
+ mockMvc.perform(delete(DEFAULT_URL)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal)
+ .queryParams(queries))
+ .andDo(
+ document("delete-member-daily-routines-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 데일리 루틴 삭제")
+ .queryParameters(
+ parameterWithName("routines").description("삭제할 루틴 id 리스트")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("루틴 달성 성공")
+ void success_achieveMemberDailyRoutine() throws Exception {
+ // given
+ Long routineId = 1L;
+ AchievedMemberDailyRoutineResponse memberRoutine = new AchievedMemberDailyRoutineResponse(
+ routineId, true, 1);
+ ResponseEntity response = ResponseEntity.ok(success("루틴 달성 성공", memberRoutine));
+
+ // when
+ when(controller.achieveMemberDailyRoutine(principal, routineId)).thenReturn(response);
+
+ // then
+ mockMvc.perform(patch(DEFAULT_URL + "/routine/{routineId}", routineId)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ document("achieve-member-daily-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 데일리 루틴 달성 성공")
+ .pathParameters(
+ parameterWithName("routineId").description("루틴 id")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routineId").type(NUMBER).description("루틴 id"),
+ fieldWithPath("data.isAchieve").type(BOOLEAN).description("달성 여부"),
+ fieldWithPath("data.achieveCount").type(NUMBER).description("달성 횟수")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("회원 별 데일리 루틴 리스트 조회 성공")
+ void success_getMemberDailyRoutines() throws Exception {
+ // given
+ MemberDailyRoutinesResponse routines = MemberDailyRoutineFixture.createMemberDailyRoutinesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines));
+
+ // when
+ when(controller.getMemberDailyRoutines(principal)).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-member-daily-routines-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 별 데일리 루틴 리스트 조회")
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"),
+ fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"),
+ fieldWithPath("data.routines[].content").type(STRING).description("테마 내용"),
+ fieldWithPath("data.routines[].iconImageUrl").type(STRING).description("아이콘 이미지 url"),
+ fieldWithPath("data.routines[].achieveCount").type(NUMBER).description("달성 횟수"),
+ fieldWithPath("data.routines[].isAchieve").type(BOOLEAN).description("달성 여부")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java b/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java
new file mode 100644
index 00000000..9a9db831
--- /dev/null
+++ b/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java
@@ -0,0 +1,205 @@
+package com.soptie.server.memberRoutine.controller;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse;
+import com.soptie.server.memberRoutine.fixture.MemberHappinessRoutineFixture;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+
+import java.net.URI;
+import java.security.Principal;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static com.soptie.server.common.dto.Response.success;
+import static org.mockito.Mockito.when;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(MemberHappinessRoutineController.class)
+class MemberHappinessRoutineControllerTest extends BaseControllerTest {
+
+ @MockBean
+ MemberHappinessRoutineController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/routines/happiness/member";
+ private final String TAG = "MEMBER HAPPINESS ROUTINE";
+
+ @Test
+ @DisplayName("회원 별 행복 루틴 조회 성공")
+ void success_getMemberHappinessRoutines() throws Exception {
+ // given
+ MemberHappinessRoutinesResponse routines = MemberHappinessRoutineFixture.createMemberHappinessRoutinesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines));
+
+ // when
+ when(controller.getMemberHappinessRoutine(principal)).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-member-happiness-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 별 행복 루틴 리스트 조회")
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routineId").type(NUMBER).description("루틴 id"),
+ fieldWithPath("data.iconImageUrl").type(STRING).description("아이콘 이미지 url"),
+ fieldWithPath("data.contentImageUrl").type(STRING).description("카드 이미지 url"),
+ fieldWithPath("data.themeName").type(STRING).description("테마 이름"),
+ fieldWithPath("data.themeNameColor").type(STRING).description("테마 이름 색상"),
+ fieldWithPath("data.title").type(STRING).description("루틴 제목"),
+ fieldWithPath("data.content").type(STRING).description("루틴 내용"),
+ fieldWithPath("data.detailContent").type(STRING).description("루틴 상세 내용"),
+ fieldWithPath("data.place").type(STRING).description("장소"),
+ fieldWithPath("data.timeTaken").type(STRING).description("소요 시간")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("회원 행복 루틴 추가 성공")
+ void success_createMemberHappinessRoutine() throws Exception {
+
+ MemberHappinessRoutineRequest request = new MemberHappinessRoutineRequest(1L);
+ MemberHappinessRoutineResponse savedMemberRoutine = MemberHappinessRoutineFixture.createMemberHappinessRoutineResponseDTO();
+ ResponseEntity response = ResponseEntity
+ .created(URI.create("redirect_uri"))
+ .body(success("루틴 추가 성공", savedMemberRoutine));
+
+ when(controller.createMemberHappinessRoutine(principal, request)).thenReturn(response);
+
+ mockMvc.perform(post(DEFAULT_URL)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal)
+ .content(objectMapper.writeValueAsString(request))
+ )
+ .andDo(
+ document("post-member-happiness-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 행복 루틴 추가 성공")
+ .requestFields(
+ fieldWithPath("subRoutineId").type(NUMBER).description("추가할 루틴 id")
+ )
+ .responseHeaders(
+ headerWithName("Location").description("Redirect URI")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routineId").type(NUMBER).description("생성한 루틴 id")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isCreated());
+ }
+
+ @Test
+ @DisplayName("회원 행복 루틴 삭제 성공")
+ void success_deleteMemberHappinessRoutine() throws Exception {
+
+ Long routineId = 1L;
+ ResponseEntity response = ResponseEntity.ok(success("루틴 삭제 성공"));
+
+ when(controller.deleteMemberHappinessRoutine(principal, routineId)).thenReturn(response);
+
+ mockMvc.perform(delete(DEFAULT_URL + "/routine/{routineId}", routineId)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ document("delete-member-happiness-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 행복 루틴 삭제 성공")
+ .pathParameters(
+ parameterWithName("routineId").description("루틴 id")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("회원 행복 루틴 달성 성공")
+ void success_achieveMemberHappinessRoutine() throws Exception {
+
+ Long routineId = 1L;
+ ResponseEntity response = ResponseEntity.ok(success("회원 행복 루틴 달성 성공"));
+
+ when(controller.achieveMemberHappinessRoutine(principal, routineId)).thenReturn(response);
+
+ mockMvc.perform(patch(DEFAULT_URL + "/routine/{routineId}", routineId)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ document("achieve-member-happiness-routine-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("회원 행복 루틴 달성 성공")
+ .pathParameters(
+ parameterWithName("routineId").description("루틴 id")
+ )
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(NULL).description("응답 데이터")
+ )
+ .build()
+ )
+ ))
+ .andExpect(status().isOk());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java
new file mode 100644
index 00000000..69f4453c
--- /dev/null
+++ b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java
@@ -0,0 +1,35 @@
+package com.soptie.server.memberRoutine.fixture;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse;
+
+public class MemberDailyRoutineFixture {
+
+
+ public static MemberDailyRoutineResponse createMemberDailyRoutineResponseDTO() {
+ return new MemberDailyRoutineResponse(1L);
+ }
+
+ public static MemberDailyRoutinesResponse createMemberDailyRoutinesResponseDTO() {
+ return new MemberDailyRoutinesResponse(createDailyRoutineResponses());
+ }
+
+ private static List createDailyRoutineResponses() {
+ return Stream.iterate(1, i -> i + 1).limit(5)
+ .map(MemberDailyRoutineFixture::createDailyRoutineResponse)
+ .toList();
+ }
+
+ private static MemberDailyRoutinesResponse.MemberDailyRoutineResponse createDailyRoutineResponse(int i) {
+ return MemberDailyRoutinesResponse.MemberDailyRoutineResponse.builder()
+ .routineId(i)
+ .content("routine content" + i)
+ .iconImageUrl("https://...")
+ .achieveCount(i)
+ .isAchieve(i % 2 == 0)
+ .build();
+ }
+}
diff --git a/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java
new file mode 100644
index 00000000..2e4aa2b5
--- /dev/null
+++ b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java
@@ -0,0 +1,37 @@
+package com.soptie.server.memberRoutine.fixture;
+
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse;
+import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse;
+
+public class MemberHappinessRoutineFixture {
+
+ private static final String CONTENT = "routine-content";
+ private static final String DETAIL_CONTENT = "routine-detail-content";
+ private static final String TIME_TAKEN = "5~10분 소요";
+ private static final String PLACE = "루틴 장소";
+ private static final String TITLE = "루틴 제목";
+ private static final String NAME = "태마 이름";
+ private static final String NAME_COLOR = "#ffcdc5";
+ private static final String ICON_IMAGE_URL = "icon-image-url";
+ private static final String CONTENT_IMAGE_URL = "content-image-url";
+
+ public static MemberHappinessRoutinesResponse createMemberHappinessRoutinesResponseDTO() {
+ return MemberHappinessRoutinesResponse.builder()
+ .routineId(1L)
+ .iconImageUrl(ICON_IMAGE_URL)
+ .contentImageUrl(CONTENT_IMAGE_URL)
+ .themeName(NAME)
+ .themeNameColor(NAME_COLOR)
+ .title(TITLE)
+ .content(CONTENT)
+ .detailContent(DETAIL_CONTENT)
+ .place(PLACE)
+ .timeTaken(TIME_TAKEN)
+ .build();
+
+ }
+
+ public static MemberHappinessRoutineResponse createMemberHappinessRoutineResponseDTO() {
+ return new MemberHappinessRoutineResponse(1L);
+ }
+}
diff --git a/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java b/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java
new file mode 100644
index 00000000..93e926ef
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java
@@ -0,0 +1,176 @@
+package com.soptie.server.routine.controller;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse;
+import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse;
+import com.soptie.server.routine.dto.DailyThemesResponse;
+import com.soptie.server.routine.fixture.DailyRoutineFixture;
+
+@WebMvcTest(DailyRoutineController.class)
+class DailyRoutineControllerTest extends BaseControllerTest {
+
+ @MockBean
+ DailyRoutineController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/routines/daily";
+ private final String TAG = "DAILY ROUTINE";
+
+ @Test
+ @DisplayName("데일리 루틴 테마 리스트 조회 성공")
+ void success_getDailyThemes() throws Exception {
+ // given
+ DailyThemesResponse themes = DailyRoutineFixture.createDailyThemesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("테마 조회 성공", themes));
+
+ // when
+ when(controller.getThemes()).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/themes")
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-daily-themes-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("데일리 루틴 테마 리스트 조회")
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.themes").type(ARRAY).description("테마 정보 리스트"),
+ fieldWithPath("data.themes[].themeId").type(NUMBER).description("테마 id"),
+ fieldWithPath("data.themes[].name").type(STRING).description("테마 이름"),
+ fieldWithPath("data.themes[].iconImageUrl").type(STRING).description("아이콘 이미지 url"),
+ fieldWithPath("data.themes[].backgroundImageUrl").type(STRING).description("배경 이미지 url")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("테마 리스트 별 데일리 루틴 리스트 조회 성공")
+ void success_getDailyRoutinesByThemes() throws Exception {
+ // given
+ DailyRoutinesByThemesResponse routines = DailyRoutineFixture.createDailyRoutinesByThemesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines));
+
+ MultiValueMap queries = new LinkedMultiValueMap<>();
+ String themes = "1,2,3";
+ queries.add("themes", themes);
+
+ // when
+ when(controller.getRoutinesByThemes(List.of(1L, 2L, 3L))).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .params(queries))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-themes-daily-routines-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("테마 리스트 별 데일리 루틴 리스트 조회")
+ .queryParameters(
+ parameterWithName("themes").description("조회할 테마 id 정보")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"),
+ fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"),
+ fieldWithPath("data.routines[].content").type(STRING).description("테마 내용")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("테마 별 데일리 루틴 리스트 조회 성공")
+ void success_getDailyRoutinesByTheme() throws Exception {
+ // given
+ long themeId = 1L;
+ DailyRoutinesByThemeResponse routines = DailyRoutineFixture.createDailyRoutinesByThemeResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines));
+
+ // when
+ when(controller.getRoutinesByTheme(principal, themeId)).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/theme/{themeId}", 1L)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .principal(principal))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-theme-daily-routines-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("테마 별 데일리 루틴 리스트 조회")
+ .pathParameters(
+ parameterWithName("themeId").description("테마 id")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.backgroundImageUrl").type(STRING).description("테마 배경 이미지 url"),
+ fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"),
+ fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"),
+ fieldWithPath("data.routines[].content").type(STRING).description("테마 내용")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java b/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java
new file mode 100644
index 00000000..181a5e0f
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java
@@ -0,0 +1,192 @@
+package com.soptie.server.routine.controller;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.routine.dto.HappinessRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessSubRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessThemesResponse;
+import com.soptie.server.routine.fixture.HappinessRoutineFixture;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import java.util.List;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static com.soptie.server.routine.message.SuccessMessage.SUCCESS_GET_HAPPINESS_SUB_ROUTINES;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(HappinessRoutineController.class)
+class HappinessRoutineControllerTest extends BaseControllerTest {
+
+ @MockBean
+ HappinessRoutineController controller;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/routines/happiness";
+ private final String TAG = "HAPPINESS ROUTINE";
+
+ @Test
+ @DisplayName("행복 루틴 테마 리스트 조회 성공")
+ void success_getHappinessThemes() throws Exception {
+ // given
+ HappinessThemesResponse themes = HappinessRoutineFixture.createHappinessThemesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("테마 조회 성공", themes));
+
+ // when
+ when(controller.getHappinessThemes()).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/themes")
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-happiness-themes-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("행복 루틴 테마 리스트 조회")
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.themes").type(ARRAY).description("테마 정보 리스트"),
+ fieldWithPath("data.themes[].themeId").type(NUMBER).description("테마 id"),
+ fieldWithPath("data.themes[].name").type(STRING).description("테마 이름")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("테마 별 행복 루틴 리스트 조회 성공")
+ void success_getHappinessRoutinesByTheme() throws Exception {
+ HappinessRoutinesResponse routines = HappinessRoutineFixture.createHappinessRoutinesResponseDTO();
+ ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines));
+
+ MultiValueMap queries = new LinkedMultiValueMap<>();
+ String themes = "1";
+ queries.add("themeId", themes);
+
+ when(controller.getHappinessRoutinesByThemes(anyLong())).thenReturn(response);
+
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .params(queries))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-theme-happiness-routines-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("테마 별 행복 루틴 리스트 조회")
+ .queryParameters(
+ parameterWithName("themeId").description("조회할 테마 id")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"),
+ fieldWithPath("data.routines[].routineId").type(NUMBER).description("행복 루틴 id"),
+ fieldWithPath("data.routines[].name").type(STRING).description("행복 루틴 제목"),
+ fieldWithPath("data.routines[].nameColor").type(STRING).description("행복 루틴 제목 색상"),
+ fieldWithPath("data.routines[].title").type(STRING).description("행복 루틴 설명"),
+ fieldWithPath("data.routines[].iconImageUrl").type(STRING).description("이미지 url")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("행복 루틴 별 서브 행복 루틴 리스트 조회 성공")
+ void success_getHappinessSubRoutinesByRoutineOfTheme() throws Exception {
+ // given
+ List subRoutines = List.of(
+ HappinessSubRoutinesResponse.HappinessSubRoutineResponse.builder()
+ .subRoutineId(1L)
+ .content("소프티 숙소")
+ .detailContent("어쩌구 저쩌구")
+ .timeTaken("아무때")
+ .place("소프티 만나러 가는 길")
+ .build());
+ HappinessSubRoutinesResponse response = HappinessSubRoutinesResponse.builder()
+ .title("타이틀")
+ .name("테마 이름")
+ .nameColor("#999")
+ .iconImageUrl("https://...")
+ .contentImageUrl("https://...")
+ .subRoutines(subRoutines)
+ .build();
+ ResponseEntity result = ResponseEntity.ok(Response.success(SUCCESS_GET_HAPPINESS_SUB_ROUTINES.getMessage(), response));
+
+ // when
+ when(controller.getHappinessSubRoutinesByRoutineOfTheme(anyLong())).thenReturn(result);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/routine/{routineId}", 1L)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-sub-happiness-routines-by-routine-of-theme-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("행복 루틴 별 서브 행복 루틴 리스트 조회")
+ .pathParameters(
+ parameterWithName("routineId").description("루틴 id")
+ )
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.title").type(STRING).description("행복 루틴 주제"),
+ fieldWithPath("data.name").type(STRING).description("테마 이름"),
+ fieldWithPath("data.nameColor").type(STRING).description("색깔"),
+ fieldWithPath("data.iconImageUrl").type(STRING).description("아이콘 이미지 url"),
+ fieldWithPath("data.contentImageUrl").type(STRING).description("카드 이미지 url"),
+ fieldWithPath("data.subRoutines").type(ARRAY).description("서브 루틴 정보 리스트"),
+ fieldWithPath("data.subRoutines[].subRoutineId").type(NUMBER).description("서브 루틴 id"),
+ fieldWithPath("data.subRoutines[].content").type(STRING).description("서브 루틴 내용"),
+ fieldWithPath("data.subRoutines[].detailContent").type(STRING).description("서브 루틴 세부 내용"),
+ fieldWithPath("data.subRoutines[].timeTaken").type(STRING).description("소요 시간"),
+ fieldWithPath("data.subRoutines[].place").type(STRING).description("장소")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java b/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java
new file mode 100644
index 00000000..9bcf751d
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java
@@ -0,0 +1,47 @@
+package com.soptie.server.routine.fixture;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import com.soptie.server.routine.dto.DailyRoutineResponse;
+import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse;
+import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse;
+import com.soptie.server.routine.dto.DailyThemesResponse;
+
+public class DailyRoutineFixture {
+
+ public static DailyThemesResponse createDailyThemesResponseDTO() {
+ return new DailyThemesResponse(createDailyThemeResponses());
+ }
+
+ private static List createDailyThemeResponses() {
+ return Stream.iterate(1, i -> i + 1).limit(5)
+ .map(DailyRoutineFixture::createDailyThemeResponse).toList();
+ }
+
+ private static DailyThemesResponse.DailyThemeResponse createDailyThemeResponse(int i) {
+ return DailyThemesResponse.DailyThemeResponse.builder()
+ .themeId(i)
+ .name("theme name" + i)
+ .iconImageUrl("https://...")
+ .backgroundImageUrl("https://...")
+ .build();
+ }
+
+ public static DailyRoutinesByThemesResponse createDailyRoutinesByThemesResponseDTO() {
+ return new DailyRoutinesByThemesResponse(createDailyRoutineResponses());
+ }
+
+ public static DailyRoutinesByThemeResponse createDailyRoutinesByThemeResponseDTO() {
+ return new DailyRoutinesByThemeResponse("https://...", createDailyRoutineResponses());
+ }
+
+ private static List createDailyRoutineResponses() {
+ return Stream.iterate(1, i -> i + 1).limit(5)
+ .map(DailyRoutineFixture::createDailyRoutineResponse).toList();
+ }
+
+ private static DailyRoutineResponse createDailyRoutineResponse(int i) {
+ return new DailyRoutineResponse((long)i, "routine content" + i);
+ }
+}
diff --git a/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java b/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java
new file mode 100644
index 00000000..5ef9b5b0
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java
@@ -0,0 +1,44 @@
+package com.soptie.server.routine.fixture;
+
+import com.soptie.server.routine.dto.HappinessRoutinesResponse;
+import com.soptie.server.routine.dto.HappinessThemesResponse;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+public class HappinessRoutineFixture {
+
+ private static final String NAME = "Happiness routine Theme";
+ private static final String NAME_COLOR = "Happiness routine Theme";
+ private static final String TITLE = "Happiness routine Theme";
+ private static final String ICON_IMAGE_URL = "icon_image_url";
+
+
+ public static HappinessThemesResponse createHappinessThemesResponseDTO() {
+ List routines = createHappinessThemeResponses();
+ return new HappinessThemesResponse(routines);
+ }
+
+ private static List createHappinessThemeResponses() {
+ return Stream.iterate(1, i -> i + 1).limit(5)
+ .map(HappinessRoutineFixture::createHappinessThemeResponse).toList();
+ }
+
+ private static HappinessThemesResponse.HappinessThemeResponse createHappinessThemeResponse(int i) {
+ return new HappinessThemesResponse.HappinessThemeResponse((long)i, NAME + i);
+ }
+
+ public static HappinessRoutinesResponse createHappinessRoutinesResponseDTO() {
+ List routines = createHappinessRoutineResponses();
+ return new HappinessRoutinesResponse(routines);
+ }
+
+ private static List createHappinessRoutineResponses() {
+ return Stream.iterate(1, i -> i + 1).limit(5)
+ .map(HappinessRoutineFixture::createHappinessRoutineResponse).toList();
+ }
+
+ private static HappinessRoutinesResponse.HappinessRoutineResponse createHappinessRoutineResponse(int i) {
+ return new HappinessRoutinesResponse.HappinessRoutineResponse((long)i, NAME + i, NAME_COLOR + i, TITLE + i, ICON_IMAGE_URL + i);
+ }
+}
diff --git a/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java b/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java
new file mode 100644
index 00000000..1200ef8f
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java
@@ -0,0 +1,109 @@
+package com.soptie.server.routine.repository.daily.routine;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import com.soptie.server.base.BaseRepositoryTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+import com.soptie.server.routine.entity.daily.RoutineImage;
+import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository;
+
+import static org.hamcrest.MatcherAssert.*;
+import static org.hamcrest.CoreMatchers.*;
+
+class DailyRoutineRepositoryTest extends BaseRepositoryTest {
+
+ @Autowired
+ private DailyRoutineRepository dailyRoutineRepository;
+ @Autowired
+ private DailyThemeRepository dailyThemeRepository;
+
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String ROUTINE_CONTENT = "오늘의 음악 선곡하기";
+ private final DailyTheme THEME = getTheme();
+
+ @BeforeEach
+ void init() {
+ dailyThemeRepository.save(THEME);
+ }
+
+ @DisplayName("데일리 루틴 저장")
+ @Test
+ void success_createRoutine() {
+ // given
+ int i = 0;
+ DailyRoutine routine = getRoutine(i);
+
+ // when
+ DailyRoutine savedRoutine = dailyRoutineRepository.save(routine);
+
+ // then
+ assertThat(savedRoutine.getContent(), is(equalTo(ROUTINE_CONTENT + i)));
+ assertThat(savedRoutine.getTheme(), is(equalTo(THEME)));
+ }
+
+ @DisplayName("데일리 루틴 조회")
+ @Test
+ void success_getRoutine() {
+ // given
+ int i = 0;
+ DailyRoutine savedRoutine = dailyRoutineRepository.save(getRoutine(i));
+
+ // when
+ Optional routine = dailyRoutineRepository.findById(savedRoutine.getId());
+
+ // then
+ assertThat(routine.isPresent(), is(equalTo(true)));
+ assertThat(routine.get(), is(equalTo(savedRoutine)));
+ assertThat(routine.get().getId(), is(equalTo(savedRoutine.getId())));
+ assertThat(routine.get().getContent(), is(equalTo(savedRoutine.getContent())));
+ assertThat(routine.get().getTheme(), is(equalTo(savedRoutine.getTheme())));
+ }
+
+ @DisplayName("테마별 데일리 루틴 목록 조회")
+ @Test
+ void success_getRoutinesByTheme() {
+ // given
+ int size = 5;
+ Stream.iterate(1, i -> i + 1).limit(size)
+ .forEach(i -> dailyRoutineRepository.save(getRoutine(i)));
+
+ // when
+ List result = dailyRoutineRepository.findAllByTheme(THEME);
+
+ // then
+ assertThat(result.size(), is(equalTo(size)));
+ }
+
+ private DailyRoutine getRoutine(int i) {
+ return DailyRoutine.builder()
+ .content(ROUTINE_CONTENT + i)
+ .theme(THEME)
+ .build();
+ }
+
+ private DailyTheme getTheme() {
+ return DailyTheme.builder()
+ .name("소중한 나")
+ .imageInfo(getImageInfo())
+ .build();
+ }
+
+ private RoutineImage getImageInfo() {
+ return RoutineImage.builder()
+ .iconImageUrl("https://...")
+ .backgroundImageUrl("https://...")
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java b/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java
new file mode 100644
index 00000000..38965f78
--- /dev/null
+++ b/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java
@@ -0,0 +1,100 @@
+package com.soptie.server.routine.service;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import com.soptie.server.base.BaseServiceTest;
+import com.soptie.server.member.entity.Member;
+import com.soptie.server.member.entity.SocialType;
+import com.soptie.server.member.repository.MemberRepository;
+import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse;
+import com.soptie.server.routine.entity.daily.DailyRoutine;
+import com.soptie.server.routine.entity.daily.DailyTheme;
+import com.soptie.server.routine.entity.daily.RoutineImage;
+import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository;
+import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository;
+
+import static org.hamcrest.MatcherAssert.*;
+import static org.mockito.Mockito.*;
+import static org.hamcrest.CoreMatchers.*;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+class DailyRoutineServiceTest extends BaseServiceTest {
+
+ @InjectMocks
+ private DailyRoutineServiceImpl dailyRoutineService;
+
+ @Mock
+ private DailyRoutineRepository dailyRoutineRepository;
+ @Mock
+ private DailyThemeRepository dailyThemeRepository;
+ @Mock
+ private MemberRepository memberRepository;
+
+ private final Member MEMBER = member();
+ private final DailyTheme THEME = theme();
+ private final int LIST_SIZE = 5;
+ private final String ROUTINE_CONTENT = "오늘의 음악 선곡하기";
+
+ @DisplayName("테마별 데일리 루틴 조회")
+ @Test
+ void success_getRoutinesByTheme() {
+ // given
+ long memberId = 0L;
+ long themeId = 1L;
+ List routines = routineList();
+
+ when(memberRepository.findById(memberId)).thenReturn(Optional.of(MEMBER));
+ when(dailyThemeRepository.findById(themeId)).thenReturn(Optional.of(THEME));
+ when(dailyRoutineRepository.findAllByTheme(THEME)).thenReturn(routines);
+
+ // when
+ DailyRoutinesByThemeResponse response = dailyRoutineService.getRoutinesByTheme(memberId, themeId);
+
+ // then
+ assertThat(response.routines().size(), is(equalTo(LIST_SIZE)));
+ assertThat(response.routines().get(0).content(), is(equalTo(ROUTINE_CONTENT + 1)));
+
+ // verify
+ verify(dailyThemeRepository, times(1)).findById(themeId);
+ verify(dailyRoutineRepository, times(1)).findAllByTheme(THEME);
+ }
+
+ private List routineList() {
+ return Stream.iterate(1, i -> i + 1).limit(LIST_SIZE)
+ .map(this::routine).toList();
+ }
+
+ private DailyRoutine routine(int i) {
+ return DailyRoutine.builder()
+ .content(ROUTINE_CONTENT + i)
+ .theme(THEME)
+ .build();
+ }
+
+ private Member member() {
+ return Member.builder()
+ .socialType(SocialType.KAKAO)
+ .socialId("socialId")
+ .build();
+ }
+
+ private DailyTheme theme() {
+ return DailyTheme.builder()
+ .name("소중한 나")
+ .imageInfo(imageInfo())
+ .build();
+ }
+
+ private RoutineImage imageInfo() {
+ return RoutineImage.builder()
+ .iconImageUrl("https://...")
+ .backgroundImageUrl("https://...")
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/soptie/server/test/TestControllerTest.java b/src/test/java/com/soptie/server/test/TestControllerTest.java
new file mode 100644
index 00000000..437752d6
--- /dev/null
+++ b/src/test/java/com/soptie/server/test/TestControllerTest.java
@@ -0,0 +1,62 @@
+package com.soptie.server.test;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static org.mockito.Mockito.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+
+@WebMvcTest(TestController.class)
+public class TestControllerTest extends BaseControllerTest {
+
+ @MockBean
+ TestController testController;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/test";
+ private final String TAG = "TEST";
+
+ @Test
+ @DisplayName("서버 연결 테스트")
+ void success_test() throws Exception {
+ // given
+ ResponseEntity response = ResponseEntity.ok("Success to server connect.");
+
+ //when
+ when(testController.test()).thenReturn(response);
+
+ //then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "test-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("서버 연결 테스트")
+ .requestFields()
+ .responseFields()
+ .build())))
+ .andExpect(
+ MockMvcResultMatchers.status().isOk());
+ }
+}
diff --git a/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java b/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java
new file mode 100644
index 00000000..dd29c53c
--- /dev/null
+++ b/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java
@@ -0,0 +1,89 @@
+package com.soptie.server.version.controller;
+
+import static com.epages.restdocs.apispec.ResourceDocumentation.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.JsonFieldType.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.security.Principal;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+import com.soptie.server.base.BaseControllerTest;
+import com.soptie.server.common.config.ValueConfig;
+import com.soptie.server.common.dto.Response;
+import com.soptie.server.version.dto.AppVersionResponse;
+
+@WebMvcTest(VersionController.class)
+class VersionControllerTest extends BaseControllerTest {
+
+ @MockBean
+ VersionController controller;
+ @MockBean
+ Principal principal;
+ @MockBean
+ ValueConfig valueConfig;
+
+ private final String DEFAULT_URL = "/api/v1/versions";
+ private final String TAG = "VERSION";
+
+ @Test
+ @DisplayName("클라이언트 앱 버전 정보 조회 성공")
+ void success_getClientAppVersion() throws Exception {
+ // given
+ AppVersionResponse versionInfo = AppVersionResponse.of(
+ "1.0.0",
+ "1.0.0",
+ "1.0.0",
+ "1.0.0",
+ "업데이트 안내",
+ "업데이트가 필요합니다.");
+ ResponseEntity response = ResponseEntity.ok(Response.success("버전 조회 성공", versionInfo));
+
+ // when
+ when(controller.getClientAppVersion()).thenReturn(response);
+
+ // then
+ mockMvc
+ .perform(
+ RestDocumentationRequestBuilders.get(DEFAULT_URL + "/client/app")
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON))
+ .andDo(
+ MockMvcRestDocumentation.document(
+ "get-client-app-version-docs",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag(TAG)
+ .description("앱 버전 정보 조회")
+ .requestFields()
+ .responseFields(
+ fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
+ fieldWithPath("message").type(STRING).description("응답 메시지"),
+ fieldWithPath("data").type(OBJECT).description("응답 데이터"),
+ fieldWithPath("data.iosVersion").type(OBJECT).description("iOS 버전 정보"),
+ fieldWithPath("data.iosVersion.appVersion").type(STRING).description("iOS 앱 버전"),
+ fieldWithPath("data.iosVersion.forceUpdateVersion").type(STRING).description("iOS 강제 업데이트 버전"),
+ fieldWithPath("data.androidVersion").type(OBJECT).description("안드로이드 버전 정보"),
+ fieldWithPath("data.androidVersion.appVersion").type(STRING).description("안드로이드 앱 버전"),
+ fieldWithPath("data.androidVersion.forceUpdateVersion").type(STRING).description("안드로이드 강제 업데이트 버전"),
+ fieldWithPath("data.notificationTitle").type(STRING).description("업데이트 알림 제목"),
+ fieldWithPath("data.notificationContent").type(STRING).description("업데이트 알림 내용")
+ )
+ .build())))
+ .andExpect(status().isOk());
+ }
+
+}
\ No newline at end of file