diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
index 32566caa..319d64a8 100644
--- a/.github/ISSUE_TEMPLATE/issue_template.md
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -1,7 +1,7 @@
---
-name: 이슈 템플릿
-about: "\b해당 이슈 생성 템플릿을 사용하여 이슈 생성"
-title: ''
+name: "✅ Issue Template"
+about: 기본 Issue Template
+title: 예시) ✨ Feature 이슈 제목
labels: ''
assignees: ''
diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml
new file mode 100644
index 00000000..ccb818d4
--- /dev/null
+++ b/.github/workflows/mody-dev.yml
@@ -0,0 +1,92 @@
+name: MODY service Dev Build and Deploy to AWS
+
+on:
+ push:
+ branches:
+ - develop
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ # 1. 코드 체크아웃
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ # 1-1. Java 21 세팅
+ - name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ # 1-2. application.yml 파일 생성
+ - name: make application.yml
+ run: |
+ # create application.yml
+ cd ./src/main
+ cd ./resources
+
+ # application.yml 파일 생성하기
+ touch ./application.yml
+
+ # Secrets에 저장한 값을 application.yml 파일에 쓰기
+ echo "${{ secrets.DEV_YML }}" >> ./application.yml
+ shell: bash # 스크립트가 Bash 셸에서 실행
+
+ # 1-2-1 start.sh 파일 생성
+ - name: make start.sh
+ run: |
+ # create start.sh
+ cd ./scripts
+
+ # start.sh 파일 생성하기
+ touch ./start.sh
+
+ # Secrets에 저장한 값을 start.sh 파일에 쓰기
+ echo "${{ secrets.START_SH }}" >> ./start.sh
+
+ # 1-3. Spring Boot 애플리케이션 빌드
+ - name: Build with Gradle
+ run: |
+ chmod +x ./gradlew
+ ./gradlew clean build -x test
+
+ # 2. AWS CLI 설정
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v2
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ # 3. Docker 로그인
+ - name: Log in to Amazon ECR
+ run: |
+ aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPO }}
+
+ # 4. Docker 이미지 빌드
+ - name: Build Docker Image
+ run: |
+ docker build -t ${{ secrets.AWS_ECR_REPO }}:latest .
+ docker tag ${{ secrets.AWS_ECR_REPO }}:latest ${{ secrets.AWS_ECR_REPO }}:latest
+
+ # 5. Docker 이미지 푸시
+ - name: Push to Amazon ECR
+ run: |
+ docker push ${{ secrets.AWS_ECR_REPO }}:latest
+
+ # 6-0. S3 업로드
+ - name: Upload to S3
+ run: |
+ zip -r deploy.zip appspec.yml scripts/
+ aws s3 cp deploy.zip s3://modi-service-bucket/deploy/deploy.zip
+
+ # 6. CodeDeploy 트리거
+ - name: Trigger CodeDeploy Deployment
+ run: |
+ aws deploy create-deployment \
+ --application-name modi-server \
+ --deployment-group-name modi-server-group \
+ --revision "{\"revisionType\":\"S3\",\"s3Location\":{\"bucket\":\"modi-service-bucket\",\"key\":\"deploy/deploy.zip\",\"bundleType\":\"zip\"}}"
diff --git a/.github/workflows/mody-prod.yml b/.github/workflows/mody-prod.yml
new file mode 100644
index 00000000..8802ecad
--- /dev/null
+++ b/.github/workflows/mody-prod.yml
@@ -0,0 +1,74 @@
+name: MODY service Build and Deploy to AWS
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ # 1. 코드 체크아웃
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ # 1-1. Java 21 세팅
+ - name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ # 1-2. application.yml 파일 생성
+ - name: make application.yml
+ run: |
+ # create application.yml
+ cd ./src/main
+ cd ./resources
+
+ # application.yml 파일 생성하기
+ touch ./application.yml
+
+ # Secrets에 저장한 값을 application.yml 파일에 쓰기
+ echo "${{ secrets.YML }}" >> ./application.yml
+ shell: bash # 스크립트가 Bash 셸에서 실행
+
+ # 1-3. Spring Boot 애플리케이션 빌드
+ - name: Build with Gradle
+ run: |
+ chmod +x ./gradlew
+ ./gradlew clean build -x test
+
+ # 2. AWS CLI 설정
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v2
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ # 3. Docker 로그인
+ - name: Log in to Amazon ECR
+ run: | # AWS ECR에 로그인 (AWS CLI 사용)
+ aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPO }}
+
+ # 4. Docker 이미지 빌드
+ - name: Build Docker Image
+ run: |
+ docker build -t ${{ secrets.AWS_ECR_REPO }}:prod .
+ docker tag ${{ secrets.AWS_ECR_REPO }}:prod ${{ secrets.AWS_ECR_REPO }}:prod
+
+ # 5. Docker 이미지 푸시
+ - name: Push to Amazon ECR
+ run: |
+ docker push ${{ secrets.AWS_ECR_REPO }}:prod
+
+ # 6. CodeDeploy 트리거
+ - name: Trigger CodeDeploy Deployment
+ run: |
+ aws deploy create-deployment \
+ --application-name my-app \
+ --deployment-group-name my-app-group \
+ --revision "{\"revisionType\":\"AppSpecContent\",\"appSpecContent\":{\"content\":\"$(cat appspec.yml)\"}}"
diff --git a/.gitignore b/.gitignore
index 022c0b3e..2481f47d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
HELP.md
.gradle
build/
+!build.gradle
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
@@ -36,4 +37,10 @@ out/
### VS Code ###
.vscode/
-/application.yml
\ No newline at end of file
+application.yml
+
+## QClass ##
+!**/src/main/generated/
+
+## log
+logs/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..6c355c30
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+FROM openjdk:21-jdk-slim
+
+# 크롬 설치
+RUN apt-get update && apt-get install -y wget curl unzip \
+ && wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
+ && apt-get install -y ./google-chrome-stable_current_amd64.deb \
+ && rm ./google-chrome-stable_current_amd64.deb \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# JAR 파일 복사 및 실행
+ARG JAR_FILE=build/libs/*.jar
+COPY ${JAR_FILE} mody-server-0.0.1.jar
+ENTRYPOINT ["java","-jar","/mody-server-0.0.1.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index c1d45314..f6c73204 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,141 @@
-# mody-server
\ No newline at end of file
+# mody
+
+> "모드(Mode)"와 "버디(Buddy)"의 결합으로 만들어진 이 이름은, 당신의 **라이프스타일과 개성을 반영하는 최적의 '모드'를 발견하도록 돕는 친구**를 뜻합니다.
+> 고객의 체형, 취향, 라이프스타일 데이터를 기반으로 맞춤형 스타일링 경험을 제공합니다.
+> 단순한 추천을 넘어, 고객 스스로 스타일링 능력을 기를 수 있도록 돕는 서비스입니다.
+
+
+
+
+
+
+## 프로젝트 기능
+1. 종합 패션 스타일 컨설팅
+ * 체형 분석
+ * 스타일 추천
+ * 패션 아이템 추천
+ * 브랜드 추천
+2. 데일리 패션 추천
+ * 날씨 기반 추천
+ * 일정 및 라이프스타일 반영 추천
+3. 부가 기능
+ * 스타일링 팁 제공
+ * 쇼핑 연계 기능
+ * 저장 및 비교 기능
+ * 스타일 커뮤니티
+4. 기능 기대 효과
+ * 시간 절약: 고객이 스타일 고민에 소모하던 시간을 획기적으로 단축
+ * 개성 표현: 획일적인 스타일에서 벗어나, 개인의 개성과 매력을 돋보이게 함
+ * 편리한 쇼핑: 추천과 쇼핑을 한 번에 해결하여 구매 실패율을 낮춤
+ * 지속적인 성장: 고객 스스로 스타일링 실력을 키워 패션 근육을 강화
+
+## Member
+
+| 박동규 | 김재헌 | 서상효 | 김성욱 | 최윤서 |
+|:---------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:|
+| | | | | |
+| [dong99u](https://github.com/dong99u) | [jher235](https://github.com/jher235) | [seoshinehyo](https://github.com/seoshinehyo) | [so3659](https://github.com/so3659) | [yunseo02](https://github.com/yunseo02) |
+
+## Tech Stack
+
+
+| 기술 스택 | 버전 |
+|:---------------------------------------------------------------------------------------------------------------------:|:------:|
+|
| 21 |
+|  | 3.3.5 |
+|  | 8.0.39 |
+
+
+
+
+
+
+
+
+
+
+## 협업 규칙
+
+### Github 협업 규칙
+
+Github 협업 규칙은 아래와 같습니다.
+
+1. 전체적인 협업 flow는 Github flow를 따름.
+2. Fork한 저장소를 각자 local로 가져와 수정.
+3. 수정한 코드는 add -> commit -> push 후, upstream에 Pull Request를 수행.
+4. main branch로부터 dev branch, prod branch를 구성.
+5. 추가되는 기능에 대해서는 feature branch를 생성하여 각 기능별 branch를 구성.
+6. Pull Request 시 Code Review 이후 Merge 진행.
+7. Commit 규칙은 아래와 같이 진행했습니다.
+
+ | 커밋 타입 | 설명 |
+ | --------- | -------------------------------------------------------------- |
+ | feature | 새로운 기능 구현 |
+ | fix | 수정 |
+ | refactor | 리팩토링 |
+ | docs | 문서 수정 |
+ | style | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 |
+ | chore | 패키지 구조 수정, code와 무관한 부분들 (.gitignore, build.gradle 같은) 수정 |
+ | hotfix | hotfix |
+ | remove | 패키지나 클래스 삭제 |
+ | test | 테스트 코드, 리펙토링 테스트 코드 추가 |
+ | rename | 패키지나 클래스명 수정 |
+ | comment | 주석 추가 |
+
+### Issue 활용
+
+
+- Github 레포지토리의 Issue탭에 Todo인 상황 혹은 In progress에 대한 상황을 작성하고 공유했습니다. 해당 Issue 번호로 각자의 로컬 레포지토리에 브랜치를 생성하여 Pull Request 시에 해당 Issue를 언급하여 공유했습니다. 해당 전략을 사용하여 Merge Conflict의 발생 가능성을 줄였습니다.
+
+### PR 활용
+
+
+- 다음과 같이 개발 이후 특정 프로젝트에 대한 변경사항을 제안하고, 팀원과 이를 검토 및 논의한 후, 최종적으로 해당 변경사항을 반영할 수 있도록 했습니다.
+- 다른 개발자들은 해당 Pull Request를 검토하고, 필요한 경우 피드백을 제공할 수 있었습니다.
+- 검토 후, Pull Request가 승인되면 변경 사항이 메인 프로젝트로 병합되도록 했습니다. 반면, 추가적인 수정이 필요한 경우 개발자는 피드백을 반영하여 수정하고, 수정된 변경사항을 다시 push 했습니다.
+
+## 구현결과
+
+
+
+
로그인 / 회원가입
+
+
+
+
+
+
+
+
+
체형 분석
+
+
+
+
+
스타일 추천
+
+
+
패션 아이템 추천
+
+
+
+
마이페이지
+
+
+
+
+
+
비슷한 체형 게시글
+
+
+
+
+
+
+## Contact
+
+* 박동규 : qkrehdrb0813@gmail.com
+* 김재헌 : king09044@naver.com
+* 서상효 : springssh0504@naver.com
+* 김성욱 : so3659@naver.com
+* 최윤서 : chldbstj021902@gmail.com
diff --git a/appspec.yml b/appspec.yml
new file mode 100644
index 00000000..a3ae9fd0
--- /dev/null
+++ b/appspec.yml
@@ -0,0 +1,14 @@
+version: 0.0
+os: linux
+files:
+ - source: /
+ destination: /home/ec2-user/app # 애플리케이션 파일들이 위치한 경로
+hooks:
+ BeforeInstall:
+ - location: scripts/stop.sh
+ timeout: 300 # 5분
+ runas: root
+ AfterInstall:
+ - location: scripts/start.sh
+ timeout: 300
+ runas: root
diff --git a/build.gradle b/build.gradle
index d0357109..e13e021a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,68 +1,98 @@
plugins {
- id 'java'
- id 'org.springframework.boot' version '3.3.5'
- id 'io.spring.dependency-management' version '1.1.6'
- id 'org.asciidoctor.jvm.convert' version '3.3.2'
+ id 'java'
+ id 'org.springframework.boot' version '3.3.5'
+ id 'io.spring.dependency-management' version '1.1.6'
+ id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
- toolchain {
- languageVersion = JavaLanguageVersion.of(21)
- }
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
}
configurations {
- compileOnly {
- extendsFrom annotationProcessor
- }
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
}
repositories {
- mavenCentral()
+ mavenCentral()
}
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
+
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ //sentry 사용
+ implementation 'io.sentry:sentry-spring-boot-starter-jakarta:7.9.0'
+
+ //jwt 사용 0.12.3 버전 사용
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
+
+ implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
+
+ compileOnly 'org.projectlombok:lombok'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // 스웨거 사용
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
+
+ //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"
+
+ // AWS 서비스 의존성 추가
+ implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+
+ //Webflux
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+ // macos 전용 netty server
+ implementation 'io.netty:netty-resolver-dns-native-macos'
- //jwt 사용
- implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
- runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
- runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+ // 테스트
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+ runtimeOnly 'com.h2database:h2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
- implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
+ // Security
+ implementation 'org.springframework.boot:spring-boot-starter-security'
- compileOnly 'org.projectlombok:lombok'
- runtimeOnly 'com.mysql:mysql-connector-j'
- annotationProcessor 'org.projectlombok:lombok'
+ // Oauth2
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
- // 스웨거 사용
- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
+ // P6Spy
+ implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
- //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"
+ // Mail
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
- // AWS 서비스 의존성 추가
- implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+ // Redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- //Webflux
- implementation 'org.springframework.boot:spring-boot-starter-webflux'
- // macos 전용 netty server
- implementation 'io.netty:netty-resolver-dns-native-macos'
+ // Selenium
+ implementation 'org.seleniumhq.selenium:selenium-java:4.8.0'
- // 테스트
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
- testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
- testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
- outputs.dir snippetsDir
- useJUnitPlatform()
+ outputs.dir snippetsDir
+ useJUnitPlatform()
}
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
new file mode 100644
index 00000000..471e0050
--- /dev/null
+++ b/docker-compose-dev.yml
@@ -0,0 +1,52 @@
+services: # 컨테이너 설정
+ backend:
+ container_name: mody-server-dev
+ image: mody-server:dev #ecr에 올린 이미지로 수정 필요
+ environment:
+ - SPRING_PROFILES_ACTIVE=dev
+ - REDIS_HOST=redis
+ - REDIS_PORT=6379
+ ports:
+ - 8000:8000
+ depends_on:
+ - redis
+ networks:
+ - mody-network
+
+ nginx: # Nginx 컨테이너 설정 (certbot은 인증서 발급 시에만 포트를 사용하게 설정)
+ image: nginx
+ container_name: nginx
+ restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작
+ ports:
+ - "880:80"
+ - "8443:443"
+ volumes:
+ - ./certbot/conf:/etc/letsencrypt # 파일 설정을 nginx에 마운트 시킴
+ - ./certbot/www:/var/www/certbot
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf
+ - ./nginx/conf/portainer.mody.server.kkoalla.app.conf:/etc/nginx/conf.d/portainer.mody.server.kkoalla.app.conf
+ command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
+ networks:
+ - mody-network
+
+ certbot:
+ image: certbot/certbot
+ container_name: certbot
+ volumes:
+ - ./certbot/conf:/etc/letsencrypt
+ - ./certbot/www:/var/www/certbot
+ entrypoint: "/bin/sh -c 'trap exit TERM; certbot certonly --manual --preferred-challenges dns --email shimseawon1510@gmail.com -d kkoalla.app -d *.kkoalla.app --agree-tos --no-eff-email; exit;'"
+
+ redis:
+ image: redis
+ container_name: redis
+ restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작
+ ports:
+ - "6379:6379"
+ networks:
+ - mody-network
+ command: redis-server --appendonly yes # redis 서버가 비정상적으로 종료되었을 때 데이터를 보존하기 위해 설정
+
+networks:
+ mody-network:
+ driver: bridge
diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml
new file mode 100644
index 00000000..91eacf6c
--- /dev/null
+++ b/docker-compose-prod.yml
@@ -0,0 +1,28 @@
+services: # 컨테이너 설정
+ backend-prod:
+ container_name: mody-server-prod
+ image: mody-server #ecr에 올린 이미지로 수정 필요 #ecr에 올린 이미지로 수정 필요
+ environment:
+ - SPRING_PROFILES_ACTIVE=prod
+ - REDIS_HOST=redis
+ - REDIS_PORT=6379
+ ports:
+ - 8080:8080
+ depends_on:
+ - redis-prod
+ networks:
+ - mody-prod-network
+
+ redis-prod:
+ image: redis
+ container_name: redis-prod
+ restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작
+ ports:
+ - "66379:66379"
+ networks:
+ - mody-prod-network
+ command: redis-server --appendonly yes # redis 서버가 비정상적으로 종료되었을 때 데이터를 보존하기 위해 설정
+
+networks:
+ mody-prod-network:
+ driver: bridge
\ No newline at end of file
diff --git a/scripts/stop.sh b/scripts/stop.sh
new file mode 100644
index 00000000..ef5ba822
--- /dev/null
+++ b/scripts/stop.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# 컨테이너 중지
+docker-compose -f /home/ec2-user/app/docker-compose-dev.yml stop backend
+docker-compose -f /home/ec2-user/app/docker-compose-dev.yml rm -f backend
+
+
+# 안 사용하는 이미지 정리
+docker image prune -f
\ No newline at end of file
diff --git a/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java b/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java
new file mode 100644
index 00000000..205d68ae
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java
@@ -0,0 +1,64 @@
+package com.example.mody.domain.auth.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QRefreshToken is a Querydsl query type for RefreshToken
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QRefreshToken extends EntityPathBase {
+
+ private static final long serialVersionUID = 1361191399L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QRefreshToken refreshToken = new QRefreshToken("refreshToken");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final StringPath token = createString("token");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QRefreshToken(String variable) {
+ this(RefreshToken.class, forVariable(variable), INITS);
+ }
+
+ public QRefreshToken(Path extends RefreshToken> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QRefreshToken(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QRefreshToken(PathMetadata metadata, PathInits inits) {
+ this(RefreshToken.class, metadata, inits);
+ }
+
+ public QRefreshToken(Class extends RefreshToken> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java
new file mode 100644
index 00000000..d61c3bc5
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java
@@ -0,0 +1,66 @@
+package com.example.mody.domain.bodytype.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QAnswer is a Querydsl query type for Answer
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QAnswer extends EntityPathBase {
+
+ private static final long serialVersionUID = -978464325L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QAnswer answer = new QAnswer("answer");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final StringPath content = createString("content");
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath imageUrl = createString("imageUrl");
+
+ public final QQuestion question;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QAnswer(String variable) {
+ this(Answer.class, forVariable(variable), INITS);
+ }
+
+ public QAnswer(Path extends Answer> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QAnswer(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QAnswer(PathMetadata metadata, PathInits inits) {
+ this(Answer.class, metadata, inits);
+ }
+
+ public QAnswer(Class extends Answer> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.question = inits.isInitialized("question") ? new QQuestion(forProperty("question")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java
new file mode 100644
index 00000000..8567cda9
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java
@@ -0,0 +1,53 @@
+package com.example.mody.domain.bodytype.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QBodyType is a Querydsl query type for BodyType
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QBodyType extends EntityPathBase {
+
+ private static final long serialVersionUID = -1799118151L;
+
+ public static final QBodyType bodyType = new QBodyType("bodyType");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final ListPath memberBodyTypeList = this.createList("memberBodyTypeList", com.example.mody.domain.bodytype.entity.mapping.MemberBodyType.class, com.example.mody.domain.bodytype.entity.mapping.QMemberBodyType.class, PathInits.DIRECT2);
+
+ public final StringPath name = createString("name");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QBodyType(String variable) {
+ super(BodyType.class, forVariable(variable));
+ }
+
+ public QBodyType(Path extends BodyType> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QBodyType(PathMetadata metadata) {
+ super(BodyType.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java
new file mode 100644
index 00000000..4dd9ffda
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java
@@ -0,0 +1,53 @@
+package com.example.mody.domain.bodytype.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QQuestion is a Querydsl query type for Question
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QQuestion extends EntityPathBase {
+
+ private static final long serialVersionUID = -372686173L;
+
+ public static final QQuestion question = new QQuestion("question");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final ListPath answerList = this.createList("answerList", Answer.class, QAnswer.class, PathInits.DIRECT2);
+
+ public final StringPath content = createString("content");
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QQuestion(String variable) {
+ super(Question.class, forVariable(variable));
+ }
+
+ public QQuestion(Path extends Question> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QQuestion(PathMetadata metadata) {
+ super(Question.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java
new file mode 100644
index 00000000..a4ed1cf4
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java
@@ -0,0 +1,65 @@
+package com.example.mody.domain.bodytype.entity.mapping;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QMemberAnswer is a Querydsl query type for MemberAnswer
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMemberAnswer extends EntityPathBase {
+
+ private static final long serialVersionUID = 571481941L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QMemberAnswer memberAnswer = new QMemberAnswer("memberAnswer");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final com.example.mody.domain.bodytype.entity.QAnswer answer;
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QMemberAnswer(String variable) {
+ this(MemberAnswer.class, forVariable(variable), INITS);
+ }
+
+ public QMemberAnswer(Path extends MemberAnswer> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QMemberAnswer(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QMemberAnswer(PathMetadata metadata, PathInits inits) {
+ this(MemberAnswer.class, metadata, inits);
+ }
+
+ public QMemberAnswer(Class extends MemberAnswer> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.answer = inits.isInitialized("answer") ? new com.example.mody.domain.bodytype.entity.QAnswer(forProperty("answer"), inits.get("answer")) : null;
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java
new file mode 100644
index 00000000..7d38ecdf
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java
@@ -0,0 +1,67 @@
+package com.example.mody.domain.bodytype.entity.mapping;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QMemberBodyType is a Querydsl query type for MemberBodyType
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMemberBodyType extends EntityPathBase {
+
+ private static final long serialVersionUID = 1640559059L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QMemberBodyType memberBodyType = new QMemberBodyType("memberBodyType");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final StringPath body = createString("body");
+
+ public final com.example.mody.domain.bodytype.entity.QBodyType bodyType;
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QMemberBodyType(String variable) {
+ this(MemberBodyType.class, forVariable(variable), INITS);
+ }
+
+ public QMemberBodyType(Path extends MemberBodyType> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QMemberBodyType(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QMemberBodyType(PathMetadata metadata, PathInits inits) {
+ this(MemberBodyType.class, metadata, inits);
+ }
+
+ public QMemberBodyType(Class extends MemberBodyType> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.bodyType = inits.isInitialized("bodyType") ? new com.example.mody.domain.bodytype.entity.QBodyType(forProperty("bodyType")) : null;
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java b/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java
new file mode 100644
index 00000000..cfd4ebfb
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java
@@ -0,0 +1,54 @@
+package com.example.mody.domain.file.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QBackupFile is a Querydsl query type for BackupFile
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QBackupFile extends EntityPathBase {
+
+ private static final long serialVersionUID = -1486765701L;
+
+ public static final QBackupFile backupFile = new QBackupFile("backupFile");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final StringPath fileName = createString("fileName");
+
+ public final NumberPath fileSize = createNumber("fileSize", Long.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath s3Url = createString("s3Url");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QBackupFile(String variable) {
+ super(BackupFile.class, forVariable(variable));
+ }
+
+ public QBackupFile(Path extends BackupFile> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QBackupFile(PathMetadata metadata) {
+ super(BackupFile.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/member/entity/QMember.java b/src/main/generated/com/example/mody/domain/member/entity/QMember.java
new file mode 100644
index 00000000..51045878
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/member/entity/QMember.java
@@ -0,0 +1,89 @@
+package com.example.mody.domain.member.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QMember is a Querydsl query type for Member
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMember extends EntityPathBase {
+
+ private static final long serialVersionUID = -372438059L;
+
+ public static final QMember member = new QMember("member1");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final DatePath birthDate = createDate("birthDate", java.time.LocalDate.class);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final StringPath email = createString("email");
+
+ public final EnumPath gender = createEnum("gender", com.example.mody.domain.member.enums.Gender.class);
+
+ public final NumberPath height = createNumber("height", Integer.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final BooleanPath isRegistrationCompleted = createBoolean("isRegistrationCompleted");
+
+ public final NumberPath likeCount = createNumber("likeCount", Integer.class);
+
+ public final ListPath likes = this.createList("likes", com.example.mody.domain.post.entity.mapping.MemberPostLike.class, com.example.mody.domain.post.entity.mapping.QMemberPostLike.class, PathInits.DIRECT2);
+
+ public final EnumPath loginType = createEnum("loginType", com.example.mody.domain.member.enums.LoginType.class);
+
+ public final ListPath memberBodyType = this.createList("memberBodyType", com.example.mody.domain.bodytype.entity.mapping.MemberBodyType.class, com.example.mody.domain.bodytype.entity.mapping.QMemberBodyType.class, PathInits.DIRECT2);
+
+ public final StringPath nickname = createString("nickname");
+
+ public final StringPath password = createString("password");
+
+ public final ListPath posts = this.createList("posts", com.example.mody.domain.post.entity.Post.class, com.example.mody.domain.post.entity.QPost.class, PathInits.DIRECT2);
+
+ public final StringPath profileImageUrl = createString("profileImageUrl");
+
+ public final StringPath provider = createString("provider");
+
+ public final StringPath providerId = createString("providerId");
+
+ public final ListPath recommendations = this.createList("recommendations", com.example.mody.domain.recommendation.entity.Recommendation.class, com.example.mody.domain.recommendation.entity.QRecommendation.class, PathInits.DIRECT2);
+
+ public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2);
+
+ public final NumberPath reportCount = createNumber("reportCount", Integer.class);
+
+ public final EnumPath role = createEnum("role", com.example.mody.domain.member.enums.Role.class);
+
+ public final EnumPath status = createEnum("status", com.example.mody.domain.member.enums.Status.class);
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QMember(String variable) {
+ super(Member.class, forVariable(variable));
+ }
+
+ public QMember(Path extends Member> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QMember(PathMetadata metadata) {
+ super(Member.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/post/entity/QPost.java b/src/main/generated/com/example/mody/domain/post/entity/QPost.java
new file mode 100644
index 00000000..e2b82896
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/post/entity/QPost.java
@@ -0,0 +1,77 @@
+package com.example.mody.domain.post.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QPost is a Querydsl query type for Post
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QPost extends EntityPathBase {
+
+ private static final long serialVersionUID = 874386081L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QPost post = new QPost("post");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final com.example.mody.domain.bodytype.entity.QBodyType bodyType;
+
+ public final StringPath content = createString("content");
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final ListPath images = this.createList("images", PostImage.class, QPostImage.class, PathInits.DIRECT2);
+
+ public final BooleanPath isPublic = createBoolean("isPublic");
+
+ public final NumberPath likeCount = createNumber("likeCount", Integer.class);
+
+ public final ListPath likes = this.createList("likes", com.example.mody.domain.post.entity.mapping.MemberPostLike.class, com.example.mody.domain.post.entity.mapping.QMemberPostLike.class, PathInits.DIRECT2);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final NumberPath reportCount = createNumber("reportCount", Integer.class);
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QPost(String variable) {
+ this(Post.class, forVariable(variable), INITS);
+ }
+
+ public QPost(Path extends Post> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QPost(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QPost(PathMetadata metadata, PathInits inits) {
+ this(Post.class, metadata, inits);
+ }
+
+ public QPost(Class extends Post> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.bodyType = inits.isInitialized("bodyType") ? new com.example.mody.domain.bodytype.entity.QBodyType(forProperty("bodyType")) : null;
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java b/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java
new file mode 100644
index 00000000..c1792073
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java
@@ -0,0 +1,64 @@
+package com.example.mody.domain.post.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QPostImage is a Querydsl query type for PostImage
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QPostImage extends EntityPathBase {
+
+ private static final long serialVersionUID = 2094080826L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QPostImage postImage = new QPostImage("postImage");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final QPost post;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public final StringPath url = createString("url");
+
+ public QPostImage(String variable) {
+ this(PostImage.class, forVariable(variable), INITS);
+ }
+
+ public QPostImage(Path extends PostImage> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QPostImage(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QPostImage(PathMetadata metadata, PathInits inits) {
+ this(PostImage.class, metadata, inits);
+ }
+
+ public QPostImage(Class extends PostImage> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.post = inits.isInitialized("post") ? new QPost(forProperty("post"), inits.get("post")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java b/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java
new file mode 100644
index 00000000..3d9ecd73
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java
@@ -0,0 +1,65 @@
+package com.example.mody.domain.post.entity.mapping;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QMemberPostLike is a Querydsl query type for MemberPostLike
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMemberPostLike extends EntityPathBase {
+
+ private static final long serialVersionUID = -1295047182L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QMemberPostLike memberPostLike = new QMemberPostLike("memberPostLike");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final com.example.mody.domain.post.entity.QPost post;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QMemberPostLike(String variable) {
+ this(MemberPostLike.class, forVariable(variable), INITS);
+ }
+
+ public QMemberPostLike(Path extends MemberPostLike> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QMemberPostLike(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QMemberPostLike(PathMetadata metadata, PathInits inits) {
+ this(MemberPostLike.class, metadata, inits);
+ }
+
+ public QMemberPostLike(Class extends MemberPostLike> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ this.post = inits.isInitialized("post") ? new com.example.mody.domain.post.entity.QPost(forProperty("post"), inits.get("post")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java b/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java
new file mode 100644
index 00000000..c6b717f7
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java
@@ -0,0 +1,65 @@
+package com.example.mody.domain.post.entity.mapping;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QPostReport is a Querydsl query type for PostReport
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QPostReport extends EntityPathBase {
+
+ private static final long serialVersionUID = 902725461L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QPostReport postReport = new QPostReport("postReport");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final com.example.mody.domain.post.entity.QPost post;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QPostReport(String variable) {
+ this(PostReport.class, forVariable(variable), INITS);
+ }
+
+ public QPostReport(Path extends PostReport> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QPostReport(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QPostReport(PathMetadata metadata, PathInits inits) {
+ this(PostReport.class, metadata, inits);
+ }
+
+ public QPostReport(Class extends PostReport> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ this.post = inits.isInitialized("post") ? new com.example.mody.domain.post.entity.QPost(forProperty("post"), inits.get("post")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java b/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java
new file mode 100644
index 00000000..6f430bb7
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java
@@ -0,0 +1,21 @@
+package com.example.mody.domain.recommendation.dto.response;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.ConstructorExpression;
+import javax.annotation.processing.Generated;
+
+/**
+ * com.example.mody.domain.recommendation.dto.response.QRecommendResponse is a Querydsl Projection type for RecommendResponse
+ */
+@Generated("com.querydsl.codegen.DefaultProjectionSerializer")
+public class QRecommendResponse extends ConstructorExpression {
+
+ private static final long serialVersionUID = 1780340304L;
+
+ public QRecommendResponse(com.querydsl.core.types.Expression memberId, com.querydsl.core.types.Expression nickname, com.querydsl.core.types.Expression recommendationId, com.querydsl.core.types.Expression recommendType, com.querydsl.core.types.Expression isLiked, com.querydsl.core.types.Expression title, com.querydsl.core.types.Expression content, com.querydsl.core.types.Expression imageUrl) {
+ super(RecommendResponse.class, new Class>[]{long.class, String.class, long.class, com.example.mody.domain.recommendation.enums.RecommendType.class, boolean.class, String.class, String.class, String.class}, memberId, nickname, recommendationId, recommendType, isLiked, title, content, imageUrl);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java
new file mode 100644
index 00000000..e5e23219
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java
@@ -0,0 +1,74 @@
+package com.example.mody.domain.recommendation.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QRecommendation is a Querydsl query type for Recommendation
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QRecommendation extends EntityPathBase {
+
+ private static final long serialVersionUID = 1413846483L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QRecommendation recommendation = new QRecommendation("recommendation");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ public final StringPath content = createString("content");
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath imageUrl = createString("imageUrl");
+
+ public final NumberPath likeCount = createNumber("likeCount", Integer.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2);
+
+ public final EnumPath recommendType = createEnum("recommendType", com.example.mody.domain.recommendation.enums.RecommendType.class);
+
+ public final StringPath title = createString("title");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QRecommendation(String variable) {
+ this(Recommendation.class, forVariable(variable), INITS);
+ }
+
+ public QRecommendation(Path extends Recommendation> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QRecommendation(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QRecommendation(PathMetadata metadata, PathInits inits) {
+ this(Recommendation.class, metadata, inits);
+ }
+
+ public QRecommendation(Class extends Recommendation> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java
new file mode 100644
index 00000000..9741fcaf
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java
@@ -0,0 +1,50 @@
+package com.example.mody.domain.recommendation.entity.category;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QAppealCategory is a Querydsl query type for AppealCategory
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QAppealCategory extends EntityPathBase {
+
+ private static final long serialVersionUID = -2052142589L;
+
+ public static final QAppealCategory appealCategory = new QAppealCategory("appealCategory");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath name = createString("name");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QAppealCategory(String variable) {
+ super(AppealCategory.class, forVariable(variable));
+ }
+
+ public QAppealCategory(Path extends AppealCategory> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QAppealCategory(PathMetadata metadata) {
+ super(AppealCategory.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java
new file mode 100644
index 00000000..d4506081
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java
@@ -0,0 +1,50 @@
+package com.example.mody.domain.recommendation.entity.category;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QStyleCategory is a Querydsl query type for StyleCategory
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QStyleCategory extends EntityPathBase {
+
+ private static final long serialVersionUID = 1183613113L;
+
+ public static final QStyleCategory styleCategory = new QStyleCategory("styleCategory");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath name = createString("name");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QStyleCategory(String variable) {
+ super(StyleCategory.class, forVariable(variable));
+ }
+
+ public QStyleCategory(Path extends StyleCategory> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QStyleCategory(PathMetadata metadata) {
+ super(StyleCategory.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java b/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java
new file mode 100644
index 00000000..0def67a8
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java
@@ -0,0 +1,65 @@
+package com.example.mody.domain.recommendation.entity.mapping;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QMemberRecommendationLike is a Querydsl query type for MemberRecommendationLike
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMemberRecommendationLike extends EntityPathBase {
+
+ private static final long serialVersionUID = -2009073692L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QMemberRecommendationLike memberRecommendationLike = new QMemberRecommendationLike("memberRecommendationLike");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final com.example.mody.domain.member.entity.QMember member;
+
+ public final com.example.mody.domain.recommendation.entity.QRecommendation recommendation;
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QMemberRecommendationLike(String variable) {
+ this(MemberRecommendationLike.class, forVariable(variable), INITS);
+ }
+
+ public QMemberRecommendationLike(Path extends MemberRecommendationLike> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QMemberRecommendationLike(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QMemberRecommendationLike(PathMetadata metadata, PathInits inits) {
+ this(MemberRecommendationLike.class, metadata, inits);
+ }
+
+ public QMemberRecommendationLike(Class extends MemberRecommendationLike> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null;
+ this.recommendation = inits.isInitialized("recommendation") ? new com.example.mody.domain.recommendation.entity.QRecommendation(forProperty("recommendation"), inits.get("recommendation")) : null;
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/domain/test/entity/QTest.java b/src/main/generated/com/example/mody/domain/test/entity/QTest.java
new file mode 100644
index 00000000..234f9a9c
--- /dev/null
+++ b/src/main/generated/com/example/mody/domain/test/entity/QTest.java
@@ -0,0 +1,50 @@
+package com.example.mody.domain.test.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QTest is a Querydsl query type for Test
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QTest extends EntityPathBase {
+
+ private static final long serialVersionUID = 936710021L;
+
+ public static final QTest test = new QTest("test");
+
+ public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this);
+
+ //inherited
+ public final DateTimePath createdAt = _super.createdAt;
+
+ //inherited
+ public final DateTimePath deletedAt = _super.deletedAt;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath name = createString("name");
+
+ //inherited
+ public final DateTimePath updatedAt = _super.updatedAt;
+
+ public QTest(String variable) {
+ super(Test.class, forVariable(variable));
+ }
+
+ public QTest(Path extends Test> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QTest(PathMetadata metadata) {
+ super(Test.class, metadata);
+ }
+
+}
+
diff --git a/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java b/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java
new file mode 100644
index 00000000..f9f3f002
--- /dev/null
+++ b/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java
@@ -0,0 +1,41 @@
+package com.example.mody.global.common.base;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QBaseEntity is a Querydsl query type for BaseEntity
+ */
+@Generated("com.querydsl.codegen.DefaultSupertypeSerializer")
+public class QBaseEntity extends EntityPathBase {
+
+ private static final long serialVersionUID = 425151923L;
+
+ public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity");
+
+ public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class);
+
+ public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class);
+
+ public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class);
+
+ public QBaseEntity(String variable) {
+ super(BaseEntity.class, forVariable(variable));
+ }
+
+ public QBaseEntity(Path extends BaseEntity> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QBaseEntity(PathMetadata metadata) {
+ super(BaseEntity.class, metadata);
+ }
+
+}
+
diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java
index f69d93ea..bf1d12f9 100644
--- a/src/main/java/com/example/mody/ModyApplication.java
+++ b/src/main/java/com/example/mody/ModyApplication.java
@@ -2,8 +2,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+//
@SpringBootApplication
+@EnableJpaAuditing
public class ModyApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java
new file mode 100644
index 00000000..25e39852
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java
@@ -0,0 +1,512 @@
+package com.example.mody.domain.auth.controller;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseCookie;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.CookieValue;
+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.RestController;
+
+import com.example.mody.domain.auth.dto.request.EmailRequest;
+import com.example.mody.domain.auth.dto.request.EmailVerificationRequest;
+import com.example.mody.domain.auth.dto.request.MemberJoinRequest;
+import com.example.mody.domain.auth.dto.request.MemberLoginReqeust;
+import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest;
+import com.example.mody.domain.auth.dto.response.AccessTokenResponse;
+import com.example.mody.domain.auth.dto.response.LoginResponse;
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.auth.service.AuthCommandService;
+import com.example.mody.domain.auth.service.email.EmailService;
+import com.example.mody.domain.member.service.MemberCommandService;
+import com.example.mody.global.common.base.BaseResponse;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AuthErrorStatus;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.headers.Header;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@Tag(name = "Auth API", description = "인증 관련 API - 회원가입, 로그인, 토큰 재발급, 로그아웃 등의 기능을 제공합니다.")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/auth")
+public class AuthController {
+
+ private static final Logger log = LoggerFactory.getLogger(AuthController.class);
+ private final AuthCommandService authCommandService;
+ private final MemberCommandService memberCommandService;
+ private final EmailService emailService;
+
+ @Operation(
+ summary = "회원가입 완료",
+ description = """
+ 소셜 로그인 혹은 자체 회원가입 후 추가 정보를 입력받아 회원가입을 완료합니다.
+ - 카카오 로그인 시, 로그인 성공 후 신규 회원인 경우 호출해야하는 API입니다.
+ - 자체 회원 가입 시, /auth/signup 성공 후 /auth/login으로 로그인 후 호출해야하는 API입니다.
+ (자체 회원가입 API에서는 이메일, 비밀번호만 입력받고 현재 API를 호출하여 회원가입을 완료합니다.)
+ """
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "회원가입 완료 성공",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "COMMON200",
+ "message": "요청에 성공하였습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "COMMON400",
+ "message": "필수 정보가 누락되었습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "사용자를 찾을 수 없음",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "MEMBER404",
+ "message": "해당 회원을 찾을 수 없습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/signup/complete")
+ public BaseResponse completeRegistration(
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ @Valid @RequestBody
+ @Parameter(
+ description = "회원가입 완료 요청 정보",
+ required = true,
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = MemberRegistrationRequest.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "nickname": "사용자닉네임",
+ "birthDate": "2000-01-01",
+ "gender": "MALE",
+ "height": 180,
+ "profileImageUrl": "https://example.com/profile.jpg"
+ }
+ """
+ )
+ )
+ ) MemberRegistrationRequest request
+ ) {
+ memberCommandService.completeRegistration(userDetails.getMember().getId(), request);
+ return BaseResponse.onSuccess(null);
+ }
+
+ @Operation(
+ summary = "토큰 재발급",
+ description = """
+ Access Token이 만료되었을 때 Refresh Token을 사용하여 새로운 토큰을 발급받습니다.
+ Refresh Token은 쿠키에서 자동으로 추출되며, 새로운 Access Token과 Refresh Token이 발급됩니다.
+ 발급된 Access Token은 응답 헤더의 Authorization에, Refresh Token은 쿠키에 포함됩니다.
+ """
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "토큰 재발급 성공",
+ headers = {
+ @Header(
+ name = "Authorization",
+ description = "새로 발급된 Access Token",
+ schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...")
+ ),
+ @Header(
+ name = "Set-Cookie",
+ description = "새로 발급된 Refresh Token (HttpOnly Cookie)",
+ schema = @Schema(type = "string", example = "refresh_token=eyJhbGciOiJIUzI1...; Path=/; HttpOnly; Secure; SameSite=Strict")
+ )
+ },
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "COMMON200",
+ "message": "요청에 성공하였습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "REFRESH_TOKEN404",
+ description = "유효하지 않은 리프레쉬 토큰일 때 발생합니다. (디비에 동일한 리프레쉬 토큰이 없을 때 발생)",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "REFRESH_TOKEN404",
+ "message": "REFRESH TOKEN이 유효하지 않습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/reissue")
+ public BaseResponse reissueToken(
+ @CookieValue(name = "refresh_token") String refreshToken,
+ HttpServletResponse response
+ ) {
+ log.info("refreshToken: {}", refreshToken);
+ return BaseResponse.onSuccess(authCommandService.reissueToken(refreshToken, response));
+ }
+
+ @Operation(
+ summary = "로그아웃",
+ description = """
+ 사용자를 로그아웃 처리합니다.
+ 서버에서 Refresh Token을 삭제하고, 클라이언트의 쿠키에서도 Refresh Token을 제거합니다.
+ 클라이언트에서는 저장된 Access Token도 함께 삭제해야 합니다.
+ """,
+ security = @SecurityRequirement(name = "Bearer Authentication")
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "로그아웃 성공",
+ headers = {
+ @Header(
+ name = "Set-Cookie",
+ description = "Refresh Token 제거를 위한 쿠키",
+ schema = @Schema(type = "string", example = "refresh_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict")
+ )
+ },
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "COMMON200",
+ "message": "요청에 성공하였습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "AUTH401",
+ description = "인증되지 않은 사용자",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "AUTH001",
+ "message": "JWT가 없습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "REFRESH_TOKEN404",
+ description = "유효하지 않은 Refresh Token",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "REFRESH_TOKEN404",
+ "message": "REFRESH TOKEN이 유효하지 않습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/logout")
+ public BaseResponse logout(
+ @CookieValue(name = "refresh_token")
+ @Parameter(
+ description = "리프레시 토큰 (쿠키에서 자동 추출)",
+ required = true
+ ) String refreshToken,
+ HttpServletResponse response
+ ) {
+ authCommandService.logout(refreshToken);
+
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "")
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .maxAge(0)
+ .path("/")
+ .build();
+
+ response.setHeader("Set-Cookie", refreshTokenCookie.toString());
+ return BaseResponse.onSuccess(null);
+ }
+
+ @Operation(
+ summary = "일반 회원가입 API",
+ description = "이메일과 비밀번호를 사용하여 새로운 회원을 등록합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "회원가입 성공",
+ headers = {
+ @io.swagger.v3.oas.annotations.headers.Header(
+ name = "Authorization",
+ description = "Access Token",
+ schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...")
+ ),
+ @io.swagger.v3.oas.annotations.headers.Header(
+ name = "Set-Cookie",
+ description = "Refresh Token (HttpOnly Cookie)",
+ schema = @Schema(type = "string")
+ )
+ },
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class)
+ )
+ ),
+ @ApiResponse(
+ responseCode = "COMMON400",
+ description = "잘못된 요청 데이터",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "COMMON400",
+ "message": "유효하지 않은 이메일 형식입니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "MEMBER409",
+ description = "이미 존재하는 이메일",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "MEMBER409",
+ "message": "이미 등록된 이메일입니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "COMMON402",
+ description = "올바르지 않은 비밀번호 형식",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-27T00:08:57.1421127",
+ "code": "COMMON402",
+ "message": "Validation Error입니다.",
+ "result": {
+ "password": "비밀번호는 8자 이상, 영어와 숫자, 그리고 특수문자(@$!%*?)를 포함해야 하며, 한글은 사용할 수 없습니다."
+ }
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/signup")
+ public BaseResponse joinMember(
+ @Valid @RequestBody
+ @Parameter(
+ description = "회원가입 요청 정보",
+ required = true,
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = MemberJoinRequest.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "email": "user@example.com",
+ "password": "Password123!",
+ "nickname": "사용자닉네임"
+ }
+ """
+ )
+ )
+ ) MemberJoinRequest request,
+ HttpServletResponse response
+ ) {
+ LoginResponse loginResponse = memberCommandService.joinMember(request, response);
+ return BaseResponse.onSuccess(loginResponse);
+ }
+
+ /**
+ * Swagger 명세를 위한 테스트 controller
+ * 실제 동작은 이 controller를 거치지 않고 JwtLoginFilter를 통해 이루어집니다.
+ * @param loginRequest
+ * @return
+ */
+ @Operation(
+ summary = "로그인 API",
+ description = "이메일과 비밀번호를 사용하여 로그인합니다. 성공 시 Access Token과 Refresh Token이 발급됩니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "로그인 성공",
+ headers = {
+ @io.swagger.v3.oas.annotations.headers.Header(
+ name = "Authorization",
+ description = "Access Token",
+ schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...")
+ ),
+ @io.swagger.v3.oas.annotations.headers.Header(
+ name = "Set-Cookie",
+ description = "Refresh Token (HttpOnly Cookie)",
+ schema = @Schema(type = "string")
+ )
+ },
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class)
+ )
+ ),
+ @ApiResponse(
+ responseCode = "AUTH_INVALID_PASSWORD",
+ description = "올바르지 않은 비밀번호를 입력함",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "code": "AUTH_INVALID_PASSWORD",
+ "message": "비밀번호가 올바르지 않습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "MEMBER404",
+ description = "입력한 이메일을 가진 사용자가 없을 때 발생",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-27T00:12:53.6087824",
+ "code": "MEMBER404",
+ "message": "해당 회원을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/login")
+ public BaseResponse authLogin(
+ @RequestBody @Valid
+ @Parameter(
+ description = "로그인 요청 정보",
+ required = true,
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = MemberLoginReqeust.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "email": "user@example.com",
+ "password": "Password123!"
+ }
+ """
+ )
+ )
+ ) MemberLoginReqeust loginRequest
+ ) {
+
+ return BaseResponse.onSuccess(null);
+ }
+
+ @Operation(summary = "인증 메일 발송", description = "입력된 이메일로 인증번호를 발송합니다.")
+ @PostMapping("/email/verify/send")
+ public BaseResponse sendVerificationEmail(@Valid @RequestBody EmailRequest request) {
+ emailService.sendVerificationEmail(request.getEmail());
+ return BaseResponse.onSuccess(null);
+ }
+
+ @Operation(summary = "이메일 인증", description = "발송된 인증번호를 확인합니다.")
+ @PostMapping("/email/verify")
+ public BaseResponse verifyEmail(@Valid @RequestBody EmailVerificationRequest request) {
+ if (!emailService.verifyEmail(request.getEmail(), request.getVerificationCode())) {
+ throw new RestApiException(AuthErrorStatus.INVALID_VERIFICATION_CODE);
+ }
+ return BaseResponse.onSuccess(null);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java b/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java
new file mode 100644
index 00000000..1e1c2c94
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java
@@ -0,0 +1,17 @@
+package com.example.mody.domain.auth.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TokenDto {
+ private String grantType;
+ private String accessToken;
+ private String refreshToken;
+ private Long accessTokenExpiresIn;
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java
new file mode 100644
index 00000000..20e57747
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java
@@ -0,0 +1,21 @@
+package com.example.mody.domain.auth.dto.request;
+
+import com.example.mody.domain.auth.exception.annotation.UniqueEmail;
+
+import jakarta.validation.constraints.Email;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class EmailRequest {
+ @Email
+ @UniqueEmail
+ private String email;
+}
+
diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java
new file mode 100644
index 00000000..b5b465d6
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java
@@ -0,0 +1,20 @@
+package com.example.mody.domain.auth.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class EmailVerificationRequest {
+ @Email
+ private String email;
+ @NotBlank
+ private String verificationCode;
+}
diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java
new file mode 100644
index 00000000..8221e497
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.auth.dto.request;
+
+import com.example.mody.domain.member.enums.Gender;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+
+@Schema(description = "자체 회원가입 요청 DTO")
+@Getter
+@NoArgsConstructor
+public class MemberJoinRequest {
+ @Schema(
+ description = "이메일",
+ example = "user@example.com"
+ )
+ @NotNull(message = "이메일은 필수입니다")
+ @Email
+ private String email;
+
+ @Schema(
+ description = "비밀번호",
+ example = "user1234!"
+ )
+ @NotNull(message = "비밀번호는 필수입니다")
+ @Pattern(
+ regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?])[A-Za-z\\d@$!%*?]{8,}$",
+ message = "비밀번호는 8자 이상, 영어와 숫자, 그리고 특수문자(@$!%*?)를 포함해야 하며, 한글은 사용할 수 없습니다."
+ )
+ private String password;
+}
diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java
new file mode 100644
index 00000000..ca24c064
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java
@@ -0,0 +1,27 @@
+package com.example.mody.domain.auth.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class MemberLoginReqeust {
+
+ @Schema(
+ description = "이메일",
+ example = "user@example.com"
+ )
+ @NotNull(message = "이메일은 필수입니다")
+ @Email
+ private String email;
+
+ @Schema(
+ description = "비밀번호",
+ example = "user1234!"
+ )
+ @NotNull(message = "비밀번호는 필수입니다")
+ private String password;
+}
diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java
new file mode 100644
index 00000000..6b2d8945
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java
@@ -0,0 +1,66 @@
+package com.example.mody.domain.auth.dto.request;
+
+import java.time.LocalDate;
+
+import com.example.mody.domain.member.enums.Gender;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Past;
+import jakarta.validation.constraints.Positive;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 회원 가입할 때 필요한 정보들
+ */
+@Schema(description = "회원가입 요청 DTO")
+@Getter
+@NoArgsConstructor
+public class MemberRegistrationRequest {
+
+ @Schema(
+ description = "닉네임",
+ example = "모디",
+ minLength = 2,
+ maxLength = 20
+ )
+ @NotBlank(message = "닉네임은 필수입니다")
+ private String nickname;
+
+ @Schema(
+ description = "생년월일",
+ example = "1990-01-01",
+ type = "string",
+ format = "date"
+ )
+ @NotNull(message = "생년월일은 필수입니다")
+ @Past(message = "생년월일은 과거 날짜여야 합니다")
+ private LocalDate birthDate;
+
+ @Schema(
+ description = "성별",
+ example = "MALE",
+ allowableValues = {"MALE", "FEMALE"}
+ )
+ @NotNull(message = "성별은 필수입니다")
+ private Gender gender;
+
+ @Schema(
+ description = "키(cm)",
+ example = "170",
+ minimum = "100",
+ maximum = "250"
+ )
+ @NotNull(message = "키는 필수입니다")
+ @Positive(message = "키는 양수여야 합니다")
+ private Integer height;
+
+ @Schema(
+ description = "프로필 이미지 URL",
+ example = "https://my-bucket.s3.amazonaws.com/path/to/file.jpg",
+ required = false
+ )
+ private String profileImageUrl;
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java
new file mode 100644
index 00000000..d10fd404
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java
@@ -0,0 +1,17 @@
+package com.example.mody.domain.auth.dto.response;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class AccessTokenResponse {
+
+ private String accessToken;
+
+}
diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java
new file mode 100644
index 00000000..c82e9f09
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.auth.dto.response;
+
+import java.util.Map;
+
+import lombok.Getter;
+
+@Getter
+public class KakaoResponse implements OAuth2Response {
+ private Map attribute;
+ private Map kakaoAccount;
+ private Map profile;
+
+ public KakaoResponse(Map attribute) {
+ this.attribute = attribute;
+ this.kakaoAccount = (Map)attribute.get("kakao_account");
+ this.profile = (Map)kakaoAccount.get("profile");
+ }
+
+ @Override
+ public String getProvider() {
+ return "kakao";
+ }
+
+ @Override
+ public String getProviderId() {
+ return attribute.get("id").toString();
+ }
+
+ @Override
+ public String getName() {
+ return profile.get("nickname").toString();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java
new file mode 100644
index 00000000..55e9cafa
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java
@@ -0,0 +1,55 @@
+package com.example.mody.domain.auth.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Schema(description = "로그인 응답 DTO")
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class LoginResponse {
+ @Schema(
+ description = "회원 ID",
+ example = "1"
+ )
+ private Long memberId;
+
+ @Schema(
+ description = "닉네임",
+ example = "모디"
+ )
+ private String nickname;
+
+ @Schema(
+ description = "신규 회원 여부",
+ example = "true"
+ )
+ private boolean isNewMember;
+
+ @Schema(
+ description = "회원가입 완료 여부",
+ example = "false"
+ )
+ private boolean isRegistrationCompleted;
+
+ @Schema(
+ description = "access 토큰"
+ )
+ private String accessToken;
+
+ public static LoginResponse of(Long memberId, String nickname, boolean isNewMember,
+ boolean isRegistrationCompleted, String newAccessToken) {
+ return LoginResponse.builder()
+ .memberId(memberId)
+ .nickname(nickname)
+ .isNewMember(isNewMember)
+ .isRegistrationCompleted(isRegistrationCompleted)
+ .accessToken(newAccessToken)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java b/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java
new file mode 100644
index 00000000..162bbda4
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java
@@ -0,0 +1,13 @@
+package com.example.mody.domain.auth.dto.response;
+
+public interface OAuth2Response {
+
+ //제공자 (Ex. naver, google, ...)
+ String getProvider();
+
+ //제공자에서 발급해주는 아이디(번호)
+ String getProviderId();
+
+ //사용자 실명 (설정한 이름)
+ String getName();
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java b/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java
new file mode 100644
index 00000000..7ab55a8d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java
@@ -0,0 +1,49 @@
+package com.example.mody.domain.auth.entity;
+
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.global.common.base.BaseEntity;
+
+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.OneToOne;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Entity
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@DynamicUpdate
+@DynamicInsert
+@Table(name = "refresh_token")
+public class RefreshToken extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "refresh_token_id")
+ private Long id;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @Column(nullable = false, unique = true)
+ private String token;
+
+ public void updateToken(String token) {
+ this.token = token;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java b/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java
new file mode 100644
index 00000000..0f2c7c77
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java
@@ -0,0 +1,24 @@
+package com.example.mody.domain.auth.exception.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.example.mody.domain.auth.exception.validator.ExistsEmailValidator;
+
+import jakarta.validation.Constraint;
+
+@Documented
+@Constraint(validatedBy = ExistsEmailValidator.class)
+@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface UniqueEmail {
+
+ String message() default "이미 존재하는 이메일입니다.";
+
+ Class>[] groups() default {};
+
+ Class>[] payload() default {};
+}
diff --git a/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java b/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java
new file mode 100644
index 00000000..7167cdf3
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java
@@ -0,0 +1,38 @@
+package com.example.mody.domain.auth.exception.validator;
+
+import org.springframework.stereotype.Component;
+
+import com.example.mody.domain.auth.exception.annotation.UniqueEmail;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class ExistsEmailValidator implements ConstraintValidator {
+
+ private final MemberRepository memberRepository;
+
+ @Override
+ public void initialize(UniqueEmail constraintAnnotation) {
+ ConstraintValidator.super.initialize(constraintAnnotation);
+ }
+
+ @Override
+ public boolean isValid(String email, ConstraintValidatorContext context) {
+
+ if (memberRepository.existsByEmail(email)) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(MemberErrorStatus.EMAIL_ALREADY_EXISTS.getMessage())
+ .addConstraintViolation();
+ return false;
+ }
+
+ return true;
+
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java
new file mode 100644
index 00000000..ff993dbf
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java
@@ -0,0 +1,89 @@
+package com.example.mody.domain.auth.handler;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
+
+import com.example.mody.domain.auth.jwt.JwtProvider;
+import com.example.mody.domain.auth.security.CustomOAuth2User;
+import com.example.mody.domain.auth.service.AuthCommandService;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.Role;
+import com.example.mody.domain.member.enums.Status;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.util.UriComponentsBuilder;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
+
+ private final JwtProvider jwtProvider;
+ private final MemberRepository memberRepository;
+ private final AuthCommandService authCommandService;
+
+ // 프론트 엔드 주소, 환경변수에서 주입
+ @Value("${front.redirect-url.home}")
+ private String FRONT_HOME_URL;
+
+ @Value("${front.redirect-url.signup}")
+ private String FRONT_SIGNUP_URL;
+
+ /**
+ * OAuth2 로그인 성공 시 처리
+ * @param request
+ * @param response
+ * @param authentication
+ * @throws IOException
+ * @throws ServletException
+ */
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ CustomOAuth2User oAuth2User = (CustomOAuth2User)authentication.getPrincipal();
+
+ // 회원 조회 또는 생성
+ Member member = memberRepository.findByProviderId(oAuth2User.getOAuth2Response().getProviderId())
+ .orElseGet(() -> saveMember(oAuth2User));
+
+ // 새로 가입한 멤버인지 아닌지 확인
+ boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt());
+
+ // Access Token, Refresh Token 발급
+ String newAccessToken = authCommandService.processLoginSuccess(member, response);
+
+ String tempUrl = (isNewMember || !member.isRegistrationCompleted()) ? FRONT_SIGNUP_URL : FRONT_HOME_URL;
+
+ String targetUrl = UriComponentsBuilder.fromUriString(tempUrl)
+ .build().toUriString();
+
+ // 리다이렉션 수행
+ response.sendRedirect(targetUrl);
+
+ }
+
+ private Member saveMember(CustomOAuth2User oAuth2User) {
+ Member member = Member.builder()
+ .providerId(oAuth2User.getOAuth2Response().getProviderId())
+ .provider(oAuth2User.getOAuth2Response().getProvider())
+ .nickname(oAuth2User.getOAuth2Response().getName())
+ .status(Status.ACTIVE)
+ .role(Role.ROLE_USER)
+ .isRegistrationCompleted(false)
+ .build();
+
+ return memberRepository.save(member);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java
new file mode 100644
index 00000000..11599ba8
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java
@@ -0,0 +1,117 @@
+package com.example.mody.domain.auth.jwt;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.domain.member.service.MemberQueryService;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AuthErrorStatus;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+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;
+
+/**
+ * JWT 토큰을 검증하고 인증 정보를 SecurityContextHolder에 저장하는 필터
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtProvider jwtProvider;
+ private final MemberRepository memberRepository;
+ private final ObjectMapper objectMapper;
+ private final MemberQueryService memberQueryService;
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
+ String uri = request.getRequestURI();
+ String contextPath = request.getContextPath();
+ if (contextPath != null && !contextPath.isEmpty()) {
+ uri = uri.substring(contextPath.length());
+ }
+ log.info("JwtAuthenticationFilter - Request URI after context removal: {}", uri);
+ boolean skip = uri.startsWith("/auth/") && !uri.startsWith("/auth/signup/complete") ||
+ uri.startsWith("/oauth2/") ||
+ uri.startsWith("/email/") ||
+ uri.startsWith("/swagger-ui/") ||
+ uri.startsWith("/v3/api-docs/");
+ log.info("JwtAuthenticationFilter - shouldNotFilter returns: {}", skip);
+ return skip;
+
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+ try {
+ // 헤더에서 토큰 추출
+ String token = resolveToken(request);
+
+ // 만약 토큰이 있다면
+ if (token != null) {
+ String memberId = jwtProvider.validateTokenAndGetSubject(token);
+ Member member = memberRepository.findById(Long.parseLong(memberId))
+ .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN));
+
+ CustomUserDetails customUserDetails = new CustomUserDetails(member);
+
+ Authentication authentication = new UsernamePasswordAuthenticationToken(
+ customUserDetails,
+ null,
+ List.of(new SimpleGrantedAuthority(member.getRole().toString()))
+ );
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ filterChain.doFilter(request, response);
+ } catch (RestApiException e) {
+ SecurityContextHolder.clearContext();
+ sendErrorResponse(response, e);
+ } catch (Exception e) {
+ SecurityContextHolder.clearContext();
+ sendErrorResponse(response, new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN));
+ }
+ }
+
+ private String resolveToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+
+ private void sendErrorResponse(HttpServletResponse response, RestApiException exception) throws IOException {
+ response.setStatus(exception.getErrorCode().getHttpStatus().value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+
+ Map errorResponse = Map.of(
+ "timestamp", java.time.LocalDateTime.now().toString(),
+ "code", exception.getErrorCode().getCode(),
+ "message", exception.getErrorCode().getMessage()
+ );
+
+ response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java
new file mode 100644
index 00000000..0f8d319e
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java
@@ -0,0 +1,118 @@
+package com.example.mody.domain.auth.jwt;
+
+import com.example.mody.domain.auth.dto.request.MemberLoginReqeust;
+import com.example.mody.domain.auth.dto.response.LoginResponse;
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.auth.service.AuthCommandService;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.base.BaseResponse;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AuthErrorStatus;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import java.io.IOException;
+
+
+@RequiredArgsConstructor
+public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
+
+ private final AuthenticationManager authenticationManager;
+ private final JwtProvider jwtProvider;
+ private final AuthCommandService authCommandService;
+ private final MemberRepository memberRepository;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
+ throws AuthenticationException {
+
+ //Json형식 데이터 추출
+ try {
+ MemberLoginReqeust loginReqeust = objectMapper.readValue(request.getInputStream(),
+ MemberLoginReqeust.class);
+
+ UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
+ loginReqeust.getEmail(), loginReqeust.getPassword(), null);
+
+ //authenticationManager가 이메일, 비밀번호로 검증을 진행
+ return authenticationManager.authenticate(authToken);
+
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to parse authentication request body", e);
+ }
+ }
+
+ //로그인 성공시, JWT토큰 발급
+ @Override
+ public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
+ Authentication authResult) throws IOException, ServletException {
+
+ CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal();
+
+ String username = customUserDetails.getUsername();
+ Member member = memberRepository.findByEmail(username)
+ .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ID_TOKEN));
+
+ // Access Token, Refresh Token 발급
+ String newAccessToken = authCommandService.processLoginSuccess(member, response);
+
+ // 로그인 응답 데이터 설정
+ LoginResponse loginResponse = LoginResponse.of(
+ member.getId(),
+ member.getNickname(),
+ false,
+ member.isRegistrationCompleted(),
+ newAccessToken
+ );
+
+ // 응답 바디 작성
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+ response.getWriter().write(objectMapper.writeValueAsString(BaseResponse.onSuccess(loginResponse)));
+ }
+
+ //로그인 실패한 경우 응답처리
+ @Override
+ public void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException failed) throws IOException, ServletException {
+
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+
+ String errorMessage;
+ String errorCode = "AUTH401"; // 기본 에러 코드
+
+ //존재하지 않는 이메일인 경우, 비밀번호가 올라바르지 않은 경우에 따른 예외처리
+ if (failed.getCause() instanceof RestApiException) {
+ RestApiException restApiException = (RestApiException)failed.getCause();
+ errorMessage = restApiException.getErrorCode().getMessage(); //"해당 회원은 존재하지 않습니다."
+ errorCode = restApiException.getErrorCode().getCode();
+ } else if (failed instanceof BadCredentialsException) {
+ errorMessage = "비밀번호가 올바르지 않습니다.";
+ errorCode = "AUTH_INVALID_PASSWORD";
+ } else {
+ errorMessage = "인증에 실패했습니다.";
+ }
+
+ // JSON 응답 작성
+ BaseResponse errorResponse = BaseResponse.onFailure(errorCode, errorMessage, null);
+ String jsonResponse = objectMapper.writeValueAsString(errorResponse);
+ response.getWriter().write(jsonResponse);
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java
new file mode 100644
index 00000000..77899a37
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java
@@ -0,0 +1,84 @@
+package com.example.mody.domain.auth.jwt;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+import javax.crypto.SecretKey;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AuthErrorStatus;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * JWT 토큰 생성 및 검증을 담당하는 클래스
+ */
+@Slf4j
+@Component
+public class JwtProvider {
+
+ private final SecretKey secretKey;
+ private final long accessTokenValidityInMilliseconds;
+ private final long refreshTokenValidityInMilliseconds;
+
+ public JwtProvider(
+ // secret key
+ @Value("${jwt.secret}") final String secretKey,
+ // access token 유효 시간
+ @Value("${jwt.accessExpiration}") final long accessTokenValidityInMilliseconds,
+ // refresh token 유효 시간
+ @Value("${jwt.refreshExpiration}") final long refreshTokenValidityInMilliseconds
+ ) {
+ this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
+ this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;
+ this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
+ }
+
+ // access token 생성
+ public String createAccessToken(String subject) {
+ return createToken(subject, accessTokenValidityInMilliseconds);
+ }
+
+ // refresh token 생성
+ public String createRefreshToken(String subject) {
+ return createToken(subject, refreshTokenValidityInMilliseconds);
+ }
+
+ // 토큰 생성
+ private String createToken(String subject, long validityInMilliseconds) {
+ Date now = new Date();
+ Date validity = new Date(now.getTime() + validityInMilliseconds);
+
+ return Jwts.builder()
+ .subject(subject)
+ .issuedAt(now)
+ .expiration(validity)
+ .signWith(secretKey)
+ .compact();
+ }
+
+ // 토큰 검증 및 subject 반환
+ public String validateTokenAndGetSubject(String token) {
+ try {
+ return Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload()
+ .getSubject();
+ } catch (ExpiredJwtException e) {
+ throw new RestApiException(AuthErrorStatus.EXPIRED_MEMBER_JWT);
+ } catch (UnsupportedJwtException e) {
+ throw new RestApiException(AuthErrorStatus.UNSUPPORTED_JWT);
+ } catch (Exception e) {
+ throw new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN);
+ }
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java
new file mode 100644
index 00000000..e890f0bb
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.auth.repository;
+
+import java.time.Duration;
+
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Repository;
+
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class EmailVerificationRepository {
+
+ private static final String KEY_PREFIX = "email:verification:";
+ private final StringRedisTemplate redisTemplate;
+
+ public void saveVerificationCode(String email, String code, Duration duration) {
+ redisTemplate.opsForValue()
+ .set(KEY_PREFIX + email, code, duration);
+ }
+
+ public String getVerificationCode(String email) {
+ return redisTemplate.opsForValue().get(KEY_PREFIX + email);
+ }
+
+ public void removeVerificationCode(String email) {
+ redisTemplate.delete(KEY_PREFIX + email);
+ }
+
+ public boolean hasKey(String email) {
+ return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + email));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java
new file mode 100644
index 00000000..aa174adb
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java
@@ -0,0 +1,16 @@
+package com.example.mody.domain.auth.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.example.mody.domain.auth.entity.RefreshToken;
+import com.example.mody.domain.member.entity.Member;
+
+public interface RefreshTokenRepository extends JpaRepository {
+ Optional findByToken(String token);
+
+ Optional findByMember(Member member);
+
+ boolean existsByToken(String token);
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java b/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java
new file mode 100644
index 00000000..c16beae6
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java
@@ -0,0 +1,44 @@
+package com.example.mody.domain.auth.security;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+import com.example.mody.domain.auth.dto.response.OAuth2Response;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class CustomOAuth2User implements OAuth2User {
+
+ private final OAuth2Response oAuth2Response;
+ private final Map attributes;
+ private final boolean isRegistrationCompleted;
+
+ @Override
+ public Map getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ Collection authorities = new ArrayList<>();
+ authorities.add(new GrantedAuthority() {
+ @Override
+ public String getAuthority() {
+ return "ROLE_USER";
+ }
+ });
+ return authorities;
+ }
+
+ @Override
+ public String getName() {
+ return oAuth2Response.getName();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java
new file mode 100644
index 00000000..7aa87361
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java
@@ -0,0 +1,70 @@
+package com.example.mody.domain.auth.security;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import com.example.mody.domain.member.entity.Member;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class CustomUserDetails implements UserDetails {
+
+ private final Member member;
+
+ //Role return
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+
+ Collection collection = new ArrayList<>();
+
+ collection.add(new GrantedAuthority() {
+ @Override
+ public String getAuthority() {
+
+ return member.getRole().name();
+ }
+ });
+
+ return collection;
+ }
+
+ @Override
+ public String getPassword() {
+ return member.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return member.getEmail();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ public Member getMember() {
+ return this.member;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java
new file mode 100644
index 00000000..a365ef69
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java
@@ -0,0 +1,35 @@
+package com.example.mody.domain.auth.security;
+
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+import com.example.mody.domain.exception.MemberException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class CustomUserDetailsService implements UserDetailsService {
+
+ private final MemberRepository memberRepository;
+
+ //데이터베이스에서 사용자의 정보를 조회
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+
+ Member member = memberRepository.findByEmail(username)
+ .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND));
+
+ // 소셜 로그인 사용자인지 확인
+ if (member.getProvider() != null) {
+ throw new MemberException(MemberErrorStatus.INVALID_LOGIN_TYPE);
+ }
+
+ return new CustomUserDetails(member);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java
new file mode 100644
index 00000000..b68246ab
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java
@@ -0,0 +1,69 @@
+package com.example.mody.domain.auth.security;
+
+import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+
+import com.example.mody.domain.auth.dto.response.KakaoResponse;
+import com.example.mody.domain.auth.dto.response.OAuth2Response;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.LoginType;
+import com.example.mody.domain.member.enums.Role;
+import com.example.mody.domain.member.enums.Status;
+import com.example.mody.domain.member.repository.MemberRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class OAuth2UserService extends DefaultOAuth2UserService {
+
+ private final MemberRepository memberRepository;
+
+ /**
+ * OAuth2UserRequest를 통해 OAuth2User를 데이터베이스에서 로드하고 회원가입 및 로그인 처리
+ * @param userRequest
+ * @return
+ * @throws OAuth2AuthenticationException
+ */
+ @Override
+ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
+ OAuth2User oAuth2User = super.loadUser(userRequest);
+ String registrationId = userRequest.getClientRegistration().getRegistrationId();
+ final OAuth2Response oAuth2Response;
+
+ // 카카오 로그인
+ if (registrationId.equals("kakao")) {
+ oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
+ } else {
+ return null;
+ }
+
+ // 회원 정보를 조회.
+ // 만약 회원이 없다면 회원을 저장함.
+ // 그 다음 AuthController의 회원가입 API를 통해 회원가입을 완료해야 함.
+ Member member = memberRepository.findByProviderIdAndLoginType(
+ oAuth2Response.getProviderId(),
+ LoginType.KAKAO
+ )
+ .orElseGet(() -> saveMember(oAuth2Response));
+
+ return new CustomOAuth2User(oAuth2Response, oAuth2User.getAttributes(), member.isRegistrationCompleted());
+ }
+
+ private Member saveMember(OAuth2Response oAuth2Response) {
+ Member member = Member.builder()
+ .providerId(oAuth2Response.getProviderId())
+ .provider(oAuth2Response.getProvider())
+ .nickname(oAuth2Response.getName())
+ .status(Status.ACTIVE)
+ .role(Role.ROLE_USER)
+ .loginType(LoginType.KAKAO)
+ .isRegistrationCompleted(false)
+ .build();
+
+ return memberRepository.save(member);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java
new file mode 100644
index 00000000..40aea7f2
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java
@@ -0,0 +1,17 @@
+package com.example.mody.domain.auth.service;
+
+import com.example.mody.domain.auth.dto.response.AccessTokenResponse;
+import com.example.mody.domain.member.entity.Member;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+public interface AuthCommandService {
+
+ AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletResponse response);
+
+ void saveRefreshToken(Member member, String refreshToken);
+
+ void logout(String refreshToken);
+
+ String processLoginSuccess(Member member, HttpServletResponse response);
+}
diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java
new file mode 100644
index 00000000..4b6455d0
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java
@@ -0,0 +1,111 @@
+package com.example.mody.domain.auth.service;
+
+import org.springframework.http.ResponseCookie;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.example.mody.domain.auth.dto.response.AccessTokenResponse;
+import com.example.mody.domain.auth.entity.RefreshToken;
+import com.example.mody.domain.auth.jwt.JwtProvider;
+import com.example.mody.domain.auth.repository.RefreshTokenRepository;
+import com.example.mody.domain.exception.RefreshTokenException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AuthErrorStatus;
+
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class AuthCommandServiceImpl implements AuthCommandService {
+
+ private final JwtProvider jwtProvider;
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ public AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletResponse response) {
+ log.info("Client refresh token: {}", oldRefreshToken);
+ RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(oldRefreshToken)
+ .orElseThrow(() -> {
+ log.warn("DB에 저장된 refresh token과 일치하는 값이 없습니다.");
+ return new RefreshTokenException(AuthErrorStatus.INVALID_REFRESH_TOKEN);
+ });
+ log.info("DB refresh token for member {}: {}", refreshTokenEntity.getMember().getId(),
+ refreshTokenEntity.getToken());
+
+ // Refresh Token에 해당하는 회원 조회
+ Member member = refreshTokenEntity.getMember();
+
+ // 새로운 토큰 발급
+ String newAccessToken = jwtProvider.createAccessToken(member.getId().toString());
+ String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString());
+
+ // Refresh Token 교체 (Rotation)
+ refreshTokenEntity.updateToken(newRefreshToken);
+
+ // Refresh Token을 쿠키에 설정
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", newRefreshToken)
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .maxAge(7 * 24 * 60 * 60) // 7일
+ .path("/")
+ .build();
+
+ // Access Token을 Authorization 헤더에 설정
+
+ response.setHeader("Set-Cookie", refreshTokenCookie.toString());
+
+ return AccessTokenResponse.builder()
+ .accessToken(newAccessToken)
+ .build();
+ }
+
+ public void saveRefreshToken(Member member, String refreshToken) {
+ // 기존 리프레시 토큰이 있다면 업데이트, 없다면 새로 생성
+ RefreshToken refreshTokenEntity = refreshTokenRepository.findByMember(member)
+ .orElse(RefreshToken.builder()
+ .member(member)
+ .token(refreshToken)
+ .build());
+
+ refreshTokenEntity.updateToken(refreshToken);
+ refreshTokenRepository.save(refreshTokenEntity);
+ }
+
+ public void logout(String refreshToken) {
+ RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken)
+ .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN));
+
+ refreshTokenRepository.delete(refreshTokenEntity);
+ }
+
+ /**
+ * 로그인 성공 시, 엑세스 토큰과 리프레쉬 토큰을 발급하고 헤더에 넣는 코드를 공통으로 묶음.
+ */
+ @Override
+ public String processLoginSuccess(Member member, HttpServletResponse response) {
+ // Access Token, Refresh Token 발급
+ String newAccessToken = jwtProvider.createAccessToken(member.getId().toString());
+ String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString());
+
+ // Refresh Token 저장
+ saveRefreshToken(member, newRefreshToken);
+
+ // Refresh Token을 쿠키에 설정
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", newRefreshToken)
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .maxAge(7 * 24 * 60 * 60) // 7일(7 * 24 * 60 * 60)
+ .path("/")
+ .build();
+
+ response.setHeader("Set-Cookie", refreshTokenCookie.toString());
+
+ return newAccessToken;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java b/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java
new file mode 100644
index 00000000..8e2999e9
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java
@@ -0,0 +1,8 @@
+package com.example.mody.domain.auth.service.email;
+
+public interface EmailService {
+
+ void sendVerificationEmail(String email);
+
+ boolean verifyEmail(String email, String code);
+}
diff --git a/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java b/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java
new file mode 100644
index 00000000..5201f19f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java
@@ -0,0 +1,69 @@
+package com.example.mody.domain.auth.service.email;
+
+import java.time.Duration;
+import java.util.Random;
+
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.GlobalErrorStatus;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class EmailServiceImpl implements EmailService {
+ private static final long VERIFICATION_TIME_LIMIT = 5L;
+ private final JavaMailSender emailSender;
+ private final StringRedisTemplate redisTemplate;
+ private final SpringTemplateEngine templateEngine;
+
+ public void sendVerificationEmail(String email) {
+ try {
+ String verificationCode = generateVerificationCode();
+
+ // 템플릿에 전달할 데이터 설정
+ Context context = new Context();
+ context.setVariable("name", email.split("@")[0]); // 이메일 앞부분을 이름으로 사용
+ context.setVariable("verificationCode", verificationCode);
+
+ // 템플릿을 이용해 HTML 생성
+ String htmlContent = templateEngine.process("mail/verification-email", context);
+
+ // 이메일 메시지 생성
+ MimeMessage message = emailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+
+ helper.setTo(email);
+ helper.setSubject("MODY 이메일 인증");
+ helper.setText(htmlContent, true); // HTML 사용을 위해 true로 설정
+
+ // 이메일 발송
+ emailSender.send(message);
+
+ // Redis에 인증번호 저장
+ redisTemplate.opsForValue()
+ .set(email, verificationCode, Duration.ofMinutes(VERIFICATION_TIME_LIMIT));
+
+ } catch (MessagingException e) {
+ throw new RestApiException(GlobalErrorStatus._INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ public boolean verifyEmail(String email, String code) {
+ String storedCode = redisTemplate.opsForValue().get(email);
+ return code.equals(storedCode);
+ }
+
+ private String generateVerificationCode() {
+ Random random = new Random();
+ return String.format("%06d", random.nextInt(1000000));
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java
new file mode 100644
index 00000000..5e3ca045
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java
@@ -0,0 +1,156 @@
+package com.example.mody.domain.bodytype.controller;
+
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.bodytype.dto.request.BodyTypeAnalysisRequest;
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeCommandService;
+import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeQueryService;
+import com.example.mody.global.common.base.BaseResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "체형 분석", description = "체형 분석 API")
+@RestController
+@RequestMapping("/body-analysis")
+@RequiredArgsConstructor
+public class BodyTypeController {
+
+ private final MemberBodyTypeCommandService memberBodyTypeCommandService;
+ private final MemberBodyTypeQueryService memberBodyTypeQueryService;
+
+ @PostMapping("/result")
+ @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 보내주세요.")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 성공"),
+ @ApiResponse(
+ responseCode = "401",
+ description = "Access Token이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "체형을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "BODY_TYPE404",
+ "message": "체형을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "500",
+ description = "GPT가 적절한 응답을 하지 못 했습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "ANALYSIS108",
+ "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요."
+ }
+ """
+ )
+ )
+ ),
+ })
+ public BaseResponse analyzeBodyType(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @Valid @RequestBody BodyTypeAnalysisRequest request
+ ) {
+ return BaseResponse.onSuccess(memberBodyTypeCommandService.analyzeBodyType(customUserDetails.getMember(), request.getAnswer()));
+ }
+
+// @GetMapping()
+// @Operation(summary = "체형 질문 문항 조회 API - 프론트와 협의 필요(API 연동 안 해도 됨)",
+// description = "체형 분석을 하기 위해 질문 문항을 받아 오는 API입니다. 이 부분은 서버에서 바로 프롬프트로 넣는 방법도 있기 때문에 프론트와 협의 후 진행하겠습니다.")
+// @ApiResponses({
+// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공")
+// })
+// public BaseResponse getQuestion() {
+// return BaseResponse.onSuccess(null);
+// }
+
+ @GetMapping("/result")
+ @Operation(summary = "체형 분석 결과 조회 API", description = "사용자의 체형 분석 결과를 받아 오는 API입니다.")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 결과 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "JSON_PARSING400",
+ "message": "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "401",
+ description = "Access Token이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "체형 분석 결과를 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "MEMBER_BODY_TYPE404",
+ "message": "체형 분석 결과를 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ })
+ public BaseResponse getBodyType(@AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ return BaseResponse.onSuccess(memberBodyTypeQueryService.getBodyTypeAnalysis(customUserDetails.getMember()));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java
new file mode 100644
index 00000000..7845d86d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java
@@ -0,0 +1,29 @@
+package com.example.mody.domain.bodytype.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 체형 분석 요청 DTO
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class BodyTypeAnalysisRequest {
+ @Schema(description = "체형 분석 질문에 대한 답변", example = "목 두께가 얇고 상대적으로 긴 편이다. "
+ + "쇄골이 굵고 단단하며 뼈와 힘줄이 돋보인다. "
+ + "보송보송하고 관절과 힘줄이 부각된다. "
+ + "다리 길이가 상대적으로 긴 편이다. "
+ + "허리가 굴곡이 없는 편이다. "
+ + "어깨의 뼈가 크고 넓은 편이다. "
+ + "엉덩이가 입체적이며 탄탄하다. "
+ + "어깨선 및 쇄골뼈가 부각되어 있다. "
+ + "탄탄하고 근육이 붙어있다. "
+ + "팔, 가슴, 배 등 상체 위주로 살이 찐다. "
+ + "보통이다.")
+ private String answer;
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java b/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java
new file mode 100644
index 00000000..2b697b74
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java
@@ -0,0 +1,55 @@
+package com.example.mody.domain.bodytype.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 체형 분석 응답 DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "체형 분석 결과 응답 DTO")
+public class BodyTypeAnalysisResponse {
+
+ @Schema(description = "사용자 이름", example = "차은우")
+ private String name;
+
+ @Schema(description = "체형 분석 결과")
+ private BodyTypeAnalysis bodyTypeAnalysis;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Schema(description = "체형 분석 결과")
+ public static class BodyTypeAnalysis {
+
+ @Schema(description = "체형 유형", example = "네추럴")
+ private String type;
+
+ @Schema(description = "체형 설명", example = "차은우님의 체형은 큰 뼈대와 넓은 어깨, 굵은 쇄골, 그리고 허리의 굴곡이 뚜렷하지 않은 네추럴 유형에 해당합니다. 전체적으로 다부지고 탄탄한 신체구조를 가지고 있으며 다리와 엉덩이가 비교적 길고 입체적입니다.")
+ private String description;
+
+ @Schema(description = "스타일링 제안")
+ private FeatureBasedSuggestions featureBasedSuggestions;
+ }
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Schema(description = "스타일링 제안")
+ public static class FeatureBasedSuggestions {
+
+ @Schema(description = "강조할 부분", example = "차은우님은 넓고 탄탄한 어깨와 긴 다리를 활용한 스타일링이 가능합니다. 어깨와 다리의 비율을 강조하는 깔끔한 실루엣의 재킷과 팬츠는 체형의 균형감을 극대화시킵니다.")
+ private String emphasize;
+
+ @Schema(description = "보완할 부분", example = "허리 굴곡이 뚜렷하지 않으므로, 허리선을 살짝 강조하는 디자인이나 레이어드 스타일링으로 몸의 중간 라인을 정의하면 더욱 세련된 인상을 줄 수 있습니다.")
+ private String enhance;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java b/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java
new file mode 100644
index 00000000..468216f2
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.bodytype.entity;
+
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+@Entity
+@Getter
+@Setter
+@DynamicUpdate
+@DynamicInsert
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class Answer extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "answer_id")
+ private Long id;
+
+ @Column(nullable = false, length = 100)
+ private String content; // 답변 내용
+
+ @Column(nullable = true, length = 100) // 잠시 nullable로 세팅. 이미지 구현 완료 후에 false로 변경 예정
+ private String imageUrl; // 답변 이미지 S3 url
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "question_id")
+ private Question question;
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java
new file mode 100644
index 00000000..217032a5
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.bodytype.entity;
+
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Getter
+@Setter
+@DynamicUpdate
+@DynamicInsert
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class BodyType extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "body_type_id")
+ private Long id;
+
+ @Column(nullable = false, length = 30)
+ private String name; // 네추럴, 스트레이트, 웨이브
+
+ @OneToMany(mappedBy = "bodyType", cascade = CascadeType.ALL)
+ private List memberBodyTypeList = new ArrayList<>();
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/Question.java b/src/main/java/com/example/mody/domain/bodytype/entity/Question.java
new file mode 100644
index 00000000..59bb5e28
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/entity/Question.java
@@ -0,0 +1,32 @@
+package com.example.mody.domain.bodytype.entity;
+
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Getter
+@Setter
+@DynamicUpdate
+@DynamicInsert
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class Question extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "question_id")
+ private Long id;
+
+ @Column(nullable = false, length = 100)
+ private String content; // 질문 내용
+
+ @OneToMany(mappedBy = "question", cascade = CascadeType.ALL)
+ private List answerList = new ArrayList<>();
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java
new file mode 100644
index 00000000..d62817fc
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.bodytype.entity.mapping;
+
+import com.example.mody.domain.bodytype.entity.Answer;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+@Entity
+@Getter
+@Setter
+@DynamicUpdate
+@DynamicInsert
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class MemberAnswer extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_answer_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "answer_id")
+ private Answer answer;
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java
new file mode 100644
index 00000000..7f3c0fa6
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java
@@ -0,0 +1,42 @@
+package com.example.mody.domain.bodytype.entity.mapping;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+@Entity
+@Getter
+@Setter
+@DynamicUpdate
+@DynamicInsert
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class MemberBodyType extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_body_type_id")
+ private Long id;
+
+ @Column(nullable = false, length = 1000)
+ private String body;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "body_type_id")
+ private BodyType bodyType;
+
+ public MemberBodyType(String body, Member member, BodyType bodyType) {
+ this.body = body;
+ this.member = member;
+ this.bodyType = bodyType;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java
new file mode 100644
index 00000000..6fd0da1a
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java
@@ -0,0 +1,12 @@
+package com.example.mody.domain.bodytype.repository;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface BodyTypeRepository extends JpaRepository {
+ Optional findByName(String bodyTypeName);
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java
new file mode 100644
index 00000000..fc6af18b
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java
@@ -0,0 +1,16 @@
+package com.example.mody.domain.bodytype.repository;
+
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.member.entity.Member;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface MemberBodyTypeRepository extends JpaRepository {
+ Optional findTopByMemberOrderByCreatedAt(Member member);
+ Optional findMemberBodyTypeByMember(Member member);
+ Long countAllByMember(Member member);
+ Optional findTopByMemberOrderByCreatedAtDesc(Member member);
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java b/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java
new file mode 100644
index 00000000..3ded547d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java
@@ -0,0 +1,28 @@
+package com.example.mody.domain.bodytype.service;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.bodytype.repository.BodyTypeRepository;
+import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository;
+import com.example.mody.domain.member.entity.Member;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class BodyTypeService {
+ private final BodyTypeRepository bodyTypeRepository;
+ private final MemberBodyTypeRepository memberBodyTypeRepository;
+
+ /**
+ * 유저의 가장 최근 체형 분석 결과의 체형 타입을 반환하는 메서드
+ * @param member 체형 타입을 조회할 유저
+ * @return 마지막 체형 분석이 존재하지 않을 경우 empty Optional을 반환함.
+ */
+ public Optional findLastBodyType(Member member){
+ Optional optionalMemberBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAt(member);
+ return optionalMemberBodyType.map(MemberBodyType::getBodyType);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java
new file mode 100644
index 00000000..f391f5bd
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java
@@ -0,0 +1,7 @@
+package com.example.mody.domain.bodytype.service.bodytype;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+
+public interface BodyTypeQueryService {
+ BodyType findByBodyTypeName(String bodyTypeName);
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java
new file mode 100644
index 00000000..b5d66e5d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java
@@ -0,0 +1,25 @@
+package com.example.mody.domain.bodytype.service.bodytype;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.repository.BodyTypeRepository;
+import com.example.mody.domain.exception.BodyTypeException;
+import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class BodyTypeQueryServiceImpl implements BodyTypeQueryService {
+
+ private final BodyTypeRepository bodyTypeRepository;
+
+ // 체형 이름으로 BodyType 클래스 조회
+ @Override
+ public BodyType findByBodyTypeName(String bodyTypeName) {
+ Optional optionalBodyType = bodyTypeRepository.findByName(bodyTypeName);
+ BodyType bodyType = optionalBodyType.orElseThrow(()-> new BodyTypeException(BodyTypeErrorStatus.BODY_TYPE_NOT_FOUND));
+ return bodyType;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java
new file mode 100644
index 00000000..f89dc89a
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java
@@ -0,0 +1,8 @@
+package com.example.mody.domain.bodytype.service.memberbodytype;
+
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.member.entity.Member;
+
+public interface MemberBodyTypeCommandService {
+ public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers);
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java
new file mode 100644
index 00000000..8ebd2b92
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java
@@ -0,0 +1,40 @@
+package com.example.mody.domain.bodytype.service.memberbodytype;
+
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository;
+import com.example.mody.domain.bodytype.service.bodytype.BodyTypeQueryService;
+import com.example.mody.domain.chatgpt.service.ChatGptService;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.Gender;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class MemberBodyTypeCommandServiceImpl implements MemberBodyTypeCommandService {
+
+ private final MemberBodyTypeRepository memberBodyTypeRepository;
+ private final ChatGptService chatGptService;
+ private final BodyTypeQueryService bodyTypeQueryService;
+
+ @Override
+ public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers) {
+ // 사용자 정보(닉네임, 성별) 조회
+ String nickname = member.getNickname();
+ Gender gender = member.getGender();
+
+ // OpenAi API를 통한 체형 분석
+ String content = chatGptService.getContent(nickname, gender, answers);
+ BodyTypeAnalysisResponse bodyTypeAnalysisResponse = chatGptService.analyzeBodyType(content);
+
+ // MemberBodyType을 DB에 저장
+ BodyType bodyType = bodyTypeQueryService.findByBodyTypeName(bodyTypeAnalysisResponse.getBodyTypeAnalysis().getType());
+ memberBodyTypeRepository.save(new MemberBodyType(content, member, bodyType));
+
+ return bodyTypeAnalysisResponse;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java
new file mode 100644
index 00000000..9d394ff0
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java
@@ -0,0 +1,11 @@
+package com.example.mody.domain.bodytype.service.memberbodytype;
+
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.member.entity.Member;
+
+public interface MemberBodyTypeQueryService {
+ public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member);
+
+ public MemberBodyType getMemberBodyType(Member member);
+}
diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java
new file mode 100644
index 00000000..d2b64cb3
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java
@@ -0,0 +1,43 @@
+package com.example.mody.domain.bodytype.service.memberbodytype;
+
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository;
+import com.example.mody.domain.exception.BodyTypeException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberBodyTypeQueryServiceImpl implements MemberBodyTypeQueryService {
+
+ private final MemberBodyTypeRepository memberBodyTypeRepository;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Override
+ public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member) {
+ MemberBodyType memberBodyType = getMemberBodyType(member);
+
+ // MemberBodyType의 body 내용을 BodyTypeAnalysisResponse로 변환
+ try {
+ return objectMapper.readValue(memberBodyType.getBody(), BodyTypeAnalysisResponse.class);
+ } catch (JsonProcessingException e) {
+ throw new BodyTypeException(BodyTypeErrorStatus.JSON_PARSING_ERROR);
+ }
+ }
+
+ // Member로 MemberBodyType 조회
+ @Override
+ public MemberBodyType getMemberBodyType(Member member) {
+ Optional optionalMemberBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAtDesc(member);
+ return optionalMemberBodyType.orElseThrow(() -> new BodyTypeException(BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND));
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java
new file mode 100644
index 00000000..69db1ef2
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java
@@ -0,0 +1,160 @@
+package com.example.mody.domain.chatgpt.service;
+
+import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse;
+import com.example.mody.domain.recommendation.dto.request.MemberInfoRequest;
+import com.example.mody.domain.recommendation.dto.response.analysis.ItemAnalysisResponse;
+import com.example.mody.domain.member.enums.Gender;
+import com.example.mody.domain.recommendation.dto.request.RecommendRequest;
+import com.example.mody.domain.recommendation.dto.response.analysis.StyleAnalysisResponse;
+import com.example.mody.domain.recommendation.service.CrawlerService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.AnalysisErrorStatus;
+import com.example.mody.global.infrastructure.ai.OpenAiApiClient;
+import com.example.mody.global.infrastructure.ai.dto.ChatGPTResponse;
+import com.example.mody.global.infrastructure.ai.dto.Message;
+import com.example.mody.global.templates.PromptManager;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * PromptManager를 통해 프롬프트를 생성하고, OpenAIApiClient를 사용하여 ChatGPT 모델에 요청을 보내 응답을 처리
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public final class ChatGptService {
+
+ private final OpenAiApiClient openAiApiClient; // ChatGPT API와의 통신을 담당
+ private final PromptManager promptManager; // 프롬프트 생성
+ private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 응답 변환
+ private final CrawlerService crawlerService;
+
+ @Value("${openai.model}")
+ private String model; // OpenAI 모델
+
+ @Value("${openai.max-tokens}")
+ private int maxTokens; // 최대 토큰 수
+
+ @Value("${openai.temperature}")
+ private double temperature; // 생성된 응답의 창의성 정도
+
+ private final String systemRole = "system"; // 대화에서의 역할(시스템 메시지)
+
+ private final String userRole = "user"; // 대화에서의 역할(사용자 메시지)
+
+ // OpenAi 응답에서 content 추출하는 메서드
+ public String getContent(String nickName, Gender gender, String answers) {
+ ChatGPTResponse response = getChatGPTResponse(nickName, gender, answers);
+
+ // response에서 content 추출
+ String content = response.getChoices().get(0).getMessage().getContent().trim();
+ log.info("content: {}", content);
+
+ // 백틱과 "json" 등을 제거
+ if (content.startsWith("```")) {
+ content = content.replaceAll("```[a-z]*", "").trim();
+ }
+ log.info("백틱 제거 후 content: {}", content);
+ return content;
+ }
+
+ // OpenAi 응답 메서드
+ private ChatGPTResponse getChatGPTResponse(String nickName, Gender gender, String answers) {
+ // 템플릿 생성
+ String prompt = promptManager.createBodyTypeAnalysisPrompt(nickName, gender, answers);
+
+ // OpenAI API 호출
+ ChatGPTResponse response = openAiApiClient.sendRequestToModel(
+ model,
+ List.of(
+ new Message(systemRole, prompt)
+ ),
+ maxTokens,
+ temperature
+ );
+ return response;
+ }
+
+ // 체형 분석 메서드
+ public BodyTypeAnalysisResponse analyzeBodyType(String content) {
+
+ try {
+ // content -> BodyTypeAnalysisResponse 객체로 변환
+ return objectMapper.readValue(content, BodyTypeAnalysisResponse.class);
+ } catch (JsonMappingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ } catch (JsonProcessingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ }
+ }
+
+ // 스타일 추천 메서드
+ public StyleAnalysisResponse recommendGptStyle(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest){
+
+ //스타일 추천 프롬프트 생성
+ String prompt = promptManager.createRecommendStylePrompt(memberInfoRequest, recommendRequest);
+
+ //openAiApiClient로 gpt 답변 생성
+ ChatGPTResponse response = openAiApiClient.sendRequestToModel(
+ model,
+ List.of(
+ new Message(systemRole, prompt)
+ ),
+ maxTokens,
+ temperature);
+ String content = response.getChoices().get(0).getMessage().getContent().trim();
+
+ try{
+ StyleAnalysisResponse styleAnalysisResponse = objectMapper.readValue(content, StyleAnalysisResponse.class);
+ return styleAnalysisResponse.from(searchImageFromPinterest(memberInfoRequest.getGender(), styleAnalysisResponse.getRecommendedStyle()));
+ } catch (JsonMappingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ } catch (JsonProcessingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ }
+ }
+
+ private String searchImageFromPinterest(Gender gender, String recommendedStyle) {
+ String strGender = (gender == Gender.MALE) ? "남성 " : "여성 ";
+ String keyword = strGender + recommendedStyle;
+ log.info("keyword: {}", keyword);
+
+ String imageUrl = crawlerService.getRandomImageUrl(keyword);
+ log.info("Pinterest 이미지 URL: {}", imageUrl);
+
+ return imageUrl;
+ }
+
+ // 패션 아이템 추천 메서드
+ public ItemAnalysisResponse recommendGptItem(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest){
+
+ //아이템 추천 프롬프트 생성
+ String prompt = promptManager.createRecommendItemPrompt(memberInfoRequest, recommendRequest);
+
+ //openAiApiClient로 gpt 답변 생성
+ ChatGPTResponse response = openAiApiClient.sendRequestToModel(
+ model,
+ List.of(
+ new Message(systemRole, prompt)
+ ),
+ maxTokens,
+ temperature);
+ String content = response.getChoices().get(0).getMessage().getContent().trim();
+
+ try{
+ ItemAnalysisResponse itemAnalysisResponse = objectMapper.readValue(content, ItemAnalysisResponse.class);
+ return itemAnalysisResponse.from(searchImageFromPinterest(memberInfoRequest.getGender(), itemAnalysisResponse.getItem()));
+ } catch (JsonMappingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ } catch (JsonProcessingException e) {
+ throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/exception/BodyTypeException.java b/src/main/java/com/example/mody/domain/exception/BodyTypeException.java
new file mode 100644
index 00000000..9c214edc
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/exception/BodyTypeException.java
@@ -0,0 +1,10 @@
+package com.example.mody.domain.exception;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.BaseCodeInterface;
+
+public class BodyTypeException extends RestApiException {
+ public BodyTypeException(BaseCodeInterface errorCode) {
+ super(errorCode);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/exception/MemberException.java b/src/main/java/com/example/mody/domain/exception/MemberException.java
new file mode 100644
index 00000000..ff76811e
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/exception/MemberException.java
@@ -0,0 +1,11 @@
+package com.example.mody.domain.exception;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.BaseCodeInterface;
+
+public class MemberException extends RestApiException {
+
+ public MemberException(BaseCodeInterface errorCode) {
+ super(errorCode);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/exception/PostException.java b/src/main/java/com/example/mody/domain/exception/PostException.java
new file mode 100644
index 00000000..61b4c99d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/exception/PostException.java
@@ -0,0 +1,10 @@
+package com.example.mody.domain.exception;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.BaseCodeInterface;
+
+public class PostException extends RestApiException {
+ public PostException(BaseCodeInterface errorCode) {
+ super(errorCode);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java
new file mode 100644
index 00000000..9a170362
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java
@@ -0,0 +1,11 @@
+package com.example.mody.domain.exception;
+
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.BaseCodeInterface;
+
+public class RefreshTokenException extends RestApiException {
+
+ public RefreshTokenException(BaseCodeInterface errorCode) {
+ super(errorCode);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/file/controller/FileController.java b/src/main/java/com/example/mody/domain/file/controller/FileController.java
index a77c8ea3..5eb15ff2 100644
--- a/src/main/java/com/example/mody/domain/file/controller/FileController.java
+++ b/src/main/java/com/example/mody/domain/file/controller/FileController.java
@@ -1,41 +1,35 @@
package com.example.mody.domain.file.controller;
-import com.example.mody.domain.file.dto.FileCreateResponse;
+import com.example.mody.domain.file.dto.request.BackUpFileRequests;
+import com.example.mody.domain.file.dto.request.FileCreateResponse;
import com.example.mody.domain.file.service.FileService;
import com.example.mody.global.common.base.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
-import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
@Tag(name = "File API", description = "파일 관련 API")
-//@RestController
+@RestController
@RequiredArgsConstructor
@RequestMapping("/files")
public class FileController {
private final FileService fileService;
- @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "파일 생성 API", description = "파일을 생성하는 API입니다. jpg, mp3, mp4 등 다양한 파일을 업로드할 수 있습니다.")
- public BaseResponse createFile
- (
- @Valid @Parameter(
- content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) )
- @RequestPart("file") MultipartFile file
- )
- {
- return BaseResponse.onSuccess(fileService.createFile("", file));
+ @PostMapping
+ @Operation(summary = "백업 파일 등록 API", description = "파일을 S3에 업로드 한 뒤 해당 s3uri와 파일 정보를 업로드하는 API 입니다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "백업 파일 등록 성공"),
+ @ApiResponse(responseCode = "COMMON402", description = "Validation 관련 예외 - 파일 개수 제한 초과")
+ })
+ public BaseResponse createFile(@Valid @RequestBody BackUpFileRequests backUpFileRequests) {
+ fileService.saveBackupFiles(backUpFileRequests);
+ return BaseResponse.onSuccessCreate(null);
}
- @DeleteMapping
- @Operation(summary = "파일 삭제 API", description = "파일을 삭제하는 API입니다. 파일 URL을 입력하면 해당 파일을 삭제합니다.")
- public BaseResponse deleteFile(@RequestParam("fileUrl") String fileUrl) {
- fileService.deleteFile(fileUrl);
- return BaseResponse.onSuccess("파일 삭제 성공");
- }
}
diff --git a/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java b/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java
new file mode 100644
index 00000000..a725e2de
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java
@@ -0,0 +1,30 @@
+package com.example.mody.domain.file.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+import static com.example.mody.domain.post.constant.PostConstant.POST_IMAGE_COUNT_LIMIT;
+
+@NoArgsConstructor
+@Getter
+public class BackUpFileRequests {
+
+ @Schema(
+ description = "파일명, 파일 크기, S3 URL 목록",
+ example = "[{" +
+ "\"fileName\": \"test.jpg\"," +
+ "\"fileSize\": 200," +
+ "\"s3Url\": \"https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/filea8500f7a-c902-4f64-b605-7bc6247b4e75\"" +
+ "}, {" +
+ "\"fileName\": \"example.png\"," +
+ "\"fileSize\": 150," +
+ "\"s3Url\": \"https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/fileb8500f7a-c902-4f64-b605-7bc6247b4e76\"" +
+ "}]"
+ )
+ @Size(max = POST_IMAGE_COUNT_LIMIT, message = "파일의 최대 개수는 {max}를 초과할 수 없습니다.")
+ private List files;
+}
diff --git a/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java b/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java
new file mode 100644
index 00000000..cc6e1e16
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java
@@ -0,0 +1,35 @@
+package com.example.mody.domain.file.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Positive;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+@Getter
+public class BackupFileRequest {
+
+ @Schema(
+ description = "파일명",
+ example = "test.jpg"
+ )
+ @NotBlank(message = "파일명 입력은 필수입니다.")
+ private String fileName;
+
+ @Schema(
+ description = "파일 크기. 단위는 KB",
+ example = "200"
+ )
+ @NotBlank(message = "파일 크기 입력은 필수입니다.")
+ @Positive(message = "파일 크기는 음수일 수 없습니다.")
+ private Long fileSize;
+
+
+ @Schema(
+ description = "s3 URI",
+ example = "https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/filea8500f7a-c902-4f64-b605-7bc6247b4e75]"
+ )
+ @NotBlank(message = "파일 주소 입력은 필수입니다.")
+ private String s3Url;
+}
diff --git a/src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java b/src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java
similarity index 79%
rename from src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java
rename to src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java
index 15b89c87..2d7ae1ba 100644
--- a/src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java
+++ b/src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java
@@ -1,4 +1,4 @@
-package com.example.mody.domain.file.dto;
+package com.example.mody.domain.file.dto.request;
import lombok.AllArgsConstructor;
import lombok.Getter;
diff --git a/src/main/java/com/example/mody/domain/file/entity/BackupFile.java b/src/main/java/com/example/mody/domain/file/entity/BackupFile.java
new file mode 100644
index 00000000..37e9ff6e
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/file/entity/BackupFile.java
@@ -0,0 +1,29 @@
+package com.example.mody.domain.file.entity;
+
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class BackupFile extends BaseEntity {
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "backup_image_id")
+ private Long id;
+
+ private String fileName;
+ private Long fileSize;
+ private String s3Url;
+
+ public BackupFile(String fileName, Long fileSize, String s3Url){
+ this.fileName = fileName;
+ this.fileSize = fileSize;
+ this.s3Url = s3Url;
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java b/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java
new file mode 100644
index 00000000..53dfec35
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java
@@ -0,0 +1,14 @@
+package com.example.mody.domain.file.repository;
+
+import com.example.mody.domain.file.entity.BackupFile;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BackupFileRepository extends JpaRepository {
+ void deleteAllByIdIn(List backupImageIds);
+
+ void deleteAllByS3UrlIn(List s3Urls);
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/file/service/FileService.java b/src/main/java/com/example/mody/domain/file/service/FileService.java
index 6ec4a01e..e09f80b6 100644
--- a/src/main/java/com/example/mody/domain/file/service/FileService.java
+++ b/src/main/java/com/example/mody/domain/file/service/FileService.java
@@ -1,26 +1,35 @@
package com.example.mody.domain.file.service;
-import com.example.mody.domain.file.dto.FileCreateResponse;
+import com.example.mody.domain.file.dto.request.BackUpFileRequests;
+import com.example.mody.domain.file.dto.request.BackupFileRequest;
+import com.example.mody.domain.file.dto.request.FileCreateResponse;
+import com.example.mody.domain.file.entity.BackupFile;
+import com.example.mody.domain.file.repository.BackupFileRepository;
import com.example.mody.global.util.S3FileComponent;
import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
-//@Service
+import java.util.List;
+
+@Service
@RequiredArgsConstructor
-@Transactional(readOnly = true)
public class FileService {
- private final S3FileComponent s3FileComponent;
+ private final BackupFileRepository backupFileRepository;
@Transactional
- public FileCreateResponse createFile(String domain , final MultipartFile file) {
- String imageUrl = s3FileComponent.uploadFile(domain, file);
- return new FileCreateResponse(imageUrl);
+ public void saveBackupFiles(BackUpFileRequests backupFileRequests){
+ backupFileRepository.saveAll(backupFileRequests.getFiles().stream()
+ .map(file -> new BackupFile(file.getFileName(),
+ file.getFileSize(),
+ file.getS3Url()))
+ .toList());
}
- public void deleteFile(String fileUrl) {
- s3FileComponent.deleteFile(fileUrl);
+ public void deleteByS3Urls(List s3Urls){
+ backupFileRepository.deleteAllByS3UrlIn(s3Urls);
}
}
diff --git a/src/main/java/com/example/mody/domain/image/controller/S3Controller.java b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java
new file mode 100644
index 00000000..bbb2da71
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java
@@ -0,0 +1,164 @@
+package com.example.mody.domain.image.controller;
+
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.image.dto.request.PostPresignedUrlRequest;
+import com.example.mody.domain.image.dto.request.ProfilePresignedUrlRequest;
+import com.example.mody.domain.image.dto.response.PresignedUrlResponse;
+import com.example.mody.domain.image.service.S3Service;
+import com.example.mody.global.common.base.BaseResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Tag(name = "S3 API", description = "S3 사진 업로드 관련 API")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/image")
+public class S3Controller {
+
+ private final S3Service s3Service;
+
+ @PostMapping(value = "/upload/posts")
+ @Operation(summary = "게시글 presigned url 생성 API", description = "게시글 파일 업로드(put) presigned url을 생성하는 API 입니다.")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."),
+ @ApiResponse(
+ responseCode = "401",
+ description = "Access Token이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "S3 버킷을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "S3_404",
+ "message": "지정된 S3 버킷을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "500",
+ description = "presigned url 생성을 실패하였습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "S3_500",
+ "message": "S3 presigned url 생성 중 오류가 발생했습니다."
+ }
+ """
+ )
+ )
+ ),
+ })
+ public BaseResponse> getPostPresignedUrl(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @Valid @RequestBody PostPresignedUrlRequest request
+ ) {
+ List presignedUrlResponse = s3Service.getPostPresignedUrls(customUserDetails.getMember().getId(), request.getFilenames());
+ return BaseResponse.onSuccess(presignedUrlResponse);
+ }
+
+ @PostMapping(value = "/upload/profiles")
+ @Operation(summary = "프로필 사진 presigned url 생성 API", description = "프로필 사진 파일 업로드(put) presigned url을 생성하는 API 입니다.")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."),
+ @ApiResponse(
+ responseCode = "401",
+ description = "Access Token이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "S3 버킷을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "S3_404",
+ "message": "지정된 S3 버킷을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "500",
+ description = "presigned url 생성을 실패하였습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "S3_500",
+ "message": "S3 presigned url 생성 중 오류가 발생했습니다."
+ }
+ """
+ )
+ )
+ ),
+ })
+ public BaseResponse getProfilePresignedUrl(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @Valid @RequestBody ProfilePresignedUrlRequest request
+ ) {
+ PresignedUrlResponse presignedUrlResponse = s3Service.getProfilePresignedUrl(customUserDetails.getMember().getId(), request.getFilename());
+ return BaseResponse.onSuccess(presignedUrlResponse);
+ }
+
+// // 테스트용(실제 사용 X)
+// @GetMapping(value = "/getS3Url")
+// @Operation(summary = "자체 S3 URL 조회 - 테스트용으로 API 연동 시 신경 안 써도 되는 API 입니다.",
+// description = "프론트에서 S3에 파일 업로드 후 반환하는 S3 URL을 서버에서 생성해 확인해보는 테스트용 API 입니다.")
+// @ApiResponses({
+// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "S3 url 반환 성공")
+// })
+// public BaseResponse getS3Url(@RequestParam String key) {
+// S3UrlResponse s3UrlResponse = s3Service.getS3Url(key);
+// return BaseResponse.onSuccess(s3UrlResponse);
+// }
+
+}
diff --git a/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java
new file mode 100644
index 00000000..b9695ddb
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java
@@ -0,0 +1,19 @@
+package com.example.mody.domain.image.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 게시글 S3 presigned url 요청 DTO
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class PostPresignedUrlRequest {
+ @Schema(description = "업로드할 파일 목록", example = "[\"a.png\", \"b.jpg\"]")
+ private List filenames;
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java
new file mode 100644
index 00000000..6fd9d300
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java
@@ -0,0 +1,19 @@
+package com.example.mody.domain.image.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 프로필 사진 S3 presigned url 요청 DTO
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class ProfilePresignedUrlRequest {
+ @Schema(description = "업로드할 파일", example = "a.png")
+ private String filename;
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java
new file mode 100644
index 00000000..2fdf9219
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java
@@ -0,0 +1,27 @@
+package com.example.mody.domain.image.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+
+/**
+ * S3 presigned url 응답 DTO
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@EqualsAndHashCode
+@Schema(description = "S3 presigned url 응답 DTO")
+public class PresignedUrlResponse {
+
+ @Schema(description = "S3 presigned url", example = "https://{bucket-name}.s3.{region}.amazonaws.com/{object-key}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date={timestamp}&X-Amz-SignedHeaders=host&X-Amz-Expires={expires}&X-Amz-Credential={credential}&X-Amz-Signature={signature}")
+ private String presignedUrl;
+ @Schema(description = "S3 key 값", example = "{folder}/{memberId}/{uuid}/{filename}")
+ private String key;
+
+ public static PresignedUrlResponse of(String preSignedUrl, String key){
+ PresignedUrlResponse response = new PresignedUrlResponse();
+ response.setPresignedUrl(preSignedUrl);
+ response.setKey(key);
+ return response;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java
new file mode 100644
index 00000000..9f30d03c
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java
@@ -0,0 +1,27 @@
+package com.example.mody.domain.image.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * S3 url 응답 DTO
+ */
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+@Schema(description = "S3 url 응답 DTO")
+public class S3UrlResponse {
+
+ @Schema(description = "S3 url", example = "https://{bucket-name}.s3.{region}.amazonaws.com/{object-key}")
+ private String s3Url;
+
+ public static S3UrlResponse from(String s3Url){
+ S3UrlResponse response = new S3UrlResponse();
+ response.setS3Url(s3Url);
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/image/service/S3Service.java b/src/main/java/com/example/mody/domain/image/service/S3Service.java
new file mode 100644
index 00000000..11cc3f7f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/image/service/S3Service.java
@@ -0,0 +1,124 @@
+package com.example.mody.domain.image.service;
+
+import com.amazonaws.AmazonServiceException;
+import com.amazonaws.HttpMethod;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.Headers;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.amazonaws.services.s3.model.CannedAccessControlList;
+import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
+import com.example.mody.domain.image.dto.response.PresignedUrlResponse;
+import com.example.mody.domain.image.dto.response.S3UrlResponse;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.S3ErrorStatus;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class S3Service {
+ private final AmazonS3 amazonS3Client;
+
+ @Value("${cloud.aws.s3.bucket}")
+ private String bucket;
+
+ @Value("${cloud.aws.region.static}")
+ private String region;
+
+ public List getPostPresignedUrls(Long memberId, List filenames) {
+ // 게시물당 하나의 UUID를 생성
+ String uuid = UUID.randomUUID().toString();
+
+ List presignedUrls = new ArrayList<>();
+ for (String filename : filenames) {
+ String sanitizedFilename = filename.replaceAll("[^/]+://[^/]+/", "");
+ String key = String.format("post/%d/%s/%s", memberId, uuid, sanitizedFilename);
+ Date expiration = getExpiration(); // 유효 기간
+ GeneratePresignedUrlRequest generatePresignedUrlRequest = generatePresignedUrl(key, expiration);
+
+ URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest);
+ presignedUrls.add(PresignedUrlResponse.of(url.toExternalForm(), key));
+ }
+ return presignedUrls;
+ }
+
+ public PresignedUrlResponse getProfilePresignedUrl(Long memberId, String filename) {
+ // key값 설정(profile 경로 + 멤버ID + 랜덤 값 + filename)
+ String sanitizedFilename = filename.replaceAll("[^/]+://[^/]+/", "");
+ String key = String.format("profile/%d/%s/%s", memberId, UUID.randomUUID(), sanitizedFilename);
+
+ // 유효 기간
+ Date expiration = getExpiration();
+
+ // presigned url 생성
+ GeneratePresignedUrlRequest generatePresignedUrlRequest = generatePresignedUrl(key, expiration);
+ URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest);
+
+ return PresignedUrlResponse.of(url.toExternalForm(), key);
+ }
+
+ // 업로드용(put) presigned url 생성하는 메서드
+ private GeneratePresignedUrlRequest generatePresignedUrl(String key, Date expiration) {
+ try {
+ GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, key)
+ .withMethod(HttpMethod.PUT)
+ .withKey(key)
+ .withExpiration(expiration);
+ generatePresignedUrlRequest.addRequestParameter(
+ Headers.S3_CANNED_ACL,
+ CannedAccessControlList.PublicRead.toString());
+ return generatePresignedUrlRequest;
+ } catch (AmazonS3Exception e) {
+ throw new RestApiException(S3ErrorStatus.BUCKET_NOT_FOUND);
+ } catch (Exception e) {
+ throw new RestApiException(S3ErrorStatus.PRESIGNED_URL_GENERATION_FAILED);
+ }
+ }
+
+ // 유효 기간 설정
+ private static Date getExpiration() {
+ Date expiration = new Date();
+ long expTimeMillis = expiration.getTime();
+ expTimeMillis += 2 * 1000 * 60 * 60; // 2시간 설정
+ expiration.setTime(expTimeMillis);
+ return expiration;
+ }
+
+ // S3 이미지 삭제
+ public void deleteFile(List postImageUrls) { // 파일 삭제 실패해도 다음 파일 삭제를 수행하도록 예외를 터뜨리는 것이 아닌 로그만 찍음
+ for (String imageUrl : postImageUrls) {
+ try {
+ String key = extractKey(imageUrl);
+ amazonS3Client.deleteObject(bucket, key);
+ log.info("S3 파일 삭제 성공: {}", key);
+ } catch (AmazonServiceException e) {
+ log.error("S3 파일 삭제 실패 - AWS 서비스 오류: {}, Key: {}", e.getErrorMessage(), imageUrl, e);
+ } catch (Exception e) {
+ log.error("S3 파일 삭제 중 알 수 없는 오류 발생: {}, Key: {}", imageUrl, e.getMessage(), e);
+ }
+ }
+ }
+
+ // S3 url에서 key 값 추출
+ private String extractKey(String imageUrl) {
+ return imageUrl.substring(imageUrl.indexOf(".com/") + 5); // 해당 index + 5 값이 key 값의 시작 인덱스 값
+ }
+
+// // 프론트에서 전달받은 key를 이용해 S3 URL 생성 및 반환(테스트용)
+// public S3UrlResponse getS3Url(String key) {
+// String s3Url = String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key);
+// log.info("s3Url: {}", s3Url);
+// return S3UrlResponse.from(s3Url);
+// }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/member/controller/MemberController.java b/src/main/java/com/example/mody/domain/member/controller/MemberController.java
new file mode 100644
index 00000000..72421d1f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/controller/MemberController.java
@@ -0,0 +1,48 @@
+package com.example.mody.domain.member.controller;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.member.dto.response.MemberProfileResponse;
+import com.example.mody.domain.member.service.MemberCommandService;
+import com.example.mody.domain.member.service.MemberQueryService;
+import com.example.mody.global.common.base.BaseResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/members")
+public class MemberController {
+
+ private final MemberQueryService memberQueryService;
+ private final MemberCommandService memberCommandService;
+
+ @GetMapping("/me")
+ @Operation(summary = "자신의 프로필 조회 API", description = "요청 클라이언트의 정보를 반환하는 API")
+ @ApiResponses({
+ @ApiResponse(responseCode = "COMMON200", description = "프로필 조회 성공"),
+ })
+ public BaseResponse getMyProfile(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ MemberProfileResponse response = memberQueryService.getMyProfile(customUserDetails.getMember());
+ return BaseResponse.onSuccess(response);
+ }
+
+ /**
+ * 회원탈퇴 API (soft delete)
+ */
+ @PostMapping("/withdraw")
+ public BaseResponse withdraw(@AuthenticationPrincipal CustomUserDetails userDetails) {
+ // 현재 로그인된 회원의 id로 탈퇴 처리
+ memberCommandService.withdrawMember(userDetails.getMember().getId());
+ return BaseResponse.onSuccess(null);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java b/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java
new file mode 100644
index 00000000..bfd0d6e8
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java
@@ -0,0 +1,57 @@
+package com.example.mody.domain.member.dto.response;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.Gender;
+import com.example.mody.domain.member.enums.LoginType;
+import com.example.mody.domain.member.enums.Role;
+import com.example.mody.domain.member.enums.Status;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class MemberProfileResponse {
+ private final Long id;
+ private final String email;
+ private final String bodyType;
+ private final String provider;
+ private final String nickname;
+ private final Integer likeCount;
+ private final Long inspectedBodyTypeCount;
+ private final String profileImageUrl;
+ private final LocalDate birthDate;
+ private final Gender gender;
+ private final Integer height;
+ private final Status status;
+ private final Role role;
+ private final LoginType loginType;
+
+ public static MemberProfileResponse of(Member member, Optional bodyType, Long inspectedBodyTypeCount){
+ return MemberProfileResponse.builder()
+ .id(member.getId())
+ .email(member.getEmail())
+ .bodyType(bodyType
+ .map(BodyType::getName)
+ .orElse(null))
+ .provider(member.getProvider())
+ .nickname(member.getNickname())
+ .likeCount(member.getLikeCount())
+ .inspectedBodyTypeCount(inspectedBodyTypeCount)
+ .profileImageUrl(member.getProfileImageUrl())
+ .birthDate(member.getBirthDate())
+ .gender(member.getGender())
+ .height(member.getHeight())
+ .status(member.getStatus())
+ .role(member.getRole())
+ .loginType(member.getLoginType())
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/member/entity/Member.java b/src/main/java/com/example/mody/domain/member/entity/Member.java
new file mode 100644
index 00000000..841d779e
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/entity/Member.java
@@ -0,0 +1,177 @@
+package com.example.mody.domain.member.entity;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import com.example.mody.domain.recommendation.entity.Recommendation;
+import com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType;
+import com.example.mody.domain.member.enums.Gender;
+import com.example.mody.domain.member.enums.LoginType;
+import com.example.mody.domain.member.enums.Role;
+import com.example.mody.domain.member.enums.Status;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.mapping.MemberPostLike;
+import com.example.mody.global.common.base.BaseEntity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Entity
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@DynamicInsert
+@DynamicUpdate
+@Table(name = "member")
+public class Member extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_id")
+ private Long id;
+
+ @OneToMany(mappedBy = "member",
+ cascade = CascadeType.ALL,
+ orphanRemoval = true)
+ private List likes = new ArrayList<>();
+
+ @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL)
+ private List memberBodyType = new ArrayList<>();
+
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ private List recommendations = new ArrayList<>();
+
+ @OneToMany(mappedBy = "member",
+ cascade = CascadeType.ALL,
+ orphanRemoval = true,
+ fetch = FetchType.LAZY)
+ private List RecommendLikes = new ArrayList<>();
+
+ /**
+ * 회원이 작성한 게시글 목록
+ * - 회원이 삭제되면 함께 삭제됩니다.
+ * - 회원이 작성한 게시글을 조회할 때 사용합니다.
+ */
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ private List posts = new ArrayList<>();
+
+ private String providerId; // OAuth2 제공자의 고유 ID
+
+ private String provider; // OAuth2 제공자 (kakao, google 등)
+
+ @Column(unique = true)
+ private String email;
+
+ private String nickname;
+
+ private String password;
+
+ private String profileImageUrl;
+
+ private LocalDate birthDate;
+
+ @Builder.Default
+ @Column(nullable = false)
+ private Integer likeCount = 0;
+
+ @Enumerated(EnumType.STRING)
+ private Gender gender;
+
+ private Integer height;
+
+ @Enumerated(EnumType.STRING)
+ private Status status;
+
+ @Column(nullable = false)
+ @Builder.Default
+ private Integer reportCount = 0;
+
+ @Enumerated(EnumType.STRING)
+ private Role role;
+
+ @Enumerated(EnumType.STRING)
+ private LoginType loginType;
+
+ @Builder.Default
+ private boolean isRegistrationCompleted = false; // 회원가입 완료 여부
+
+ public void completeRegistration(String nickname, LocalDate birthDate, Gender gender, Integer height
+ , String profileImageUrl) {
+ this.nickname = nickname;
+ this.birthDate = birthDate;
+ this.gender = gender;
+ this.height = height;
+ this.profileImageUrl = profileImageUrl;
+ this.isRegistrationCompleted = true;
+ }
+
+ public void setEncodedPassword(String password) {
+ this.password = password;
+ }
+
+ public void increaseReportCount() {
+ this.reportCount++;
+ }
+
+ public void increaseLikeCount() {
+ this.likeCount++;
+ }
+
+ public void decreaseLikeCount() {
+ this.likeCount--;
+ }
+
+ /**
+ * 게시글 삭제 등으로 좋아요 수를 줄이는 경우
+ * @param count
+ */
+ public void decreaseLikeCount(int count) {
+ this.likeCount -= count;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Member that)) {
+ return false;
+ }
+ return Objects.equals(that.getId(), this.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+ /**
+ * 회원탈퇴(soft delete) 처리 메서드.
+ * 상태를 DELETED로 변경하고, 삭제 시점을 기록합니다.
+ */
+ public void softDelete() {
+ this.status = Status.DELETED;
+ this.delete(); // BaseEntity의 delete() 메서드가 deletedAt을 현재 시간으로 설정합니다.
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/member/enums/Gender.java b/src/main/java/com/example/mody/domain/member/enums/Gender.java
new file mode 100644
index 00000000..708961b8
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/enums/Gender.java
@@ -0,0 +1,12 @@
+package com.example.mody.domain.member.enums;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "성별")
+public enum Gender {
+ @Schema(description = "남성")
+ MALE,
+
+ @Schema(description = "여성")
+ FEMALE
+}
diff --git a/src/main/java/com/example/mody/domain/member/enums/LoginType.java b/src/main/java/com/example/mody/domain/member/enums/LoginType.java
new file mode 100644
index 00000000..d5e0d847
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/enums/LoginType.java
@@ -0,0 +1,5 @@
+package com.example.mody.domain.member.enums;
+
+public enum LoginType {
+ GENERAL, KAKAO
+}
diff --git a/src/main/java/com/example/mody/domain/member/enums/Role.java b/src/main/java/com/example/mody/domain/member/enums/Role.java
new file mode 100644
index 00000000..03e57d48
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/enums/Role.java
@@ -0,0 +1,5 @@
+package com.example.mody.domain.member.enums;
+
+public enum Role {
+ ROLE_USER, ROLE_ADMIN
+}
diff --git a/src/main/java/com/example/mody/domain/member/enums/Status.java b/src/main/java/com/example/mody/domain/member/enums/Status.java
new file mode 100644
index 00000000..8197bb51
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/enums/Status.java
@@ -0,0 +1,17 @@
+package com.example.mody.domain.member.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum Status {
+
+ ACTIVE("활성화"),
+ INACTIVE("비활성화"),
+ DELETED("삭제")
+ ;
+
+ private final String description;
+
+}
diff --git a/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java b/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java
new file mode 100644
index 00000000..d181a25a
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java
@@ -0,0 +1,25 @@
+package com.example.mody.domain.member.exception.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.example.mody.domain.member.exception.validator.ExistsMemberValidator;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Documented
+@Constraint(validatedBy = ExistsMemberValidator.class)
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExistsMember {
+
+ String message() default "회원이 존재하지 않습니다.";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java b/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java
new file mode 100644
index 00000000..7525fc0f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java
@@ -0,0 +1,39 @@
+package com.example.mody.domain.member.exception.validator;
+
+import org.springframework.stereotype.Component;
+
+import com.example.mody.domain.member.exception.annotation.ExistsMember;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class ExistsMemberValidator implements ConstraintValidator {
+
+ private final MemberRepository memberRepository;
+
+ @Override
+ public void initialize(ExistsMember constraintAnnotation) {
+ ConstraintValidator.super.initialize(constraintAnnotation);
+ }
+
+ @Override
+ public boolean isValid(Long memberId, ConstraintValidatorContext context) {
+
+ if (memberId == null || !memberRepository.existsById(memberId)) {
+
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(
+ MemberErrorStatus.MEMBER_NOT_FOUND.getMessage()
+ ).addConstraintViolation();
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java
new file mode 100644
index 00000000..f66e73f4
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java
@@ -0,0 +1,29 @@
+package com.example.mody.domain.member.repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.LoginType;
+import com.example.mody.domain.member.enums.Status;
+
+@Repository
+public interface MemberRepository extends JpaRepository {
+ Optional findByEmail(String email);
+
+ Boolean existsByEmail(String email);
+
+ Optional findByProviderId(String providerId);
+
+ Optional findByEmailAndLoginType(String email, LoginType loginType);
+
+ Optional findByProviderIdAndLoginType(String providerId, LoginType loginType);
+
+ Boolean existsByEmailAndLoginType(String email, LoginType loginType);
+
+ List findByStatusAndDeletedAtBefore(Status status, LocalDateTime deletedAt);
+}
diff --git a/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java b/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java
new file mode 100644
index 00000000..cae1b444
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java
@@ -0,0 +1,35 @@
+package com.example.mody.domain.member.scheduler;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.Status;
+import com.example.mody.domain.member.repository.MemberRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MemberPurgeScheduler {
+
+ private final MemberRepository memberRepository;
+
+ // 매일 자정에 실행 (cron 표현식은 환경에 맞게 수정하세요)
+ @Scheduled(cron = "0 0 0 * * *")
+ @Transactional
+ public void purgeDeletedMembers() {
+ LocalDateTime deleteAt = LocalDateTime.now().minusMonths(1);
+ List membersToPurge = memberRepository.findByStatusAndDeletedAtBefore(Status.DELETED, deleteAt);
+ if (!membersToPurge.isEmpty()) {
+ log.info("Purging {} member(s) deleted before {}", membersToPurge.size(), deleteAt);
+ memberRepository.deleteAll(membersToPurge);
+ }
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java
new file mode 100644
index 00000000..a417aea4
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java
@@ -0,0 +1,15 @@
+package com.example.mody.domain.member.service;
+
+import com.example.mody.domain.auth.dto.request.MemberJoinRequest;
+import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest;
+import com.example.mody.domain.auth.dto.response.LoginResponse;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+public interface MemberCommandService {
+ void completeRegistration(Long memberId, MemberRegistrationRequest request);
+
+ LoginResponse joinMember(MemberJoinRequest request, HttpServletResponse response);
+
+ void withdrawMember(Long id);
+}
diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java
new file mode 100644
index 00000000..b68fce8c
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java
@@ -0,0 +1,89 @@
+package com.example.mody.domain.member.service;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.example.mody.domain.auth.dto.request.MemberJoinRequest;
+import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest;
+import com.example.mody.domain.auth.dto.response.LoginResponse;
+import com.example.mody.domain.auth.service.AuthCommandService;
+import com.example.mody.domain.exception.MemberException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.enums.LoginType;
+import com.example.mody.domain.member.enums.Role;
+import com.example.mody.domain.member.enums.Status;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class MemberCommandServiceImpl implements MemberCommandService {
+
+ private final MemberRepository memberRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final AuthCommandService authCommandService;
+
+ @Override
+ public void completeRegistration(Long memberId, MemberRegistrationRequest request) {
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND));
+
+ member.completeRegistration(
+ request.getNickname(),
+ request.getBirthDate(),
+ request.getGender(),
+ request.getHeight(),
+ request.getProfileImageUrl()
+ );
+ }
+
+ //회원가입
+ @Override
+ public LoginResponse joinMember(MemberJoinRequest request, HttpServletResponse response) {
+ String email = request.getEmail();
+ Boolean isExist = memberRepository.existsByEmail(email);
+
+ //이미 존재하는 회원인 경우 예외처리
+ if (isExist) {
+ throw new MemberException(MemberErrorStatus.EMAIL_ALREADY_EXISTS);
+ }
+
+ //회원 저장
+ Member newMember = Member.builder()
+ .email(email)
+ .status(Status.ACTIVE)
+ .reportCount(0)
+ .role(Role.ROLE_USER)
+ .loginType(LoginType.GENERAL)
+ .isRegistrationCompleted(false)
+ .build();
+
+ newMember.setEncodedPassword(passwordEncoder.encode(request.getPassword()));
+ memberRepository.save(newMember);
+
+ //자동 로그인 처리
+ String newAccessToken = authCommandService.processLoginSuccess(newMember, response);
+
+ return LoginResponse.of(
+ newMember.getId(),
+ newMember.getNickname(),
+ true,
+ newMember.isRegistrationCompleted(),
+ newAccessToken);
+ }
+
+ @Override
+ public void withdrawMember(Long memberId) {
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND));
+
+ // 회원의 상태를 '삭제'로 변경 (soft delete)
+ member.softDelete();
+ // 별도의 memberRepository.save(member) 호출은 @Transactional 및 변경 감지로 반영됩니다.
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java b/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java
new file mode 100644
index 00000000..982a27e1
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java
@@ -0,0 +1,9 @@
+package com.example.mody.domain.member.service;
+
+import com.example.mody.domain.member.dto.response.MemberProfileResponse;
+import com.example.mody.domain.member.entity.Member;
+
+public interface MemberQueryService {
+ Member findMemberById(Long memberId);
+ MemberProfileResponse getMyProfile(Member member);
+}
diff --git a/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java b/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java
new file mode 100644
index 00000000..590096b0
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java
@@ -0,0 +1,37 @@
+package com.example.mody.domain.member.service;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository;
+import com.example.mody.domain.bodytype.service.BodyTypeService;
+import com.example.mody.domain.exception.MemberException;
+import com.example.mody.domain.member.dto.response.MemberProfileResponse;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class MemberQueryServiceImpl implements MemberQueryService{
+ private final BodyTypeService bodyTypeService;
+
+ private final MemberRepository memberRepository;
+ private final MemberBodyTypeRepository memberBodyTypeRepository;
+
+ @Override
+ public Member findMemberById(Long memberId) {
+ return memberRepository.findById(memberId)
+ .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND));
+ }
+
+ @Override
+ public MemberProfileResponse getMyProfile(Member member) {
+ Optional bodyType = bodyTypeService.findLastBodyType(member);
+ Long bodyTypeCount = memberBodyTypeRepository.countAllByMember(member);
+ return MemberProfileResponse.of(member, bodyType, bodyTypeCount);
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/post/constant/PostConstant.java b/src/main/java/com/example/mody/domain/post/constant/PostConstant.java
new file mode 100644
index 00000000..0f40c574
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/constant/PostConstant.java
@@ -0,0 +1,10 @@
+package com.example.mody.domain.post.constant;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class PostConstant {
+ public static final int POST_CONTENT_LIMIT = 1000;
+ public static final int POST_IMAGE_COUNT_LIMIT = 10;
+}
diff --git a/src/main/java/com/example/mody/domain/post/controller/PostController.java b/src/main/java/com/example/mody/domain/post/controller/PostController.java
new file mode 100644
index 00000000..db65263d
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/controller/PostController.java
@@ -0,0 +1,480 @@
+package com.example.mody.domain.post.controller;
+
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.domain.post.dto.request.PostUpdateRequest;
+import com.example.mody.domain.post.dto.response.PostResponse;
+import com.example.mody.domain.post.entity.Post;
+import io.swagger.v3.oas.annotations.Parameters;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.example.mody.domain.auth.security.CustomUserDetails;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.domain.post.dto.request.PostCreateRequest;
+import com.example.mody.domain.post.dto.response.PostListResponse;
+import com.example.mody.domain.post.exception.annotation.ExistsPost;
+import com.example.mody.domain.post.service.PostCommandService;
+import com.example.mody.domain.post.service.PostQueryService;
+import com.example.mody.global.common.base.BaseResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "POST API", description = "게시글 관련 API")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/posts")
+@Validated
+public class PostController {
+
+ private final PostCommandService postCommandService;
+ private final PostQueryService postQueryService;
+ private final MemberRepository memberRepository;
+
+ @GetMapping
+ @Operation(summary = "게시글 목록 조회 API", description = "전체 게시글에 대한 목록 조회 API")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "게시글 목록 조회 성공"
+ )
+ })
+ public BaseResponse getAllPosts(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @RequestParam(name = "cursor", required = false) Long cursor,
+ @RequestParam(name = "size", defaultValue = "15") Integer size) {
+ PostListResponse postListResponse = postQueryService.getPosts(customUserDetails.getMember(), size, cursor);
+ return BaseResponse.onSuccess(postListResponse);
+ }
+
+ /**
+ *
+ * @param request
+ * @param customUserDetails
+ * @return
+ */
+ @PostMapping
+ @Operation(summary = "게시글 작성 API", description = "인증된 유저의 게시글 작성 API")
+ @ApiResponses({
+ @ApiResponse(responseCode = "201", description = "게시글 작성 성공"),
+ @ApiResponse(responseCode = "MEMBER_BODY_TYPE404", description = "게시글을 작성하려는 유저가 아직 체형 분석을 진행하지 않은 경우"),
+ @ApiResponse(responseCode = "COMMON402", description = "Validation 관련 예외 - 파일 개수 제한 초과, content 길이 제한 초과"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "S3 url 목록은 비어있을 수 없습니다. 파일을 선택하거나, presigned url 생성 api를 재요청해주세요.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "COMMON402",
+ "message": "Validation Error입니다.",
+ "result": {
+ "s3Urls": "S3 url 목록은 비어 있을 수 없습니다."
+ }
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "올바르지 않은 S3 url입니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T15:15:54.334Z",
+ "code": "S3_404",
+ "message": "요청한 S3 객체를 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ })
+ public BaseResponse registerPost(
+ @Valid @RequestBody PostCreateRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ postCommandService.createPost(request, customUserDetails.getMember());
+ return BaseResponse.onSuccessCreate(null);
+ }
+
+ @DeleteMapping("/{postId}")
+ @Operation(summary = "게시글 삭제 API", description = "인증된 유저의 게시글 삭제 API")
+ @ApiResponses({
+ @ApiResponse(responseCode = "COMMON202", description = "게시글 삭제 성공"),
+ @ApiResponse(
+ responseCode = "COMMON401",
+ description = "로그인이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T21:23:51.4515304",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST403",
+ description = "작성자가 아닌 유저의 요청으로 권한이 없는 경우",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-27T20:56:55.7942672",
+ "code": "POST403",
+ "message": "해당 게시글에 대한 권한이 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST404",
+ description = "해당 게시물을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "isSuccess": "false",
+ "code": "POST404",
+ "message": "해당 게시물을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ )
+ })
+ @Parameters({
+ @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다")
+ })
+ public BaseResponse deletePost(
+ @PathVariable(name = "postId") Long postId,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ postCommandService.deletePost(postId, customUserDetails.getMember());
+ return BaseResponse.onSuccessDelete(null);
+ }
+
+ @Operation(
+ summary = "게시글 좋아요 API",
+ description = "특정 게시글에 좋아요를 등록합니다. 이미 좋아요가 눌러져 있다면 좋아요가 취소됩니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "좋아요 처리 성공",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BaseResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-14T10:00:00",
+ "code": "COMMON200",
+ "message": "요청에 성공하였습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-14T10:00:00",
+ "code": "COMMON400",
+ "message": "필수 정보가 누락되었습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "게시글 또는 사용자를 찾을 수 없음",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-14T10:00:00",
+ "code": "POST404",
+ "message": "해당 게시글을 찾을 수 없습니다.",
+ "result": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/{postId}/like")
+ public BaseResponse togglePostLike(
+ @PathVariable("postId")
+ @Parameter(
+ description = "좋아요를 누를 게시글 ID",
+ required = true,
+ example = "1"
+ ) @ExistsPost Long postId,
+ @AuthenticationPrincipal
+ @Parameter(
+ description = "인증된 사용자 정보",
+ hidden = true
+ ) CustomUserDetails customUserDetails
+ ) {
+ Long myId = customUserDetails.getMember().getId();
+ postCommandService.togglePostLike(myId, postId);
+ return BaseResponse.onSuccess(null);
+ }
+
+ @GetMapping("/liked")
+ @Operation(summary = "좋아요 누른 목록 조회 API", description = "좋아요 누른 게시글에 대한 목록 조회 API")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "게시글 목록 조회 성공"
+ )
+ })
+ public BaseResponse getLikedPosts(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @RequestParam(name = "cursor", required = false) Long cursor,
+ @RequestParam(name = "size", defaultValue = "15") Integer size) {
+ PostListResponse postListResponse = postQueryService.getLikedPosts(customUserDetails.getMember(), size, cursor);
+ return BaseResponse.onSuccess(postListResponse);
+ }
+
+ @GetMapping("/me")
+ @Operation(summary = "내가 작성한 게시글 목록 조회 API", description = "내가 작성한 게시글에 대한 목록 조회 API")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "게시글 목록 조회 성공"
+ )
+ })
+ public BaseResponse getMyPosts(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @RequestParam(name = "cursor", required = false) Long cursor,
+ @RequestParam(name = "size", defaultValue = "15") Integer size) {
+ PostListResponse postListResponse = postQueryService.getMyPosts(customUserDetails.getMember(), size, cursor);
+ return BaseResponse.onSuccess(postListResponse);
+ }
+
+ @PostMapping("/{postId}/reports")
+ @Operation(summary = "게시글 신고 API", description = "인증된 유저의 게시글 신고 API")
+ @ApiResponses({
+ @ApiResponse(responseCode = "COMMON200", description = "게시글 신고 성공"),
+ @ApiResponse(
+ responseCode = "COMMON401",
+ description = "로그인이 필요합니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T21:23:51.4515304",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST404",
+ description = "해당 게시물을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "isSuccess": "false",
+ "code": "POST404",
+ "message": "해당 게시물을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST409",
+ description = "이미 신고한 게시물 입니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "isSuccess": "false",
+ "code": "POST409",
+ "message": "이미 신고한 게시물 입니다."
+ }
+ """
+ )
+ )
+ )
+ })
+ @Parameters({
+ @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다")
+ })
+ public BaseResponse reportPost(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @PathVariable Long postId) {
+ postCommandService.reportPost(customUserDetails.getMember(), postId);
+
+ return BaseResponse.onSuccess(null);
+ }
+
+ @GetMapping("/{postId}")
+ @Operation(summary = "특정 게시글 조회 API", description = "특정 게시글 조회 API")
+ @ApiResponses({
+ @ApiResponse(responseCode = "COMMON200", description = "게시글 조회 성공"),
+ @ApiResponse(
+ responseCode = "COMMON401",
+ description = "로그인을 하지 않았을 경우",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T21:23:51.4515304",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST404",
+ description = "해당 게시물을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "isSuccess": "false",
+ "code": "POST404",
+ "message": "해당 게시물을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ )
+ })
+ @Parameters({
+ @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다")
+ })
+ public BaseResponse getPost(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @PathVariable Long postId) {
+ PostResponse postResponse=postQueryService.getPost(customUserDetails.getMember(), postId);
+
+ return BaseResponse.onSuccess(postResponse);
+ }
+
+
+ @PatchMapping("/{postId}")
+ @Operation(summary = "게시글 수정 API", description = "인증된 유저의 게시글 수정 API.\ncontent만 수정하더라도 항상 isPublic의 수정 정보까지 함께 받아오므로 이 점 주의해서 request body 작성해주시면 감사하겠습니다")
+ @ApiResponses({
+ @ApiResponse(responseCode = "COMMON200", description = "게시글 수정 성공"),
+ @ApiResponse(
+ responseCode = "COMMON401",
+ description = "로그인을 하지 않았을 경우",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-26T21:23:51.4515304",
+ "code": "COMMON401",
+ "message": "인증이 필요합니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST403",
+ description = "작성자가 아닌 유저의 요청으로 권한이 없는 경우",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2025-01-27T20:56:55.7942672",
+ "code": "POST403",
+ "message": "해당 게시글에 대한 권한이 없습니다."
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "POST404",
+ description = "해당 게시물을 찾을 수 없습니다.",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "timestamp": "2024-01-13T10:00:00",
+ "isSuccess": "false",
+ "code": "POST404",
+ "message": "해당 게시물을 찾을 수 없습니다."
+ }
+ """
+ )
+ )
+ )
+ })
+ @Parameters({
+ @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다")
+ })
+ public BaseResponse updatePost(
+ @Valid @RequestBody PostUpdateRequest request,
+ @PathVariable(name = "postId") Long postId,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ postCommandService.updatePost(request, postId, customUserDetails.getMember());
+
+ return BaseResponse.onSuccess(null);
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java b/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java
new file mode 100644
index 00000000..bfbd7c5f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java
@@ -0,0 +1,47 @@
+package com.example.mody.domain.post.dto.request;
+
+import static com.example.mody.domain.post.constant.PostConstant.*;
+
+import java.util.List;
+
+import jakarta.validation.constraints.NotEmpty;
+import org.hibernate.validator.constraints.Length;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+
+@Schema(description = "게시글 작성 DTO")
+@Getter
+@NoArgsConstructor
+public class PostCreateRequest {
+
+ @Schema(
+ description = "내용",
+ example = "스트레이트형 착장 예시"
+ )
+ @NotBlank(message = "content 는 필수입니다.")
+ @Length(max = POST_CONTENT_LIMIT, message = "메세지의 최대 길이 {max}를 초과했습니다.")
+ private String content;
+
+ @Schema(
+ description = "공개여부",
+ example = "true"
+ )
+ @NotNull(message = "공개여부 입력은 필수입니다.")
+ private Boolean isPublic;
+
+ @Schema(
+ description = "S3 url 목록",
+ example = "[\"https://{bucket-name}.s3.{region}.amazonaws.com/{object-key1}\", " +
+ "\"https://my-bucket.s3.amazonaws.com/path/to/file.jpg\"]"
+ )
+ @NotEmpty(message = "S3 url 목록은 비어 있을 수 없습니다.")
+ @Size(max = POST_IMAGE_COUNT_LIMIT, message = "파일의 최대 개수는 {max}를 초과할 수 없습니다.")
+ private List s3Urls;
+
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java b/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java
new file mode 100644
index 00000000..271cad90
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java
@@ -0,0 +1,33 @@
+package com.example.mody.domain.post.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Length;
+
+import static com.example.mody.domain.post.constant.PostConstant.POST_CONTENT_LIMIT;
+
+@Schema(description = "게시글 수정 DTO")
+@Getter
+@NoArgsConstructor
+public class PostUpdateRequest {
+
+ @Schema(
+ description = "내용",
+ example = "스트레이트형 착장 예시"
+ )
+ @NotBlank(message = "content 는 필수입니다.")
+ @Length(max = POST_CONTENT_LIMIT, message = "메세지의 최대 길이 {max}를 초과했습니다.")
+ private String content;
+
+
+ @Schema(
+ description = "공개여부",
+ example = "true"
+ )
+ @NotNull(message = "공개여부 입력은 필수입니다.")
+ private Boolean isPublic;
+
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java
new file mode 100644
index 00000000..4821d2b9
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java
@@ -0,0 +1,15 @@
+package com.example.mody.domain.post.dto.response;
+
+import com.example.mody.domain.post.entity.PostImage;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class PostImageResponse {
+ private String s3Url;
+ public static PostImageResponse of(PostImage postImage){
+ return new PostImageResponse(postImage.getUrl());
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java
new file mode 100644
index 00000000..7186a315
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java
@@ -0,0 +1,32 @@
+package com.example.mody.domain.post.dto.response;
+
+import com.example.mody.global.dto.response.CursorPagination;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class PostListResponse {
+ private List postResponses;
+ private CursorPagination cursorPagination;
+
+ public static PostListResponse of(Boolean hasNext, List postResponses){
+ Long cursor = hasNext ? postResponses.getLast().getPostId() : null;
+ return new PostListResponse(postResponses, new CursorPagination(hasNext, cursor));
+ }
+
+ /**
+ * 내가 좋아요 누른 게시글 조회 시 커서를 좋아요 id로 사용함. 따라서 아래와 같이 메서드를 오버로드하여 따로 사용
+ * @param hasNext 다음 페이지 유무
+ * @param postResponses 반환할 데이터
+ * @param cursor 반환하는 게시물 중 마지막 게시물과 클라이언트에 대한 likeId
+ * @return
+ */
+ public static PostListResponse of(Boolean hasNext, List postResponses, Long cursor){
+ Long newCursor = hasNext ? cursor : null;
+ return new PostListResponse(postResponses, new CursorPagination(hasNext, newCursor));
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java
new file mode 100644
index 00000000..e3b3baba
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java
@@ -0,0 +1,44 @@
+package com.example.mody.domain.post.dto.response;
+
+import com.example.mody.domain.post.entity.PostImage;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+public class PostResponse {
+
+ private Long postId;
+ private Long writerId;
+ private String writerNickName;
+ private String content;
+ private Boolean isPublic;
+ private Integer likeCount;
+ private Boolean isLiked;
+ private String bodyType;
+ private List files;
+
+ public PostResponse(Long postId,
+ Long writerId,
+ String nickName,
+ String content,
+ Boolean isPublic,
+ Integer likeCount,
+ Boolean isLiked,
+ String bodyType,
+ List files) {
+ this.postId = postId;
+ this.writerId = writerId;
+ this.writerNickName = nickName;
+ this.content = content;
+ this.isPublic = isPublic;
+ this.likeCount = likeCount;
+ this.isLiked = isLiked;
+ this.bodyType = bodyType;
+ this.files = files.stream()
+ .map(PostImageResponse::of)
+ .toList();
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java
new file mode 100644
index 00000000..a3b39e55
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java
@@ -0,0 +1,8 @@
+package com.example.mody.domain.post.dto.response.recode;
+
+import com.example.mody.domain.post.dto.response.PostResponse;
+
+import java.util.List;
+
+public record LikedPostsResponse(Boolean hasNext, List postResponses) {
+}
diff --git a/src/main/java/com/example/mody/domain/post/entity/Post.java b/src/main/java/com/example/mody/domain/post/entity/Post.java
new file mode 100644
index 00000000..c12ab354
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/entity/Post.java
@@ -0,0 +1,99 @@
+package com.example.mody.domain.post.entity;
+
+import static com.example.mody.domain.post.constant.PostConstant.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hibernate.annotations.DynamicUpdate;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.mapping.MemberPostLike;
+import com.example.mody.global.common.base.BaseEntity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity(name = "post")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "post")
+@DynamicUpdate
+public class Post extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "post_id")
+ private Long id;
+
+ @OneToMany(mappedBy = "post",
+ cascade = CascadeType.ALL,
+ orphanRemoval = true,
+ fetch = FetchType.LAZY)
+ private List images = new ArrayList<>();
+
+ @OneToMany(mappedBy = "post",
+ cascade = CascadeType.ALL,
+ orphanRemoval = true)
+ private List likes = new ArrayList<>();
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "body_type_id", nullable = false)
+ private BodyType bodyType;
+
+ @Column(length = POST_CONTENT_LIMIT)
+ private String content;
+
+ @Column(nullable = false)
+ private Integer likeCount;
+
+ @Column(nullable = false)
+ private Boolean isPublic;
+
+ @Column(nullable = false)
+ private Integer reportCount;
+
+ public Post(Member member, BodyType bodyType, String content, Boolean isPublic) {
+ this.member = member;
+ this.bodyType = bodyType;
+ this.content = content;
+ this.isPublic = isPublic;
+ this.likeCount = 0;
+ this.reportCount = 0;
+ this.images = new ArrayList<>();
+ }
+
+ public void decreaseLikeCount() {
+ this.likeCount--;
+ }
+
+ public void increaseLikeCount() {
+ this.likeCount++;
+ }
+
+ public void increaseReportCount() {
+ this.reportCount++;
+ }
+
+ public void updatePost(String content, boolean isPublic) {
+ this.content = content;
+ this.isPublic = isPublic;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/entity/PostImage.java b/src/main/java/com/example/mody/domain/post/entity/PostImage.java
new file mode 100644
index 00000000..1b71ffaf
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/entity/PostImage.java
@@ -0,0 +1,30 @@
+package com.example.mody.domain.post.entity;
+
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "post_image")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class PostImage extends BaseEntity {
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "post_image_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @Column(nullable = false)
+ private String url;
+
+ public PostImage(Post post, String s3Url){
+ this.post = post;
+ this.url = s3Url;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java
new file mode 100644
index 00000000..0595a9b0
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java
@@ -0,0 +1,55 @@
+package com.example.mody.domain.post.entity.mapping;
+
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.DynamicUpdate;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.global.common.base.BaseEntity;
+
+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.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Entity
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@DynamicInsert
+@DynamicUpdate
+@Table(name = "member_post_like")
+public class MemberPostLike extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "member_post_like_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id")
+ private Post post;
+
+ public static MemberPostLike createMemberPostLike(Member member, Post post) {
+ return MemberPostLike.builder()
+ .member(member)
+ .post(post)
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java b/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java
new file mode 100644
index 00000000..723ece61
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java
@@ -0,0 +1,36 @@
+package com.example.mody.domain.post.entity.mapping;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.global.common.base.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "post_report")
+public class PostReport extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "report_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id")
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ private Post post;
+
+ public PostReport(Member member, Post post) {
+ this.member = member;
+ this.post = post;
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java b/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java
new file mode 100644
index 00000000..fe5a9bfe
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java
@@ -0,0 +1,25 @@
+package com.example.mody.domain.post.exception.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.example.mody.domain.post.exception.validator.ExistsPostValidator;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Documented
+@Constraint(validatedBy = ExistsPostValidator.class)
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExistsPost {
+
+ String message() default "게시글이 존재하지 않습니다.";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java b/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java
new file mode 100644
index 00000000..dc54dc1a
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java
@@ -0,0 +1,34 @@
+package com.example.mody.domain.post.exception.validator;
+
+import org.springframework.stereotype.Component;
+
+import com.example.mody.domain.post.exception.annotation.ExistsPost;
+import com.example.mody.domain.post.repository.PostRepository;
+import com.example.mody.global.common.exception.code.status.PostErrorStatus;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class ExistsPostValidator implements ConstraintValidator {
+
+ private final PostRepository postRepository;
+
+ @Override
+ public boolean isValid(Long postId, ConstraintValidatorContext context) {
+
+ if (postId == null || !postRepository.existsById(postId)) {
+
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(
+ PostErrorStatus.POST_NOT_FOUND.getMessage()
+ ).addConstraintViolation();
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java b/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java
new file mode 100644
index 00000000..2734165f
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java
@@ -0,0 +1,15 @@
+package com.example.mody.domain.post.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.mapping.MemberPostLike;
+
+public interface MemberPostLikeRepository extends JpaRepository {
+
+ Optional findByPostAndMember(Post post, Member member);
+ Optional findByMemberAndPostId(Member member, Long postId);
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java
new file mode 100644
index 00000000..dafacb5a
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java
@@ -0,0 +1,17 @@
+package com.example.mody.domain.post.repository;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.dto.response.PostListResponse;
+import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse;
+import com.example.mody.domain.post.entity.Post;
+import jakarta.persistence.EntityManager;
+
+import java.util.Optional;
+
+public interface PostCustomRepository {
+
+ public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType);
+ public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member);
+ public PostListResponse getMyPosts(Long cursor, Integer size, Member member);
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java
new file mode 100644
index 00000000..c3fbfeec
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java
@@ -0,0 +1,287 @@
+package com.example.mody.domain.post.repository;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.entity.QMember;
+import com.example.mody.domain.post.dto.response.PostListResponse;
+import com.example.mody.domain.post.dto.response.PostResponse;
+import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.QPost;
+import com.example.mody.domain.post.entity.QPostImage;
+import com.example.mody.domain.post.entity.mapping.QMemberPostLike;
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.group.GroupBy;
+import com.querydsl.core.types.ConstantImpl;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.*;
+import com.querydsl.core.types.dsl.StringTemplate;
+import com.querydsl.jpa.JPAExpressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.function.BiFunction;
+
+@Repository
+@RequiredArgsConstructor
+@Slf4j
+public class PostCustomRepositoryImpl implements PostCustomRepository{
+
+ private final JPAQueryFactory jpaQueryFactory;
+
+ private final QPost qPost = QPost.post;
+ private final QMember qMember = QMember.member;
+ private final QMemberPostLike qMemberPostLike = QMemberPostLike.memberPostLike;
+ private final QPostImage qPostImage = QPostImage.postImage;
+
+
+ /**
+ * 게시글 전체 조회. 조회할 post 목록을 조회하는 쿼리, 해당 목록의 데이터들을 가져오는 쿼리, 각 게시글 별 좋아요 여부 확인 쿼리가 날라감.
+ * @param cursorPost
+ * @param size
+ * @param member
+ * @param bodyType
+ * @return
+ */
+ @Override
+ public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType) {
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ predicate.and(qPost.isPublic.eq(true)); // 공개여부 == true
+
+ if(cursorPost.isPresent()){
+ String customCursor = createCustomCursor(cursorPost.get(), bodyType);
+ log.info(customCursor);
+ predicate.and(applyCustomCursor(customCursor, bodyType));
+ }
+
+ BooleanExpression isLiked = Expressions.asBoolean(false);
+ if (member != null){
+ isLiked = isLikedResult(member);
+ }
+
+ //동적 정렬
+ List> orderSpecifiers = new ArrayList<>();
+ if (bodyType.isPresent()) {
+ orderSpecifiers.add(matchedBodyTypeAsInteger(bodyType).desc()); // bodyType이 존재할 때만 정렬 조건 추가
+ }
+ orderSpecifiers.add(qPost.createdAt.desc()); // 항상 createdAt으로 정렬
+
+ List postIds = jpaQueryFactory
+ .select(qPost.id)
+ .from(qPost)
+ .where(predicate)
+ .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
+ .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인
+ .fetch();
+
+ Map postResponseMap = jpaQueryFactory
+ .from(qPost)
+ .leftJoin(qPost.member, qMember)
+ .leftJoin(qPost.images, qPostImage)
+ .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost))
+ .where(qPost.id.in(postIds))
+ .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
+ .transform(GroupBy.groupBy(qPost.id).as(
+ Projections.constructor(PostResponse.class,
+ qPost.id,
+ qMember.id,
+ qMember.nickname,
+ qPost.content,
+ qPost.isPublic,
+ qPost.likeCount,
+ isLiked,
+ qPost.bodyType.name,
+ GroupBy.list(qPostImage)
+ )));
+
+ List postResponses = new ArrayList<>(postResponseMap.values());
+
+ return PostListResponse.of(hasNext.apply(postResponses, size),
+ postResponses.subList(0, Math.min(size, postResponses.size())));
+ }
+
+ /**
+ * 좋아요 누른 게시글 목록 조회
+ * @param cursor 좋아요의 id
+ * @param size
+ * @param member
+ * @return
+ */
+ public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member) {
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ if(cursor != null){
+ predicate.and(qMemberPostLike.id.lt(cursor));
+ }
+
+ predicate.and(qMemberPostLike.member.eq(member));
+
+ List postIds = jpaQueryFactory
+ .select(qPost.id)
+ .from(qPost)
+ .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member)))
+ .where(predicate)
+ .orderBy(qMemberPostLike.createdAt.desc())
+ .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인
+ .fetch();
+
+ Map postResponseMap = jpaQueryFactory
+ .from(qPost)
+ .leftJoin(qPost.member, qMember)
+ .leftJoin(qPost.images, qPostImage)
+ .where(qPost.id.in(postIds))
+ .transform(GroupBy.groupBy(qPost.id).as(
+ Projections.constructor(PostResponse.class,
+ qPost.id,
+ qMember.id,
+ qMember.nickname,
+ qPost.content,
+ qPost.isPublic,
+ qPost.likeCount,
+ Expressions.asBoolean(Expressions.TRUE),
+ qPost.bodyType.name,
+ GroupBy.list(qPostImage)
+ )));
+
+ List postResponses = postIds.stream()
+ .map(postResponseMap::get)
+ .filter(Objects::nonNull)
+ .toList();
+
+ return new LikedPostsResponse(
+ hasNext.apply(postResponses, size),
+ postResponses.subList(0, Math.min(size, postResponses.size())));
+ }
+
+ @Override
+ public PostListResponse getMyPosts(Long cursor, Integer size, Member member) {
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ predicate.and(qPost.member.eq(member));
+
+ if(cursor != null){
+ predicate.and(qPost.id.lt(cursor));
+ }
+
+ log.info(predicate.toString());
+
+ List postIds = jpaQueryFactory
+ .select(qPost.id)
+ .from(qPost)
+ .where(predicate)
+ .orderBy(qPost.createdAt.desc())
+ .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인
+ .fetch();
+
+ Map postResponseMap = jpaQueryFactory
+ .from(qPost)
+ .leftJoin(qPost.member, qMember)
+ .leftJoin(qPost.images, qPostImage)
+ .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost))
+ .where(qPost.id.in(postIds))
+ .orderBy( qPost.createdAt.desc())
+ .transform(GroupBy.groupBy(qPost.id).as(
+ Projections.constructor(PostResponse.class,
+ qPost.id,
+ qMember.id,
+ qMember.nickname,
+ qPost.content,
+ qPost.isPublic,
+ qPost.likeCount,
+ isLikedResult(member),
+ qPost.bodyType.name,
+ GroupBy.list(qPostImage)
+ )));
+
+ List postResponses = new ArrayList<>(postResponseMap.values());
+
+ return PostListResponse.of(hasNext.apply(postResponses, size),
+ postResponses.subList(0, Math.min(size, postResponses.size())));
+ }
+
+ private BiFunction , Integer, Boolean> hasNext = (list, size) -> list.size() > size;
+
+ /**
+ * // 정렬 기준. 특정 바디 타입이 요구되지 않으면 전부 1
+ * @param bodyType
+ * @return
+ */
+ private NumberExpression matchedBodyTypeAsInteger(Optional bodyType){
+ if(bodyType.isPresent()){
+ return new CaseBuilder()
+ .when(qPost.bodyType.eq(bodyType.get())).then(1)
+ .otherwise(0);
+ }
+ return Expressions.asNumber(0);
+ }
+
+ private StringExpression matchedBodyTypeAsString(Optional bodyType){
+ if(bodyType.isPresent()){
+ return new CaseBuilder()
+ .when(qPost.bodyType.eq(bodyType.get())).then("1")
+ .otherwise("0");
+ }
+ return Expressions.asString("0");
+ }
+
+ private BooleanExpression isLikedResult(Member member){
+ return JPAExpressions
+ .selectFrom(qMemberPostLike)
+ .where(qMemberPostLike.member.eq(member).and(qMemberPostLike.post.eq(qPost)))
+ .exists();
+ }
+
+ private String createCustomCursor(Post cursor, Optional bodyType){
+ if (cursor == null || bodyType == null) {
+ return null;
+ }
+
+ String isMatchedBodyType = "0";
+ if(bodyType.isPresent()){
+ if(cursor.getBodyType().getId().equals(bodyType.get().getId())){
+ isMatchedBodyType = "1";
+ }
+ }
+
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+ return isMatchedBodyType + String.format("%19s", formatter.format(cursor.getCreatedAt())).replaceAll(" ","0");
+ }
+
+ /**
+ * createdAt을 'YYYYMMDDHHmmss' 로 변환
+ * @return
+ */
+ private StringTemplate mySqlDateFormat(){
+ return Expressions.stringTemplate(
+ "CAST(DATE_FORMAT({0}, {1}) AS STRING)",
+ qPost.createdAt,
+ ConstantImpl.create("%Y%m%d%H%i%s")
+ );
+ }
+
+ private StringExpression formatCreatedAt(StringTemplate stringTemplate){
+ return StringExpressions.lpad(stringTemplate, 19, '0');
+ }
+
+ private BooleanExpression applyCustomCursor(String customCursor, Optional bodyType) {
+ if (customCursor == null) { // 커서가 없으면 조건 없음
+ return null;
+ }
+
+ // bodyType 순서 계산
+ StringExpression isMatchedBodyType = matchedBodyTypeAsString(bodyType); // bodyType 일치 여부
+ StringTemplate postCreatedAtTemplate = mySqlDateFormat(); //DATE_FORMAT으로 날짜 형태 변경
+ StringExpression formattedCreatedAt = formatCreatedAt(postCreatedAtTemplate); // 날짜 형태 변경을 포맷
+
+ return isMatchedBodyType.concat(formattedCreatedAt)
+ .lt(customCursor);
+ }
+
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java
new file mode 100644
index 00000000..54694c5c
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java
@@ -0,0 +1,16 @@
+package com.example.mody.domain.post.repository;
+
+import com.example.mody.domain.post.entity.PostImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface PostImageRepository extends JpaRepository {
+ @Query("SELECT pi.id FROM PostImage pi WHERE pi.post.id = :postId")
+ List findPostImageIdByPostId(@Param("postId") Long postId);
+
+ @Query("SELECT pi.url FROM PostImage pi WHERE pi.post.id = :postId")
+ List findPostImageUrlByPostId(@Param("postId") Long postId);
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java
new file mode 100644
index 00000000..a7d89bef
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java
@@ -0,0 +1,11 @@
+package com.example.mody.domain.post.repository;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.mapping.PostReport;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostReportRepository extends JpaRepository {
+ boolean existsByMemberAndPost(Member member, Post post);
+ void deleteAllByPost(Post post);
+}
diff --git a/src/main/java/com/example/mody/domain/post/repository/PostRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostRepository.java
new file mode 100644
index 00000000..0942bcfd
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/repository/PostRepository.java
@@ -0,0 +1,12 @@
+package com.example.mody.domain.post.repository;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.entity.Post;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface PostRepository extends JpaRepository, PostCustomRepository {
+}
diff --git a/src/main/java/com/example/mody/domain/post/service/PostCommandService.java b/src/main/java/com/example/mody/domain/post/service/PostCommandService.java
new file mode 100644
index 00000000..c5cfb86e
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/service/PostCommandService.java
@@ -0,0 +1,18 @@
+package com.example.mody.domain.post.service;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.dto.request.PostCreateRequest;
+import com.example.mody.domain.post.dto.request.PostUpdateRequest;
+import com.example.mody.domain.post.dto.response.PostResponse;
+import com.example.mody.domain.post.entity.Post;
+
+public interface PostCommandService {
+ public void createPost(PostCreateRequest postCreateRequest, Member member);
+
+ public void deletePost(Long postId, Member member);
+
+ void togglePostLike(Long myId, Long postId);
+
+ public void reportPost(Member member, Long postId);
+ public void updatePost(PostUpdateRequest request, Long postId, Member member);
+}
diff --git a/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java
new file mode 100644
index 00000000..286105dc
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java
@@ -0,0 +1,192 @@
+package com.example.mody.domain.post.service;
+
+import static com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus.*;
+import static com.example.mody.global.common.exception.code.status.PostErrorStatus.*;
+
+import java.util.List;
+import java.util.Optional;
+
+
+import com.example.mody.domain.file.repository.BackupFileRepository;
+import com.example.mody.domain.file.service.FileService;
+import com.example.mody.domain.image.service.S3Service;
+import com.example.mody.domain.post.dto.request.PostUpdateRequest;
+import com.example.mody.domain.post.entity.mapping.PostReport;
+import com.example.mody.domain.post.repository.PostReportRepository;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.S3ErrorStatus;
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.service.BodyTypeService;
+import com.example.mody.domain.exception.BodyTypeException;
+import com.example.mody.domain.exception.MemberException;
+import com.example.mody.domain.exception.PostException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.member.repository.MemberRepository;
+import com.example.mody.domain.post.dto.request.PostCreateRequest;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.PostImage;
+import com.example.mody.domain.post.entity.mapping.MemberPostLike;
+import com.example.mody.domain.post.repository.MemberPostLikeRepository;
+import com.example.mody.domain.post.repository.PostImageRepository;
+import com.example.mody.domain.post.repository.PostRepository;
+import com.example.mody.global.common.exception.code.status.MemberErrorStatus;
+import com.example.mody.global.common.exception.code.status.PostErrorStatus;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.RestTemplate;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class PostCommandServiceImpl implements PostCommandService {
+ private final PostRepository postRepository;
+ private final MemberRepository memberRepository;
+ private final MemberPostLikeRepository postLikeRepository;
+ private final PostImageRepository postImageRepository;
+ private final BackupFileRepository backupFileRepository;
+ private final PostReportRepository postReportRepository;
+ private final BodyTypeService bodyTypeService;
+ private final FileService fileService;
+ private final RestTemplate restTemplate;
+ private final S3Service s3Service;
+
+ /**
+ * 게시글 작성 비즈니스 로직. BodyType은 요청 유저의 가장 마지막 BodyType을 적용함. 유저의 BodyType이 존재하지 않을 경우 예외 발생.
+ * @param postCreateRequest
+ * @param member
+ */
+ @Override
+ public void createPost(PostCreateRequest postCreateRequest, Member member) {
+ Optional optionalBodyType = bodyTypeService.findLastBodyType(member);
+
+ BodyType bodyType = optionalBodyType.orElseThrow(() -> new BodyTypeException(MEMBER_BODY_TYPE_NOT_FOUND));
+
+ Post post = new Post(member,
+ bodyType,
+ postCreateRequest.getContent(),
+ postCreateRequest.getIsPublic());
+
+ postCreateRequest.getS3Urls().forEach(s3Url -> {
+ validateS3Url(s3Url); // 유효한 S3 url인지 검증
+ PostImage postImage = new PostImage(post, s3Url);
+ post.getImages().add(postImage);
+ });
+
+ postRepository.save(post);
+ }
+
+ private void validateS3Url(String s3Url) {
+ try {
+ // S3 url에 GET 요청을 보내서 유효한지 확인
+ restTemplate.exchange(s3Url, HttpMethod.GET, null, Void.class);
+ } catch (HttpClientErrorException e) {
+ throw new RestApiException(S3ErrorStatus.OBJECT_NOT_FOUND);
+ }
+ }
+
+ @Override
+ @Transactional
+ public void deletePost(Long postId, Member member) {
+ // 게시글 조회 및 존재 여부 확인
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostException(POST_NOT_FOUND));
+
+ checkAuthorization(member, post);
+ delete(post);
+ }
+
+ @Override
+ public void togglePostLike(Long myId, Long postId) {
+
+ // 게시글과 사용자 존재 여부는 @ExistsPost와 @ExistsMember 어노테이션으로 이미 검증되었습니다.
+ // 하지만 실제 엔티티가 필요하므로 조회합니다.
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostException(PostErrorStatus.POST_NOT_FOUND));
+
+ Member member = memberRepository.findById(myId)
+ .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND));
+
+ // 이미 좋아요를 눌렀는지 확인합니다.
+ Optional existingLike = postLikeRepository.findByPostAndMember(post, member);
+
+ if (existingLike.isPresent()) {
+ // 이미 좋아요가 있다면 취소(삭제)합니다.
+ postLikeRepository.delete(existingLike.get());
+ // 게시글의 좋아요 수를 감소시킵니다.
+ post.decreaseLikeCount();
+ post.getMember().decreaseLikeCount();
+ } else {
+ // 좋아요가 없다면 새로 생성합니다.
+ MemberPostLike postLike = MemberPostLike.createMemberPostLike(member, post);
+ postLikeRepository.save(postLike);
+ // 게시글의 좋아요 수를 증가시킵니다.
+ post.increaseLikeCount();
+ post.getMember().increaseLikeCount();
+ }
+
+ // 게시글 엔티티는 @Transactional에 의해 자동으로 저장됩니다.
+ }
+
+ @Override
+ public void reportPost(Member member, Long postId) {
+
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostException(POST_NOT_FOUND));
+
+ // 이미 신고를 했는지 확인
+ if (postReportRepository.existsByMemberAndPost(member, post)) {
+ throw new PostException(POST_ALREADY_REPORT);
+ }
+
+ // 신고 기록 저장
+ PostReport postReport = new PostReport(member, post);
+ postReportRepository.save(postReport);
+
+ // 신고 횟수 증가
+ post.getMember().increaseReportCount();
+ post.increaseReportCount();
+
+ // 신고 횟수가 10회 이상이면 게시글 삭제
+ if (post.getReportCount() >= 10) {
+ postReportRepository.deleteAllByPost(post);
+ delete(post);
+ }
+ }
+
+ @Override
+ public void updatePost(PostUpdateRequest request, Long postId, Member member){
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostException(POST_NOT_FOUND));
+ checkAuthorization(member, post);
+ post.updatePost(request.getContent(), request.getIsPublic());
+ }
+
+ @Transactional
+ protected void delete(Post post) {
+ List postImageUrls = postImageRepository.findPostImageUrlByPostId(post.getId());
+
+ // S3 파일 삭제
+ s3Service.deleteFile(postImageUrls);
+
+ // DB에서 Post 관련 데이터 삭제
+ fileService.deleteByS3Urls(postImageUrls);
+ decreaseLikeCount(post);
+ postRepository.deleteById(post.getId()); // (cascade = CascadeType.ALL, orphanRemoval = true) -> postId에 해당하는 PostImage 전부 삭제됨
+ }
+
+ private void decreaseLikeCount(Post post){
+ Integer postLikeCount = post.getLikeCount();
+ post.getMember().decreaseLikeCount(postLikeCount);
+ }
+
+ private void checkAuthorization(Member member, Post post){
+ if(! member.equals(post.getMember())){
+ throw new PostException(POST_FORBIDDEN);
+ }
+ }
+}
diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryService.java b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java
new file mode 100644
index 00000000..430729be
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java
@@ -0,0 +1,12 @@
+package com.example.mody.domain.post.service;
+
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.dto.response.PostListResponse;
+import com.example.mody.domain.post.dto.response.PostResponse;
+
+public interface PostQueryService {
+ public PostListResponse getPosts(Member member, Integer size, Long cursor);
+ public PostListResponse getLikedPosts(Member member, Integer size, Long cursor);
+ public PostListResponse getMyPosts(Member member, Integer size, Long cursor);
+ public PostResponse getPost(Member member, Long postId);
+}
diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java
new file mode 100644
index 00000000..ecc2e9dc
--- /dev/null
+++ b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java
@@ -0,0 +1,105 @@
+package com.example.mody.domain.post.service;
+
+import com.example.mody.domain.bodytype.entity.BodyType;
+import com.example.mody.domain.bodytype.service.BodyTypeService;
+import com.example.mody.domain.exception.PostException;
+import com.example.mody.domain.member.entity.Member;
+import com.example.mody.domain.post.dto.response.PostListResponse;
+import com.example.mody.domain.post.dto.response.PostResponse;
+import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse;
+import com.example.mody.domain.post.entity.Post;
+import com.example.mody.domain.post.entity.mapping.MemberPostLike;
+import com.example.mody.domain.post.repository.MemberPostLikeRepository;
+import com.example.mody.domain.post.repository.PostRepository;
+import com.example.mody.global.common.exception.RestApiException;
+import com.example.mody.global.common.exception.code.status.GlobalErrorStatus;
+import com.example.mody.global.common.exception.code.status.MemberPostLikeErrorStatus;
+import com.example.mody.global.common.exception.code.status.PostErrorStatus;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+import static com.example.mody.global.common.exception.code.status.PostErrorStatus.POST_NOT_FOUND;
+
+@Service
+@RequiredArgsConstructor
+public class PostQueryServiceImpl implements PostQueryService {
+ private final BodyTypeService bodyTypeService;
+ private final PostRepository postRepository;
+ private final MemberPostLikeRepository memberPostLikeRepository;
+ private final MemberPostLikeRepository postLikeRepository;
+
+ @Override
+ @Transactional(readOnly = true)
+ public PostListResponse getPosts(Member member, Integer size, Long cursor) {
+
+ if(size<=0){
+ throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST);
+ }
+
+ Optional