diff --git a/.github/workflows/Diareat-CICD.yml b/.github/workflows/Diareat-CICD.yml
new file mode 100644
index 0000000..56f467f
--- /dev/null
+++ b/.github/workflows/Diareat-CICD.yml
@@ -0,0 +1,90 @@
+name: Diareat CI/CD
+
+on:
+ push:
+ branches: [ "master" ]
+ paths-ignore:
+ - 'k8s/**'
+
+permissions:
+ contents: read
+
+jobs:
+ CI:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK 11
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+
+ - name: Build with Gradle
+ uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
+ env:
+ DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }}
+ DB_USERNAME: ${{ secrets.DB_USERNAME }}
+ DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
+ with:
+ arguments: build
+
+ - name: Docker login
+ uses: docker/login-action@v3.0.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ logout: true
+
+ - name: Get version
+ id: image
+ run: |
+ VERSION=$(echo ${{ github.sha }} | cut -c1-8)
+ echo VERSION=$VERSION
+ echo "::set-output name=version::$VERSION"
+
+ - name: Build and push to DockerHub
+ run: |
+ docker build -t synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }} .
+ docker push synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }}
+
+
+ CD:
+ runs-on: ubuntu-latest
+ needs: CI
+ steps:
+ - name: Get version
+ id: image
+ run: |
+ VERSION=$(echo ${{ github.sha }} | cut -c1-8)
+ echo VERSION=$VERSION
+ echo "::set-output name=version::$VERSION"
+
+ - name: Setup Kustomize
+ uses: imranismail/setup-kustomize@v2.1.0-rc
+
+
+ - name: Checkout kustomize repository
+ uses: actions/checkout@v3
+ with:
+ repository: CAUSOLDOUTMEN/Diareat_backend
+ ref: master
+ token: ${{ secrets.ACCESS_TOKEN }}
+ path: Diareat_backend
+
+
+ - name: Update Kubernetes resources
+ run: |
+ cd Diareat_backend/k8s/
+ kustomize edit set image synoti21/${{ secrets.PROJECT_NAME }}=synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }}
+ kustomize build .
+
+ - name: Commit and push the updated manifest
+ run: |
+ cd Diareat_backend
+ git config --global user.name 'github-actions'
+ git config --global user.email 'github-actions@github.com'
+ git commit -am ":construction_worker: chore: Update deployment to ${{ github.sha }}"
+ git push
diff --git a/.github/workflows/diareat-Build.yml b/.github/workflows/diareat-Build.yml
new file mode 100644
index 0000000..8128bdb
--- /dev/null
+++ b/.github/workflows/diareat-Build.yml
@@ -0,0 +1,33 @@
+name: Diareat Build Test
+
+on:
+ pull_request:
+ branches: [ "master" ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 11
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+
+ - name: Build with Gradle
+ uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
+ env:
+ DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }}
+ DB_USERNAME: ${{ secrets.DB_USERNAME }}
+ DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
+ with:
+ arguments: build
+
diff --git a/.gitignore b/.gitignore
index c2065bc..e48b6be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,4 +34,4 @@ out/
/.nb-gradle/
### VS Code ###
-.vscode/
+.vscode/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4173c9b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,12 @@
+FROM gradle:8.2.1-jdk11 AS build
+
+COPY --chown=gradle:gradle . /home/gradle/src
+WORKDIR /home/gradle/src
+RUN gradle build --no-daemon -x test
+
+FROM openjdk:11-jre-slim
+
+EXPOSE 8080
+COPY --from=build /home/gradle/src/build/libs/*.jar /app/app.jar
+
+ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app/app.jar"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0b0f16b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2023 CAUSOLDOUTMEN
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e6649b0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,68 @@
+# 프로젝트명
+다이어릿 - Diareat (2023.09 ~ 2023.12)
+
+# Diareat, 사각지대 없는 식습관 관리를 위해!
+
+
+<다이어릿>은 음식을 촬영하면 칼로리를 대략적으로 계산해주는 기존 서비스에서 영감을 받아, 가공식품의 영양성분표를 촬영하면 칼로리를 추출해주는 OCR 기능을 추가로 결합해 식습관 기록의 사각지대를 해소하는 데 기여하는 Food Capture 서비스입니다.
+## Camera
+
+https://github.com/CAUSOLDOUTMEN/Diareat_backend/assets/53044069/f36dba64-3022-4b1b-b811-d9203fb987b5
+
+
+- 음식이나 영양성분표를 촬영하면 일정 시간 후 영양성분을 추출하실 수 있습니다.
+ - 실제 음식의 경우 두잉랩의 foodlens에서 제공하는 외부 API를 사용합니다.
+ - 영양성분표의 경우 Pororo OCR을 기반으로 자체 구축한 OCR 서버를 활용합니다.
+## Diary
+
+
+
+- [오늘의 영양] 화면에서 오늘 촬영한 음식들에 대한 영양분이 실시간으로 결산됩니다.
+- [다이어리] 화면에서 오늘 촬영한 음식들에 대한 정보를 확인할 수 있습니다.
+- [즐겨찾기] 기능을 통해 자주 먹는 음식을 등록하여 재촬영 없이 편리한 다이어리 추가가 가능합니다.
+- [프로필] 화면에서 개인정보 및 목표로 설정된 기준섭취량을 확인하고 직접 수정하실 수 있습니다.
+
+## Analysis
+
+
+- [일기 분석] 화면에서 최근 7일 및 4주 간의 영양소별 섭취 현황을 그래프로 제공합니다.
+- [자세히 보기]를 통해 이번 주의 구체적인 식습관 점수 현황, 최근 Best, Worst 음식들을 확인하실 수 있습니다.
+- [주간 랭킹] 화면에서 검색 기능을 통해 특정 유저를 팔로우 혹은 팔로우 취소할 수 있고, 자신이 팔로우한 유저들의 주간 식습관 점수를 확인하실 수 있습니다.
+- 친구들과 함께 앱을 사용하며 식습관 개선 목표를 달성하기 위해 서로 경쟁해보세요!
+
+# Project Information
+
+## Members
+
+
+## Tech Stack
+
+### Frontend
+
+- Android
+- Kotlin
+
+### Backend
+
+- Java 11
+- Springboot
+- Redis
+- MySQL
+
+### OCR Server
+
+- Python (Pororo OCR)
+- OpenCV
+- FastAPI
+- AWS EC2
+
+### Infra & CI/CD
+
+- Kubernetes
+- Argo CD
+- Kustomization
+- Github actions
+- Docker
+
+## Project **Architecture**
+
diff --git a/build.gradle b/build.gradle
index e1b0b09..9fc03f3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -22,8 +22,8 @@ repositories {
}
dependencies {
- // implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- // implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
@@ -31,10 +31,30 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
compileOnly 'org.projectlombok:lombok'
- // runtimeOnly 'com.mysql:mysql-connector-j'
+ 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'
+
+ // Swagger dependency
+ implementation 'io.springfox:springfox-boot-starter:3.0.0'
+ implementation 'io.springfox:springfox-swagger-ui:3.0.0'
+
+ // Webclient dependency
+ implementation platform('org.springframework.boot:spring-boot-dependencies:2.7.15')
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+
+ implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
+
+ // Jwt dependency
+ implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' // Spring Boot MyBatis
+ implementation "io.jsonwebtoken:jjwt:0.9.1"
+
+ // Redis dependency
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+
+ // Validation dependency
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
}
tasks.named('test') {
diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml
new file mode 100644
index 0000000..f1f514b
--- /dev/null
+++ b/k8s/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- manifest.yaml
+images:
+- name: synoti21/diareat-backend
+ newName: synoti21/diareat-backend
+ newTag: 19305c83
diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml
new file mode 100644
index 0000000..b33742d
--- /dev/null
+++ b/k8s/manifest.yaml
@@ -0,0 +1,94 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: diareat-deployment
+ namespace: diareat
+spec:
+ revisionHistoryLimit: 3
+ selector:
+ matchLabels:
+ app: diareat
+ template:
+ metadata:
+ labels:
+ app: diareat
+ spec:
+ containers:
+ - name: diareat
+ image: synoti21/diareat-backend:latest
+ imagePullPolicy: IfNotPresent
+ envFrom:
+ - secretRef:
+ name: diareat-secret
+ resources:
+ requests:
+ memory: "2G"
+ cpu: 0.2
+ limits:
+ memory: "4G"
+ cpu: 1
+ ports:
+ - containerPort: 8080
+ readinessProbe:
+ httpGet:
+ path: /swagger-ui/index.html
+ port: 8080
+ initialDelaySeconds: 40
+ periodSeconds: 5
+ failureThreshold: 24
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: diareat-svc
+ namespace: diareat
+spec:
+ selector:
+ app: diareat
+ ports:
+ - protocol: TCP
+ port: 2938
+ targetPort: 8080
+---
+apiVersion: traefik.containo.us/v1alpha1
+kind: IngressRoute
+metadata:
+ name: diareat-route
+ namespace: diareat
+spec:
+ entryPoints:
+ - websecure
+ routes:
+ - match: Host(`diareat.thisiswandol.com`)
+ kind: Rule
+ services:
+ - name: diareat-svc
+ port: 2938
+ tls:
+ certResolver: myresolver
+---
+apiVersion: autoscaling/v2beta2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: diareat-hpa
+ namespace: diareat
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: diareat-deployment
+ minReplicas: 1
+ maxReplicas: 3
+ metrics:
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 50
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 50
diff --git a/src/main/java/com/diareat/diareat/DiareatApplication.java b/src/main/java/com/diareat/diareat/DiareatApplication.java
index 87fdffa..bb6db32 100644
--- a/src/main/java/com/diareat/diareat/DiareatApplication.java
+++ b/src/main/java/com/diareat/diareat/DiareatApplication.java
@@ -2,7 +2,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import javax.annotation.PostConstruct;
+import java.util.TimeZone;
+
+@EnableCaching
@SpringBootApplication
public class DiareatApplication {
@@ -10,4 +16,9 @@ public static void main(String[] args) {
SpringApplication.run(DiareatApplication.class, args);
}
+ @PostConstruct
+ void started(){
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
+ }
+
}
diff --git a/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java b/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java
new file mode 100644
index 0000000..e0f61e5
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java
@@ -0,0 +1,36 @@
+package com.diareat.diareat.auth.component;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.GenericFilterBean;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+
+@RequiredArgsConstructor
+public class JwtAuthFilter extends GenericFilterBean {
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+ // 헤더에서 토큰 받아오기
+ String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
+
+ // 토큰이 유효하다면
+ if (token != null && jwtTokenProvider.validateAccessToken(token)) {
+ // 토큰으로부터 유저 정보를 받아
+ Authentication authentication = jwtTokenProvider.getAuthentication(token);
+ // SecurityContext 에 객체 저장
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ // 다음 Filter 실행
+ chain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java b/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java
new file mode 100644
index 0000000..a446dcf
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java
@@ -0,0 +1,105 @@
+package com.diareat.diareat.auth.component;
+
+import com.diareat.diareat.util.api.ResponseCode;
+import com.diareat.diareat.util.exception.ValidException;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Base64;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+@RequiredArgsConstructor
+@Component
+public class JwtTokenProvider {
+
+ @Value("${jwt.secret}")
+ private String secretKey;
+
+ private final UserDetailsService userDetailsService;
+
+ private final RedisTemplate redisTemplate;
+
+ // 객체 초기화, secretKey를 Base64로 인코딩
+ @PostConstruct
+ protected void init() {
+ secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
+ }
+
+ // 토큰 생성
+ public String createAccessToken(String userPk) {
+ Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
+ Date now = new Date();
+ return Jwts.builder()
+ .setClaims(claims) // 정보 저장
+ .setIssuedAt(now) // 토큰 발행 시간 정보
+ .setExpiration(new Date(now.getTime() + (60 * 60 * 1000L))) // 토큰 유효시각 설정 (1시간)
+ .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘과, secret 값
+ .compact();
+ }
+
+ public String createRefreshToken(String userPk) {
+ Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
+ Date now = new Date();
+ String refreshToken = Jwts.builder()
+ .setClaims(claims) // 정보 저장
+ .setIssuedAt(now) // 토큰 발행 시간 정보
+ .setExpiration(new Date(now.getTime() + (7 * 24 * 60 * 60 * 1000L))) // 토큰 유효시각 설정 (1주일)
+ .signWith(SignatureAlgorithm.HS256, secretKey)
+ .compact();
+
+ redisTemplate.opsForValue().set(
+ userPk,
+ refreshToken,
+ 7 * 24 * 60 * 60 * 1000L,
+ TimeUnit.MILLISECONDS
+ );
+
+ return refreshToken;
+ }
+
+ // 인증 정보 조회
+ public Authentication getAuthentication(String token) {
+ UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(this.getUserPk(token)));
+ return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
+ }
+
+ // 토큰에서 회원 정보 추출
+ public Long getUserPk(String token) {
+ return Long.parseLong(Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject());
+ }
+
+ // 토큰 유효성, 만료일자 확인
+ public boolean validateAccessToken(String jwtToken) {
+ try {
+ Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
+ return !claims.getBody().getExpiration().before(new Date());
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public void validateRefreshToken(Long userPK, String refreshToken) {
+ String redisRefreshToken = redisTemplate.opsForValue().get(String.valueOf(userPK));
+ if (redisRefreshToken == null || !redisRefreshToken.equals(refreshToken)) {
+ throw new ValidException(ResponseCode.REFRESH_TOKEN_VALIDATION_FAILURE);
+ }
+ }
+
+ // Request의 Header에서 token 값 가져오기
+ public String resolveToken(HttpServletRequest request) {
+ return request.getHeader("accessToken");
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java b/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java
new file mode 100644
index 0000000..5727ffc
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java
@@ -0,0 +1,24 @@
+package com.diareat.diareat.auth.component;
+
+import com.diareat.diareat.auth.dto.KakaoUserInfoResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+
+@RequiredArgsConstructor
+@Component
+public class KakaoUserInfo {
+
+ private final WebClient webClient;
+ private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me";
+
+ public KakaoUserInfoResponse getUserInfo(String token) {
+ Flux response = webClient.get()
+ .uri(USER_INFO_URI)
+ .header("Authorization", "Bearer " + token)
+ .retrieve()
+ .bodyToFlux(KakaoUserInfoResponse.class);
+ return response.blockFirst();
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/auth/controller/AuthController.java b/src/main/java/com/diareat/diareat/auth/controller/AuthController.java
new file mode 100644
index 0000000..fb2f744
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/controller/AuthController.java
@@ -0,0 +1,78 @@
+package com.diareat.diareat.auth.controller;
+
+import com.diareat.diareat.auth.component.JwtTokenProvider;
+import com.diareat.diareat.auth.dto.ResponseJwtDto;
+import com.diareat.diareat.auth.service.KakaoAuthService;
+import com.diareat.diareat.user.dto.request.JoinUserDto;
+import com.diareat.diareat.user.service.UserService;
+import com.diareat.diareat.util.api.ApiResponse;
+import com.diareat.diareat.util.api.ResponseCode;
+import io.swagger.annotations.Api;
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+@Api(tags = "2. Auth")
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+ private final UserService userService;
+ private final KakaoAuthService kakaoAuthService;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ // 카카오 로그인을 위해 회원가입 여부 확인, 이미 회원이면 Jwt 토큰 발급
+ @Operation(summary = "[로그인] 카카오로그인 및 토큰 발급", description = "카카오 로그인을 위해 회원가입 여부를 확인하고, 이미 회원이면 id와 Jwt 토큰을 발급합니다.")
+ @PostMapping("/login")
+ public ApiResponse authCheck(@RequestHeader String accessToken) {
+ Long userId = kakaoAuthService.isSignedUp(accessToken); // 유저 고유번호 추출
+
+ ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder()
+ .userId(userId)
+ .accessToken(jwtTokenProvider.createAccessToken(userId.toString()))
+ .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString()))
+ .build();
+
+ return ApiResponse.success(responseJwtDto, ResponseCode.USER_LOGIN_SUCCESS.getMessage());
+ }
+
+ // 회원가입 (성공 시 Jwt 토큰 발급)
+ @Operation(summary = "[회원가입] 회원가입 및 토큰 발급", description = "신규 회원가입을 처리하고, 회원가입 성공 시 id와 Jwt 토큰을 발급합니다.")
+ @PostMapping("/join")
+ public ApiResponse saveUser(@Valid @RequestBody JoinUserDto joinUserDto) {
+ Long userId = userService.saveUser(kakaoAuthService.createUserDto(joinUserDto));
+
+ ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder()
+ .userId(userId)
+ .accessToken(jwtTokenProvider.createAccessToken(userId.toString()))
+ .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString()))
+ .build();
+
+ return ApiResponse.success(responseJwtDto, ResponseCode.USER_CREATE_SUCCESS.getMessage());
+ }
+
+ // 토큰 검증 (Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 True 혹은 예외 반환)
+ @Operation(summary = "[토큰 검증] 토큰 검증", description = "클라이언트가 가지고 있던 Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 OK 혹은 예외를 반환합니다.")
+ @GetMapping("/token")
+ public ApiResponse tokenCheck(@RequestHeader String accessToken) {
+ return ApiResponse.success(jwtTokenProvider.validateAccessToken(accessToken), ResponseCode.TOKEN_CHECK_SUCCESS.getMessage());
+ }
+
+ @Operation(summary = "[토큰 재발급] 토큰 재발급", description = "클라이언트가 가지고 있던 Refresh 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 OK 혹은 예외를 반환합니다.")
+ @PostMapping("/reissue")
+ public ApiResponse reissueToken(@RequestHeader String refreshToken) {
+ Long userId = jwtTokenProvider.getUserPk(refreshToken);
+ jwtTokenProvider.validateRefreshToken(userId, refreshToken);
+
+ ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder()
+ .userId(userId)
+ .accessToken(jwtTokenProvider.createAccessToken(userId.toString()))
+ .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString()))
+ .build();
+
+ return ApiResponse.success(responseJwtDto, ResponseCode.TOKEN_REISSUE_SUCCESS.getMessage());
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java
new file mode 100644
index 0000000..3f0a804
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java
@@ -0,0 +1,11 @@
+package com.diareat.diareat.auth.dto;
+
+import lombok.Getter;
+
+@Getter
+public class KakaoAccount {
+
+ private Boolean profile_nickname_needs_agreement;
+ private Boolean profile_image_needs_agreement;
+ private KakaoProfile profile;
+}
diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java
new file mode 100644
index 0000000..ea6e2c8
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java
@@ -0,0 +1,12 @@
+package com.diareat.diareat.auth.dto;
+
+import lombok.Getter;
+
+@Getter
+public class KakaoProfile {
+
+ private String nickname;
+ private String thumbnail_image_url;
+ private String profile_image_url;
+ private Boolean is_default_image;
+}
diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java
new file mode 100644
index 0000000..4b11027
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java
@@ -0,0 +1,11 @@
+package com.diareat.diareat.auth.dto;
+
+import lombok.Getter;
+
+@Getter
+public class KakaoProperties {
+
+ private String nickname;
+ private String profile_image;
+ private String thumbnail_image;
+}
diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java
new file mode 100644
index 0000000..039c62b
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java
@@ -0,0 +1,12 @@
+package com.diareat.diareat.auth.dto;
+
+import lombok.Getter;
+
+@Getter
+public class KakaoUserInfoResponse {
+
+ private Long id;
+ private String connected_at;
+ private KakaoProperties properties;
+ private KakaoAccount kakao_account;
+}
diff --git a/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java b/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java
new file mode 100644
index 0000000..25d74d7
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java
@@ -0,0 +1,15 @@
+package com.diareat.diareat.auth.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+@AllArgsConstructor
+public class ResponseJwtDto {
+
+ private Long userId;
+ private String accessToken;
+ private String refreshToken;
+}
diff --git a/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java b/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java
new file mode 100644
index 0000000..fd5b14b
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java
@@ -0,0 +1,23 @@
+package com.diareat.diareat.auth.service;
+
+import com.diareat.diareat.user.repository.UserRepository;
+import com.diareat.diareat.util.api.ResponseCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@RequiredArgsConstructor
+@Service
+public class CustomUserDetailService implements UserDetailsService {
+
+ private final UserRepository userRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
+
+ return userRepository.findById(Long.parseLong(id))
+ .orElseThrow(() -> new UsernameNotFoundException(ResponseCode.USER_NOT_FOUND.getMessage()));
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java b/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java
new file mode 100644
index 0000000..c1a9bb2
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java
@@ -0,0 +1,35 @@
+package com.diareat.diareat.auth.service;
+
+import com.diareat.diareat.auth.component.KakaoUserInfo;
+import com.diareat.diareat.auth.dto.KakaoUserInfoResponse;
+import com.diareat.diareat.user.domain.User;
+import com.diareat.diareat.user.dto.request.CreateUserDto;
+import com.diareat.diareat.user.dto.request.JoinUserDto;
+import com.diareat.diareat.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Service
+public class KakaoAuthService {
+
+ private final KakaoUserInfo kakaoUserInfo;
+ private final UserRepository userRepository;
+
+ @Transactional(readOnly = true)
+ public Long isSignedUp(String token) { // 클라이언트가 보낸 token을 이용해 카카오 API에 유저 정보를 요청, 유저가 존재하지 않으면 null, 존재하면 회원번호 반환
+ KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(token);
+ Optional userOptional = userRepository.findByKeyCode(userInfo.getId().toString());
+ return userOptional.map(User::getId).orElse(null);
+ }
+
+ @Transactional(readOnly = true)
+ public CreateUserDto createUserDto(JoinUserDto joinUserDto) { // 카카오로부터 프사 URL, 유저 고유ID를 얻어온 후, 이를 유저가 입력한 정보와 함께 CreateUserDto로 반환
+ KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(joinUserDto.getToken());
+ return CreateUserDto.of(joinUserDto.getNickName(), userInfo.getKakao_account().getProfile().getProfile_image_url(),
+ userInfo.getId().toString(), joinUserDto.getGender(), joinUserDto.getHeight(), joinUserDto.getWeight(), joinUserDto.getAge());
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java b/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java
new file mode 100644
index 0000000..c4aac34
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java
@@ -0,0 +1,9 @@
+package com.diareat.diareat.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/diareat/diareat/config/RedisCacheConfig.java b/src/main/java/com/diareat/diareat/config/RedisCacheConfig.java
new file mode 100644
index 0000000..b54f050
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/config/RedisCacheConfig.java
@@ -0,0 +1,35 @@
+package com.diareat.diareat.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.time.Duration;
+
+@EnableCaching
+@Configuration
+public class RedisCacheConfig { // Redis Cache 설정
+
+ @Value("${spring.redis.host}")
+ private String host;
+
+ @Value("${spring.redis.port}")
+ private int port;
+
+ @Bean
+ public CacheManager diareatCacheManager(RedisConnectionFactory cf) {
+ RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
+ .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+ .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
+ .entryTtl(Duration.ofMinutes(3L)); // 캐시 만료 시간 3분
+ return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build();
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/config/SwaggerConfig.java b/src/main/java/com/diareat/diareat/config/SwaggerConfig.java
new file mode 100644
index 0000000..99bf20f
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/config/SwaggerConfig.java
@@ -0,0 +1,59 @@
+package com.diareat.diareat.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import springfox.documentation.builders.ApiInfoBuilder;
+import springfox.documentation.builders.PathSelectors;
+import springfox.documentation.builders.RequestHandlerSelectors;
+import springfox.documentation.service.ApiInfo;
+import springfox.documentation.service.ApiKey;
+import springfox.documentation.service.AuthorizationScope;
+import springfox.documentation.service.SecurityReference;
+import springfox.documentation.spi.DocumentationType;
+import springfox.documentation.spi.service.contexts.SecurityContext;
+import springfox.documentation.spring.web.plugins.Docket;
+import springfox.documentation.swagger2.annotations.EnableSwagger2;
+
+import java.util.Collections;
+import java.util.List;
+
+@Configuration
+@EnableSwagger2
+public class SwaggerConfig implements WebMvcConfigurer {
+
+ @Bean
+ public Docket api() {
+ return new Docket(DocumentationType.SWAGGER_2)
+ .select()
+ .apis(RequestHandlerSelectors.any())
+ .paths(PathSelectors.any())
+ .build()
+ .apiInfo(apiInfo())
+ .securityContexts(Collections.singletonList(securityContext()))
+ .securitySchemes(List.of(apiKey()));
+ }
+
+ private ApiInfo apiInfo() {
+ return new ApiInfoBuilder()
+ .title("Diareat API")
+ .description("Diareat API 설명서")
+ .version("1.1")
+ .build();
+ }
+
+ private ApiKey apiKey() {
+ return new ApiKey("JWT", "Authorization", "header");
+ }
+
+ private SecurityContext securityContext() {
+ return SecurityContext.builder().securityReferences(defaultAuth()).build();
+ }
+
+ private List defaultAuth() {
+ AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEveryThing");
+ AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
+ authorizationScopes[0] = authorizationScope;
+ return List.of(new SecurityReference("JWT", authorizationScopes));
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/config/WebClientConfig.java b/src/main/java/com/diareat/diareat/config/WebClientConfig.java
new file mode 100644
index 0000000..80d7496
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/config/WebClientConfig.java
@@ -0,0 +1,39 @@
+package com.diareat.diareat.config;
+
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ClientHttpConnector;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+import java.time.Duration;
+import java.util.function.Function;
+
+@Configuration
+public class WebClientConfig {
+
+ @Bean
+ public ReactorResourceFactory resourceFactory() {
+ ReactorResourceFactory factory = new ReactorResourceFactory();
+ factory.setUseGlobalResources(false);
+ return factory;
+ }
+
+ @Bean
+ public WebClient webClient() {
+ Function mapper = client -> HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
+ .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(10))
+ .addHandlerLast(new WriteTimeoutHandler(10)))
+ .responseTimeout(Duration.ofSeconds(1));
+
+ ClientHttpConnector connector =
+ new ReactorClientHttpConnector(resourceFactory(), mapper);
+ return WebClient.builder().clientConnector(connector).build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java b/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java
new file mode 100644
index 0000000..0bd9f3a
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java
@@ -0,0 +1,37 @@
+package com.diareat.diareat.config;
+
+import com.diareat.diareat.auth.component.JwtAuthFilter;
+import com.diareat.diareat.auth.component.JwtTokenProvider;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@RequiredArgsConstructor
+@EnableWebSecurity
+public class WebSecurityConfig {
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ private static final String[] AUTH_LIST = { // swagger 관련 URl
+ "/v2/api-docs", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**"
+ };
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf().disable()
+ .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ .and()
+ .authorizeRequests()
+ .antMatchers(AUTH_LIST).permitAll() // swagger 관련 URL은 인증 없이 접근 가능 (테스트용)
+ .antMatchers("/api/auth/**").permitAll() // 회원가입/로그인 관련 URL은 인증 없이 접근 가능
+ .anyRequest().authenticated() // 나머지 모든 URL은 Jwt 인증 필요
+ .and()
+ .addFilterBefore(new JwtAuthFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/controller/FoodController.java b/src/main/java/com/diareat/diareat/food/controller/FoodController.java
new file mode 100644
index 0000000..b682b75
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/controller/FoodController.java
@@ -0,0 +1,176 @@
+package com.diareat.diareat.food.controller;
+
+import com.diareat.diareat.auth.component.JwtTokenProvider;
+import com.diareat.diareat.food.dto.*;
+import com.diareat.diareat.food.service.FoodService;
+import com.diareat.diareat.user.dto.response.ResponseRankUserDto;
+import com.diareat.diareat.util.api.ApiResponse;
+import com.diareat.diareat.util.api.ResponseCode;
+import io.swagger.annotations.Api;
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.time.LocalDate;
+import java.util.List;
+
+@Api(tags = "2. food")
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("api/food")
+public class FoodController {
+
+ private final FoodService foodService;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ //음식 정보 저장
+ @Operation(summary = "[음식] 음식 정보 저장", description = "촬영한 음식 정보를 저장합니다.")
+ @PostMapping("/save")
+ public ApiResponse saveFood(@RequestBody @Valid CreateFoodDto createFoodDto) {
+ return ApiResponse.success(foodService.saveFood(createFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage());
+ }
+
+ //특정 날짜에 먹은 음식 반환
+ @Operation(summary = "[음식] 특정 날짜에 먹은 음식 목록 조회", description = "유저의 특정 날짜에 먹은 음식 목록을 조회합니다.")
+ @GetMapping
+ public ApiResponse> getFoodListByDate(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ LocalDate date = LocalDate.of(yy, mm, dd);
+ return ApiResponse.success(foodService.getFoodListByDate(userId, date), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //음식 정보 수정
+ @Operation(summary = "[음식] 음식 정보 수정", description = "음식에 대한 정보를 수정합니다.")
+ @PostMapping("/update")
+ public ApiResponse updateFood(@RequestBody @Valid UpdateFoodDto updateFoodDto,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ LocalDate date = LocalDate.of(yy, mm, dd);
+ foodService.updateFood(updateFoodDto, date);
+ return ApiResponse.success(null, ResponseCode.FOOD_UPDATE_SUCCESS.getMessage());
+ }
+
+ //음식 삭제
+ @Operation(summary = "[음식] 음식 정보 삭제", description = "음식에 대한 정보를 삭제합니다.")
+ @DeleteMapping("/{foodId}/delete")
+ public ApiResponse deleteFood(@PathVariable Long foodId, @RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ LocalDate date = LocalDate.of(yy, mm, dd);
+ foodService.deleteFood(foodId, userId, date);
+ return ApiResponse.success(null, ResponseCode.FOOD_DELETE_SUCCESS.getMessage());
+ }
+
+ //즐겨찾기에 음식 저장
+ @Operation(summary = "[즐겨찾기] 즐겨찾기에 저장", description = "유저의 즐겨찾기에 음식을 저장합니다.")
+ @PostMapping("/favorite")
+ public ApiResponse saveFavoriteFood(@RequestBody @Valid CreateFavoriteFoodDto createFavoriteFoodDto) {
+ return ApiResponse.success(foodService.saveFavoriteFood(createFavoriteFoodDto), ResponseCode.FOOD_FAVORITE_CREATE_SUCCESS.getMessage());
+ }
+
+ //즐겨찾기 음식 리스트 반환
+ @Operation(summary = "[즐겨찾기] 즐겨찾기 목록 조회", description = "유저의 즐겨찾기에 등록된 음식 목록을 조회합니다.")
+ @GetMapping("/favorite")
+ public ApiResponse> getFavoriteFoodList(@RequestHeader String accessToken) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getFavoriteFoodList(userId), ResponseCode.FOOD_FAVORITE_READ_SUCCESS.getMessage());
+ }
+
+ //즐겨찾기 음식 수정
+ @Operation(summary = "[즐겨찾기] 즐겨찾기 수정", description = "유저의 즐겨찾기에 등록된 음식의 정보를 수정합니다.")
+ @PostMapping("/favorite/update")
+ public ApiResponse updateFavoriteFood(@RequestBody @Valid UpdateFavoriteFoodDto updateFavoriteFoodDto) {
+ foodService.updateFavoriteFood(updateFavoriteFoodDto);
+ return ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_UPDATE_SUCCESS.getMessage());
+ }
+
+ //즐겨찾기 음식 해제
+ @Operation(summary = "[즐겨찾기] 즐겨찾기 해제", description = "유저의 즐겨찾기에 등록된 음식을 해제합니다.")
+ @DeleteMapping("/favorite/{favoriteFoodId}")
+ public ApiResponse deleteFavoriteFood(@PathVariable Long favoriteFoodId, @RequestHeader String accessToken) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ foodService.deleteFavoriteFood(favoriteFoodId, userId);
+ return ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_DELETE_SUCCESS.getMessage());
+ }
+
+ //특정 날짜에 먹은 음식들의 영양성분별 총합 조회
+ @Operation(summary = "[음식] 특정 날짜에 먹은 음식들의 영양성분 총합 조회", description = "특정 날짜에 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.")
+ @GetMapping("/nutrition")
+ public ApiResponse getNutritionSumByDate(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ LocalDate date = LocalDate.of(yy, mm, dd);
+ return ApiResponse.success(foodService.getNutritionSumByDate(userId, date), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //"" 7일간 총합 조회
+ @Operation(summary = "[음식] 최근 7일간 먹은 음식들의 영양성분 총합 조회", description = "최근 7일 간 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.")
+ @GetMapping("/nutrition/recentWeek")
+ public ApiResponse getNutritionSumByWeek(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getNutritionSumByWeek(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //"" 30일간 (1달간) 총합 조회
+ @Operation(summary = "[음식] 최근 한달 간 먹은 음식들의 영양성분 총합 조회", description = "최근 한달 간 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.")
+ @GetMapping("/nutrition/recentMonth")
+ public ApiResponse getNutritionSumByMonth(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getNutritionSumByMonth(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //유저의 주간 식습관 점수와 best3, worst3 음식 조회
+ @Operation(summary = "[음식] 유저의 주간 식습관 점수와 best3, worst3 음식 조회", description = "유저의 주간 식습관 점수와 best3, worst3 음식을 조회합니다.")
+ @GetMapping("/score")
+ public ApiResponse getScoreOfUserWithBestAndWorstFoods(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd
+ ) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getScoreOfUserWithBestAndWorstFoods(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //유저의 일기 분석 그래프 데이터 및 식습관 totalScore 조회
+ @Operation(summary = "[음식] 유저의 일기 분석 그래프 데이터 및 주간 식습관 점수 조회", description = "유저의 일기 분석 그래프 데이터 및 식습관 점수를 조회합니다.")
+ @GetMapping("/analysis")
+ public ApiResponse getAnalysisOfUser(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getAnalysisOfUser(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage());
+ }
+
+ //유저의 식습관 점수를 기반으로 한 주간 랭킹 조회
+ @Operation(summary = "[음식] 유저의 식습관 점수를 기반으로 한 주간 랭킹 조회", description = "유저의 식습관 점수를 기반으로 한 주간 랭킹을 조회합니다. (팔로잉 상대 기반)")
+ @GetMapping("/rank")
+ public ApiResponse> getUserRankByWeek(@RequestHeader String accessToken,
+ @RequestParam int yy,
+ @RequestParam int mm,
+ @RequestParam int dd) {
+ Long userId = jwtTokenProvider.getUserPk(accessToken);
+ return ApiResponse.success(foodService.getUserRankByWeek(userId, yy, mm, dd), ResponseCode.FOOD_RANK_READ_SUCCESS.getMessage());
+ }
+
+ @Operation(summary = "[음식] 즐겨찾기 음식으로 음식 생성", description = "즐겨찾기 음식으로 음식을 생성합니다.")
+ @PostMapping("/favorite/createfrom")
+ public ApiResponse createFoodFromFavoriteFood(@RequestBody @Valid CreateFoodFromFavoriteFoodDto createFoodFromFavoriteFoodDto) {
+ return ApiResponse.success(foodService.createFoodFromFavoriteFood(createFoodFromFavoriteFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage());
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java b/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java
new file mode 100644
index 0000000..4f69dbd
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java
@@ -0,0 +1,65 @@
+package com.diareat.diareat.food.domain;
+
+import com.diareat.diareat.user.domain.BaseNutrition;
+import com.diareat.diareat.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class FavoriteFood {
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "favorite_food_id")
+ private Long id;
+
+ private String name;
+
+ //@OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}, orphanRemoval = false) // 즐찾음식이 삭제되어도 음식은 삭제되지 않음
+ //@JoinColumn(name = "food_id")
+ @OneToMany(mappedBy = "favoriteFood", cascade = CascadeType.PERSIST, orphanRemoval = false) // 즐찾음식이 삭제되어도 음식은 삭제되지 않음
+ private List foods = new ArrayList<>();
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ private int count = 0; // 즐겨찾기에서 선택되어 바로 음식으로 추가된 횟수
+
+ private BaseNutrition baseNutrition;
+
+ public void addCount(){
+ this.count++;
+ }
+
+ // 생성 메서드
+ public static FavoriteFood createFavoriteFood(String name, User user, Food food, BaseNutrition baseNutrition) {
+ FavoriteFood favoriteFood = new FavoriteFood();
+ favoriteFood.name = name;
+ favoriteFood.foods.add(food);
+ favoriteFood.user = user;
+ favoriteFood.baseNutrition = baseNutrition;
+ //food.setFavoriteFood(favoriteFood); 관계의 주인이 즐겨찾기로 명확하게 정해졌기에 주석처리
+ return favoriteFood;
+ }
+
+ // 즐겨찾기 음식 정보 수정
+ public void updateFavoriteFood(String name, BaseNutrition baseNutrition) {
+ this.name = name;
+ this.baseNutrition = baseNutrition;
+ }
+
+ // 즐겨찾기 음식 정보로 새 음식 정보 생성
+ public static Food createFoodFromFavoriteFood(FavoriteFood favoriteFood) {
+ favoriteFood.addCount();
+ LocalDate createdDate = LocalDate.now();
+ return Food.createFood(favoriteFood.getName(), favoriteFood.getUser(), favoriteFood.getBaseNutrition(), createdDate.getYear(), createdDate.getMonthValue(), createdDate.getDayOfMonth());
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/domain/Food.java b/src/main/java/com/diareat/diareat/food/domain/Food.java
new file mode 100644
index 0000000..b491c2f
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/domain/Food.java
@@ -0,0 +1,70 @@
+package com.diareat.diareat.food.domain;
+
+import com.diareat.diareat.user.domain.BaseNutrition;
+import com.diareat.diareat.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.*;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@EntityListeners(AuditingEntityListener.class)
+public class Food {
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "food_id")
+ private Long id;
+
+ private String name;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ //@OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}, orphanRemoval = false) // 음식이 삭제되어도 즐찾음식은 삭제되지 않음
+ //@JoinColumn(name = "favorite_food_id")
+ @ManyToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}) // 다대일로 매핑하여 음식의 즐찾음식을 찾을 수 있도록 함
+ @JoinColumn(name = "favorite_food_id")
+ private FavoriteFood favoriteFood;
+
+ private LocalDate date;
+
+ @CreatedDate
+ @Column(name = "added_time") //테이블과 매핑
+ private LocalDateTime addedTime; //클라이언트에서 추가하도록 요청 보낸 timestamp
+
+ private BaseNutrition baseNutrition;
+
+ // 생성 메서드
+ public static Food createFood(String name, User user, BaseNutrition baseNutrition, int year, int month, int day) {
+ Food food = new Food();
+ food.name = name;
+ food.user = user;
+ food.date = LocalDate.of(year, month, day);
+ food.baseNutrition = baseNutrition;
+
+ return food;
+ }
+
+ // 음식 정보 수정
+ public void updateFood(String name, BaseNutrition baseNutrition) {
+ this.name = name;
+ this.baseNutrition = baseNutrition;
+ }
+
+ public boolean isFavorite() {
+ return this.favoriteFood != null;
+ }
+
+ public void setFavoriteFood(FavoriteFood favoriteFood){
+ this.favoriteFood = favoriteFood;
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java
new file mode 100644
index 0000000..761602b
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java
@@ -0,0 +1,32 @@
+package com.diareat.diareat.food.dto;
+
+import com.diareat.diareat.user.domain.BaseNutrition;
+import com.diareat.diareat.util.MessageUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateFavoriteFoodDto {
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private Long foodId;
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private Long userId;
+
+ @NotBlank(message = MessageUtil.NOT_BLANK)
+ private String name;
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private BaseNutrition baseNutrition;
+
+ public static CreateFavoriteFoodDto of(Long foodId, Long userId, String name, BaseNutrition baseNutrition) {
+ return new CreateFavoriteFoodDto(foodId, userId, name, baseNutrition);
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java
new file mode 100644
index 0000000..fd982c3
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java
@@ -0,0 +1,41 @@
+package com.diareat.diareat.food.dto;
+
+import com.diareat.diareat.user.domain.BaseNutrition;
+import com.diareat.diareat.util.MessageUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.PastOrPresent;
+import java.time.LocalDate;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateFoodDto {
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private Long userId;
+
+ @NotBlank(message = MessageUtil.NOT_BLANK)
+ private String name;
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private BaseNutrition baseNutrition;
+
+ private int year;
+
+ private int month;
+
+ private int day;
+
+ public static CreateFoodDto of(Long userId, String name, BaseNutrition baseNutrition, int year, int month, int day) {
+ return new CreateFoodDto(userId, name, baseNutrition, year, month, day);
+ }
+
+ public LocalDate getDate() {
+ return LocalDate.of(year, month, day);
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java
new file mode 100644
index 0000000..28011b9
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java
@@ -0,0 +1,23 @@
+package com.diareat.diareat.food.dto;
+
+import com.diareat.diareat.util.MessageUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotNull;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateFoodFromFavoriteFoodDto {
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private Long userId;
+
+ @NotNull(message = MessageUtil.NOT_NULL)
+ private Long favoriteFoodId;
+
+ public static CreateFoodFromFavoriteFoodDto of(Long userId, Long favoriteFoodId) {
+ return new CreateFoodFromFavoriteFoodDto(userId, favoriteFoodId);
+ }
+}
diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java
new file mode 100644
index 0000000..8780f35
--- /dev/null
+++ b/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java
@@ -0,0 +1,31 @@
+package com.diareat.diareat.food.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ResponseAnalysisDto { // 그래프 + 점수에 사용되는 DTO
+
+ private double totalScore;
+ private List