diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..a40b6a9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,22 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## 어떤 버그인가요?
+
+> 어떤 버그인지 간결하게 설명해주세요
+
+## 어떤 상황에서 발생한 버그인가요?
+
+> (가능하면) Given-When-Then 형식으로 서술해주세요
+
+## 예상 결과
+
+> 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요
+
+## 참고할만한 자료(선택)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..eaa144a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## 어떤 기능인가요?
+
+> 추가하려는 기능에 대해 간결하게 설명해주세요
+
+## 작업 상세 내용
+
+- [ ] TODO
+- [ ] TODO
+- [ ] TODO
+
+## 참고할만한 자료(선택)
diff --git a/.github/workflows/docker-multi-stage-build.yml b/.github/workflows/docker-multi-stage-build.yml
new file mode 100644
index 0000000..1d7b783
--- /dev/null
+++ b/.github/workflows/docker-multi-stage-build.yml
@@ -0,0 +1,105 @@
+name: docker multi-stage build
+
+on:
+ pull_request:
+ branches:
+ - develop
+ push:
+ branches:
+ - develop
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # 모든 히스토리를 가져와서 최신 코드 보장
+
+ - name: Grant execute permission to gradlew
+ run: chmod +x ./gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew build -x test
+
+ # AWS 인증 설정
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v1
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ap-northeast-2
+
+ # ECR 로그인
+ - name: Login to ECR
+ uses: aws-actions/amazon-ecr-login@v1
+ id: login-ecr
+
+ # Docker 이미지 빌드 및 ECR에 Push
+ - name: Build and Push Docker image
+ env:
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+ ECR_REPOSITORY: game_mate
+ IMAGE_TAG: latest
+ run: |
+ docker build --no-cache -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+
+ # deploy.sh & docker-compose.yml을 EC2로 업로드
+ - name: Upload deploy script and docker-compose.yml to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ubuntu
+ key: ${{ secrets.EC2_KEY }}
+ source: "./deploy.sh, ./docker-compose.yml"
+ target: "/home/ubuntu/"
+
+ # .env 파일 생성 후 EC2로 전송
+ - name: Create and Upload .env file to EC2
+ run: |
+ echo "DOCKERHUB_USERNAME=${{ vars.DOCKERHUB_USERNAME }}" > .env
+ echo "DOCKER_IMAGE_TAG_NAME=${{ vars.DOCKER_IMAGE_TAG_NAME }}" >> .env
+ echo "MYSQL_USERNAME=${{ secrets.MYSQL_USERNAME }}" >> .env
+ echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> .env
+ echo "MYSQL_URL=${{ secrets.MYSQL_URL }}" >> .env
+ echo "MYSQL_PROD_URL=${{ secrets.MYSQL_PROD_URL }}" >> .env
+ echo "MYSQL_DEV_URL=${{ secrets.MYSQL_DEV_URL }}" >> .env
+ echo "JPA_HIBERNATE_DDL=${{ secrets.JPA_HIBERNATE_DDL }}" >> .env
+ echo "JPA_HIBERNATE_DDL_PROD=${{ secrets.JPA_HIBERNATE_DDL_PROD }}" >> .env
+ echo "JPA_HIBERNATE_DDL_DEV=${{ secrets.JPA_HIBERNATE_DDL_DEV }}" >> .env
+ echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
+ echo "AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }}" >> .env
+ echo "AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }}" >> .env
+ echo "AWS_BUCKET=${{ secrets.AWS_BUCKET }}" >> .env
+ echo "AWS_REGION=${{ secrets.AWS_REGION }}" >> .env
+ echo "AWS_STACK_AUTO=${{ secrets.AWS_STACK_AUTO }}" >> .env
+ echo "OAUTH2_GOOGLE_CLIENT_ID=${{ secrets.OAUTH2_GOOGLE_CLIENT_ID }}" >> .env
+ echo "OAUTH2_GOOGLE_CLIENT_SECRET=${{ secrets.OAUTH2_GOOGLE_CLIENT_SECRET }}" >> .env
+ echo "OAUTH2_KAKAO_CLIENT_ID=${{ secrets.OAUTH2_KAKAO_CLIENT_ID }}" >> .env
+ echo "OAUTH2_KAKAO_CLIENT_SECRET=${{ secrets.OAUTH2_KAKAO_CLIENT_SECRET }}" >> .env
+ echo "EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }}" >> .env
+ echo "EMAIL_APP_PASSWORD=${{ secrets.EMAIL_APP_PASSWORD }}" >> .env
+ echo "GEMINI_URL=${{ secrets.GEMINI_URL }}" >> .env
+ echo "GEMINI_KEY=${{ secrets.GEMINI_KEY }}" >> .env
+ echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env
+ - name: Upload .env file to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ubuntu
+ key: ${{ secrets.EC2_KEY }}
+ source: "./.env"
+ target: "/home/ubuntu/"
+
+ # EC2에서 deploy.sh 실행 (최신 Docker 이미지 가져와서 실행)
+ - name: Deploy to EC2
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ubuntu
+ key: ${{ secrets.EC2_KEY }}
+
+ script: |
+ chmod +x /home/ubuntu/deploy.sh
+ /home/ubuntu/deploy.sh
diff --git a/.gitignore b/.gitignore
index a00fec8..5526412 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,5 @@ out/
.vscode/
.env
+.DS_Store
+**/.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..893185b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+# Build 스테이지
+FROM gradle:8.10.2-jdk17 AS builder
+
+# 작업 디렉토리 설정
+WORKDIR /apps
+
+# 빌더 이미지에서 애플리케이션 빌드
+COPY . /apps
+#RUN gradle clean build --no-daemon --parallel
+RUN gradle clean build -x test --no-daemon --parallel
+
+# 실행 스테이지
+# OpenJDK 17 slim 기반 이미지 사용
+FROM openjdk:17-jdk-slim
+
+# 이미지에 레이블 추가
+LABEL type="application"
+
+# 작업 디렉토리 설정
+WORKDIR /apps
+
+# 애플리케이션 jar 파일을 컨테이너로 복사
+COPY --from=builder /apps/build/libs/*-SNAPSHOT.jar /apps/app.jar
+
+# 애플리케이션이 사용할 포트 노출
+EXPOSE 8080
+
+# 애플리케이션을 실행하기 위한 엔트리포인트 정의
+ENTRYPOINT ["java", "-jar", "/apps/app.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index ad52b09..0166f92 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,377 @@
-# GameMate
\ No newline at end of file
+
+
+
+# 👀  👀
+
+### 다양한 게임 정보와 리뷰를 공유하고 게임을 같이 할 친구를 찾을 수 있는 커뮤니티 사이트
+
+
+## 👨👩👧👦 Our Team
+
+|이예지|전수연|고강혁|양제훈|
+|:---:|:---:|:---:|:---:|
+|[@yeji-world](https://github.com/yeji-world)|[@sumyeom](https://github.com/sumyeom)|[@Newbiekk](https://github.com/Newbiekk-kkh)|[@89JHoon](https://github.com/89JHoon)|
+|BE|BE|BE|BE|
+
+
+
+## 프로젝트 기능
+
+### 🛡 OAuth2 소셜로그인 (kakao, google)
+
+> * Kakao와 google 통한 간편 로그인이 가능합니다.
+
+### 📧 이메일 인증
+
+> * 이메일 인증 기능을 지원합니다.
+
+
+### 👥 게임 매칭 시스템 제공
+
+> * 현재 '리그오브레전드' 라는 게임에 대해 매칭 시스템을 통해 친구를 구할 수 있습니다.
+> * 내 정보를 입력하고, 원하는 상대방의 조건을 입력하여 매칭 로직을 통해 최대 5인의 추천을 받을 수 있습니다.
+
+
+### 🎮 게임 정보 확인
+
+> * 다양한 게임에 대한 정보를 얻을 수 있고, 각 게임에 대해 사용자들이 작성한 리뷰를 확인 할 수 있습니다.
+
+
+### ❗️ 게임 추천
+
+> * 내가 원하는 성향을 가진 게임을 추천하는 서비스를 제공합니다.
+> * Gemini API 사용해 사용자가 입력한 내용을 기반으로 게임을 추천해줍니다.
+
+
+### 📖 게임 커뮤니티
+
+> * 게임에 대한 게시글을 작성하고, 게시글에 댓글 대댓글을 작성할 수 있습니다.
+> * 조회수 상위 5개의 게시글을 오늘의 게시글로 선정해 사용자에게 제공합니다.
+
+
+### 👨👩👦 소셜 기능
+
+> * 사용자간의 팔로우 기능을 제공합니다.
+> * 게시물 / 게임 리뷰에 '좋아요' 또는 '싫어요'를 달 수 있습니다.
+
+### 🔔 실시간 알림 기능
+
+> * 알림은 SSE를 통해 실시간으로 제공됩니다.
+> * 매칭 / 팔로우 / 댓글 / 좋아요 등의 이벤트에 대해 알림이 제공됩니다.
+
+### 🎫 쿠폰 기능
+
+> * 자체 쿠폰을 발급해, 사용자들에게 여러 혜택들을 제공합니다.
+
+
+
+
+
+## 📚 적용 기술
+
+| 분야 | **기술 및 도구** | **목적** |
+| --- | --- | --- |
+| **애플리케이션 개발** | JDK 17 Spring Boot 3.4.1 IntelliJ IDEA | 1️⃣ **JDK 17** - 성능, 보안, 개발 효율성을 위한 안정적인 운영 환경 2️⃣ **Spring Boot 3.4.1** - 프로덕션 환경에서의 안정성과 클라우드 네이티브 기능 강화 3️⃣ **IntelliJ IDEA** - 개발자 생산성을 극대화하는 강력한 통합 개발 환경(IDE) |
+| **인증 / 인가** | Spring Security JWT OAuth 2.0 | 1️⃣ **Spring Security** - 웹 애플리케이션 보안 통합 관리 2️⃣ **JWT** - 무상태(Stateless) 인증 구현 3️⃣ **OAuth 2.0** - 제3자 서비스의 안전한 자원 접근 관리 |
+| **협업** | Git GitHub Slack Notion GitHub Project / Issue Trello WBS | 1️⃣ **Git & GitHub** - 코드 협업과 버전 관리 2️⃣ **Slack & Notion** - 진행 사항에 대해 소통하고, 문서화 3️⃣ **GitHub Project / Issue & Trello & WBS** - 요구 사항에 대한 일정 및 우선순위 파악, 정리 - 각 일정에 대한 진행 사항 체크 |
+| **Database** | RDS(MySQL) Redis | 1️⃣ **RDS(MySQL)** - RDS를 통해 DB를 위한 인프라 구축 - 안정적인 관계형 DB로 데이터 관리 2️⃣ **Redis** - 게시글 조회수 카운팅을 캐시를 활용하여 효과적으로 관리 - Redisson 기반 분산 락을 통해 데이터 일관성 유지 - Redis Stream을 사용해 다중 WAS 환경에서도 알림이 유실되지 않고 전달되도록 관리 |
+| **파일 첨부** | AWS S3 | 1️⃣ **AWS S3** - 대용량 데이터를 안정적으로 유지 관리 |
+| **CI/CD** | Docker GitHub Action AWS EC2 AWS | 1️⃣ **Docker** - 일관된 개발 환경 제공 - 배포 및 확장이 용이 2️⃣ **GitHub Action** - CI/CD 자동화를 구현하고, 배포 프로세스를 효율적으로 관리 3️⃣ **AWS** - 클라우드 환경에서 안정적인 서비스 운영 |
+| **외부 API** | Gemini API Google OAuth 2.0 API Kakao OAuth 2.0 API JavaMail | 1️⃣ **Gemini API** - 신뢰성 있는 게임 추천을 위한 API 2️⃣ **OAuth 2.0 API (Google & Kakao)** - 소셜 로그인을 위한 API 3️⃣ **JavaMail** - 메일 발송을 위한 API |
+
+
+
+## 📝 기술적 의사 결정
+
+
+ Gemini AI 활용 방안
+
+## 1. 배경
+- 게임 추천 시스템은 단순한 게임 선택을 넘어 **사용자 경험 설계**부터 **윤리적 검증**까지 종합적인 접근이 필요함.
+- 플레이어의 **세션 데이터**를 지속적으로 학습에 반영하면서도, 추천의 **근거를 투명하게 제시**하는 것이 장기적 신뢰 확보의 핵심.
+- AI를 이용해 게임을 추천하려면 다음 요소들을 종합적으로 분석해야 함:
+ - **사용자 취향** (장르, 플레이 스타일)
+ - **보유 기기** (PC, 콘솔, 모바일)
+ - **최신 트렌드**
+ - **소셜 데이터** (친구, 스트리머 추천)
+ - **추천 알고리즘** (콘텐츠 기반, 협업 필터링)
+ - **할인 정보, 연령 등급** 등의 요소 고려
+- 또한, **사용자 피드백을 반영**하여 지속적으로 추천 시스템을 개선하는 것이 중요함.
+
+## 2. 선택 이유
+- **구글의 Gemini AI**는 **멀티모달 기능**(텍스트, 이미지, 음성 등)을 지원하며, 강력한 **자연어 처리(NLP)**와 **데이터 분석 기능**을 제공.
+- Gemini AI를 활용하면 단순한 게임 추천이 아니라 **사용자와 대화하며 취향을 파악하고 맞춤형 추천을 제공하는 AI 시스템**을 구축 가능.
+- 특히, **Google Cloud의 API**와 결합하면 더 정교한 추천 모델을 구축할 수 있어 지속적인 발전이 기대됨.
+- 결론적으로, **Gemini AI를 활용하면 더욱 정밀하고 개인화된 게임 추천 시스템 구축**을 기대할 수 있음.
+
+## 3. 대안 비교
+| 대안 | 장점 | 단점 |
+| --- | --- | --- |
+| **Microsoft Azure OpenAI + Game Pass 데이터 연계** | - **Xbox Game Pass 데이터**와 연계 가능 - **클라우드 기반 확장성** 우수 | - **Microsoft 생태계** 내에서 활용도가 높음 - **범용적인 추천 시스템 구축에는 한계** |
+| **Claude (Anthropic)** | - **긴 문맥을 유지하는 대화 능력** 우수 - **친환경적이고 안전한 AI 설계** | - **게임 추천 알고리즘 구축에는 다소 한계** |
+
+
+
+ AWS S3 활용 방안
+
+## 1. 배경
+- 커뮤니티에서 **첨부파일(이미지) 관리**는 여러 측면에서 중요함:
+ - **사용자 경험(UX) 향상**: 이미지가 포함된 게시글은 가독성을 높이고 몰입감을 제공.
+ - **서버 성능 최적화**: 저장 공간과 로딩 속도를 고려한 최적화 필요.
+ - **보안 강화**: 불법 콘텐츠 필터링 및 악성 코드 방지를 위한 보안 조치 필수.
+ - **검색 최적화**: SEO 최적화를 통해 접근성을 높일 수 있음.
+ - **모바일 대응**: 다양한 네트워크 환경에서도 원활한 사용 가능.
+- 효과적인 이미지 관리를 위해 **이미지 압축, AI 필터링, CDN 활용** 등의 전략이 필요하며, 이를 잘 적용하여 **원활한 커뮤니티 운영**을 목표로 함.
+
+## 2. 선택 이유
+1. **대용량 이미지 저장 가능**
+2. **빠른 이미지 로딩 속도**
+ - 전 세계 어디에서든 **빠르게 로딩 가능**
+ - 모바일 및 저속 인터넷 환경에서도 **원활한 서비스 제공**
+3. **데이터 손실 안전 및 보안 강화**
+ - **자동 데이터 복제**: 여러 데이터 센터에 복제 저장하여 **데이터 손실 방지**
+ - **접근 제어 가능**: 퍼블릭/프라이빗 설정을 통해 특정 사용자만 접근 가능
+4. **비용 절감 효과**
+ - **사용량 기반 과금**으로 불필요한 비용 절감
+5. **결론**
+ - S3를 사용하면 **이미지 저장이 편리하고, 로딩 속도가 빠르며, 데이터 손실 위험이 낮고, 보안이 뛰어나며, 비용 절감 가능**
+ - 특히, **많은 사용자가 이미지를 업로드하고 조회하는 커뮤니티 게시판에 적절한 솔루션**
+
+## 3. 대안 비교
+| 대안 | 장점 | 단점 |
+| --- | --- | --- |
+| **Azure Blob Storage** | - 마이크로소프트 환경과 연계 용이 | - AWS 대비 **확장성과 글로벌 커버리지 부족** - 관리 콘솔이 다소 복잡 |
+| **Firebase Storage** | - **모바일 앱과의 연동 최적화** | - **대량 이미지 저장보다는 모바일 앱 중심** - **데이터 관리 기능이 제한적** |
+
+
+
+ SSE(Server-Sent Events) 기반의 실시간 알림 시스템
+
+## 1. SSE 도입 배경
+- 초기 알림 기능은 **스케줄러를 활용하여 5분마다 미확인된 알림을 이메일로 전송**하는 방식으로 구현됨.
+- 그러나 이러한 방식은 **실시간성이 부족하여 즉각적인 알림 제공이 불가능**했음.
+- 이를 개선하기 위해 **SSE(Server-Sent Events) 기반의 실시간 알림 시스템**을 도입하게 됨.
+
+## 2. SSE 선택 이유
+- 실시간 알림을 구현하기 위해 **Short Polling, Long Polling, SSE, WebSocket** 등 다양한 기술을 검토한 결과, **SSE가 가장 적합**하다고 판단됨.
+- **SSE(Server-Sent Events) 장점**
+ ✅ **단방향(one-way) 연결**을 통해 **서버에서 클라이언트로 실시간 알림 전송**
+ ✅ 기존 **HTTP 프로토콜**을 활용하므로 **설정이 간편**
+ ✅ WebSocket과 달리 **재연결(자동 복구) 기능 내장**
+ ✅ 알림과 같이 **단방향 전송이 주를 이루는 서비스에 적합**
+
+## 3. 대안 비교
+
+| 기술 | 동작 방식 | 장점 | 단점 |
+| --- | --- | --- | --- |
+| **Short Polling** | 클라이언트가 일정 주기마다 서버에 요청 | 구현이 간단함 | 불필요한 요청 증가로 리소스 낭비 |
+| **Long Polling** | 서버가 새로운 데이터가 있을 때까지 응답을 지연 | 실시간성 개선 | 다수의 연결 유지 시 서버 부담 증가 |
+| **WebSocket** | 클라이언트-서버 간 **양방향 연결 유지** | 쌍방향 통신 가능 | 설정이 복잡하며, HTTP/2 환경에서 오버헤드 증가 가능 |
+
+## 4. 결론
+- 알림 서비스는 **서버에서 클라이언트로 단방향 메시지 전송이 주된 역할**을 함.
+- WebSocket의 **양방향 연결 기능이 불필요**하며, 구현이 간단한 **SSE가 최적의 선택**이었음.
+
+
+ 알림 시스템에 Redis Stream 적용
+
+## 메시지 영속성과 신뢰성 확보
+
+* 기존 SSE만 사용할 경우 네트워크 문제나 서버 장애 시 알림이 유실될 수 있음
+* Redis Stream은 알림 메시지를 일시적으로 저장하고 관리하여 메시지 전달의 신뢰성을 높임
+
+## 장애 상황 대응
+
+* 사용자의 네트워크 연결이 끊기거나 서버에 문제가 생겼을 때도 Stream에 메시지가 보관되어 있어 재접속 시 미전송된 알림을 처리할 수 있음
+
+## 시스템 확장성
+
+* 향후 시스템이 확장되어 다중 WAS 환경에서 알림을 처리해야 할 경우 Redis Stream을 통해 메시지 큐 역할을 수행하여 분산 시스템에서도 안정적인 알림 처리가 가능
+
+## 대안 비교
+
+| 기술 | 동작 방식 | 장점 | 단점 |
+|------|-----------|------|------|
+| **Redis Pub/Sub** | 구독자가 있으면 실시간으로 메시지 전송 | 빠른 실시간 메시징 | 구독자가 없으면 메시지 유실 |
+| **Kafka** | 로그 기반 스트리밍 | 강력한 메시지 보장, 대량 데이터 처리 가능 | 설정이 복잡하고 운영 비용이 높음 |
+| **Redis Stream** | 메시지 저장 + 스트리밍 | 메시지 유실 방지, 소비자 그룹 지원 | Pub/Sub보다 약간의 설정 필요 |
+
+## 결론
+
+알림 서비스에서는 **메시지 유실 방지가 중요**하므로, 단순 Pub/Sub보다 **Redis Stream이 적합**했습니다. Kafka는 강력한 기능을 제공하지만, 운영 복잡성과 비용 문제 및 대량 데이터 처리가 현재 레벨에서 필요하지 않을 것으로 예상되어 오버 엔지니어링 같았습니다. 이러한 이유들로 Redis Stream을 선택했습니다.
+
+
+
+
+## 🚨 Trouble Shooting
+
+
+ 게임추천- 프롬프트 관련 보안 위험
+
+ ## 문제인식
+
+게임 추천 기능중 사용자에게 데이터를 응답 받는 중 SQL 인젝션 위험이 예상됨
+
+- **보안 위험 분석**
+
+ ```java
+ String prompt = String.format("...추가적인 요청은 %s 야",
+ userGamePreference.getExtraRequest());
+ ```
+
+ - **문제점**: **`extraRequest`**가 프롬프트에 직접 삽입되어 악성 코드 실행 가능
+ - **위험**: 프롬프트 조작을 통한 시스템 명령어 주입, 데이터 유출 등의 공격 가능성
+
+- **개선 방안**
+
+ - **OWASP 인코딩 라이브러리 적용**
+
+ ```java
+ import org.owasp.encoder.Encode;
+
+ String safeExtraRequest = Encode.forJava(extraRequest);
+ ```
+
+ - **기능**: 특수 문자 자동 이스케이프
+ - **예방 공격**: XSS, 명령어 주입
+ - **Bean Validation 통합**
+
+ ```java
+ public class UserGamePreferenceRequestDto {
+
+ @Size(max = 100, message = "추가 요청은 100자 이내로 입력해주세요")
+ @Pattern(regexp = "^[a-zA-Z0-9\\s]+$", message = "특수 문자는 사용할 수 없습니다")
+ private String extraRequest;
+ }
+
+ public class GameRecommendContorller {
+ @PostMapping
+ public ResponseEntity createUserGamePreference(
+ @Valid @RequestBody UserGamePreferenceRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ ```
+
+ - **장점**: 선언적 검증 규칙 관리
+
+- **기대 효과**
+
+ - 입력 데이터의 무결성과 유효성이 크게 향상되어, 악의적인 데이터 주입 시도를 사전에 차단할 수 있음
+ - SQL Injection과 같은 데이터베이스 공격 위험이 현저히 감소하여, 데이터베이스의 보안성이 향상
+ - 사용자 경험 측면에서도 개선이 이루어져, 유효하지 않은 데이터 입력에 대한 즉각적인 피드백을 제공함으로써 사용자 친화적인 인터페이스를 구현할 수 있음
+
+
+
+
+ 특정 유저의 팔로워 조회 시 성능 최적화
+
+ ## 🔍 기존 코드 문제점
+
+```java
+public List findFollowers(String email) {
+ User followee = userRepository.findByEmail(email)
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (followee.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 탈퇴한 회원 예외 처리
+
+ List followListByFollowee = followRepository.findByFollowee(followee);
+
+ List followersByFollowee = followListByFollowee.stream()
+ .map(Follow::getFollower)
+ .filter(follower -> follower.getUserStatus() != UserStatus.WITHDRAW)
+ .toList();
+
+ return followersByFollowee.stream()
+ .map(FollowFindResponseDto::toDto)
+ .toList();
+}
+```
+
+위 코드의 **문제점은 FetchType.LAZY로 인해 N+1 문제가 발생**한다는 것입니다.
+
+- `Follow` 엔티티에서 `getFollower()`를 호출할 때, **각 팔로워에 대한 별도의 쿼리**가 실행됨
+- 결과적으로 팔로워가 1,000명일 경우 **총 1,002개의 쿼리**가 발생 (1개의 사용자 조회 + 1개의 팔로우 리스트 조회 + 1,000개의 팔로워 조회)
+
+---
+
+## 💡 해결 방법: JPQL을 활용한 성능 최적화
+
+이 문제를 해결하기 위해 **JPQL을 사용하여 한 번의 쿼리로 필요한 데이터를 가져오도록 수정**하였습니다.
+
+```java
+@Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.follower.id, f.follower.nickname) " +
+ "FROM Follow f " +
+ "JOIN f.follower " +
+ "WHERE f.followee.email = :email " +
+ "AND f.follower.userStatus != 'WITHDRAW'")
+List findFollowersDtoByFolloweeEmail(@Param("email") String email);
+```
+
+✅ **최적화된 코드의 장점**
+
+- **단 2개의 쿼리만 실행됨** → 기존 1,002개 → 2개
+- **N+1 문제 해결** → `JOIN`을 통해 한 번에 데이터 조회
+- **DTO로 직접 매핑** → 엔티티를 불필요하게 생성하지 않고 바로 DTO로 변환
+
+---
+
+## 📊 성능 테스트 결과
+
+
+
+| 테스트 환경 | 기존 코드 | 최적화된 코드 | 성능 개선율 |
+| --- | --- | --- | --- |
+| 팔로워 1,000명 기준 | **752ms** (1,002개 쿼리) | **7ms** (2개 쿼리) | **99% 성능 개선** 🚀 |
+
+---
+
+## 🔎 결론
+
+JPQL을 활용한 최적화를 통해:
+
+- N+1 문제를 해결하여 **실행 시간을 752ms에서 7ms로 99% 단축**
+- 불필요한 쿼리를 제거하여 **DB 부하를 대폭 감소** (1,002개 → 2개)
+- DTO 직접 매핑으로 **메모리 사용량 최적화**
+
+이러한 성능 개선을 통해 팔로워가 많은 사용자의 프로필 조회시에도 빠른 응답 속도를 보장할 수 있게 되었습니다.
+
+
+
+
+
+
+## [📋 ERD Diagram]
+## 
+
+
+
+
+## 🌐 Architecture
+
+
+
+
+
+## 📆 일정 관리 (WBS)
+
+
+
+
+
+## 📝 Technologies & Tools 📝
+
+#### Java 17 | SpringBoot 3.4.1 | MySql 8.0 | QueryDSL 5.0
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
index c2b577f..a316f79 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,9 +27,14 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
- implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
implementation 'at.favre.lib:bcrypt:0.10.2'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
@@ -39,6 +44,12 @@ dependencies {
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ // QueryDSL
+ implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
+ annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
@@ -46,6 +57,23 @@ dependencies {
// test
testImplementation 'org.mockito:mockito-core'
testImplementation 'org.assertj:assertj-core'
+ testCompileOnly 'org.projectlombok:lombok'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+
+ //S3
+ implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+ implementation 'org.springframework.boot:spring-boot-starter'
+ implementation 'com.fasterxml.jackson.core:jackson-databind'
+
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0'
+ implementation 'com.fasterxml.jackson.core:jackson-core:2.18.0'
+
+ // mail
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+
+ // Redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.redisson:redisson-spring-boot-starter:3.27.1'
}
tasks.named('test') {
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..f7baf77
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# ECR 설정
+AWS_ACCOUNT_ID="863518453426"
+REGION="ap-northeast-2"
+ECR_REPOSITORY="game_mate"
+IMAGE_TAG="latest"
+ECR_URI="$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPOSITORY"
+
+# AWS ECR 로그인
+aws ecr get-login-password --region $REGION | sudo docker login --username AWS --password-stdin $ECR_URI
+
+# 기존 컨테이너 중지 및 삭제
+if [ "$(sudo docker ps -q)" ]; then
+ sudo docker-compose down
+fi
+
+# 기존 이미지 삭제 (최신 이미지 사용을 보장)
+if [ "$(sudo docker images -q $ECR_URI:$IMAGE_TAG)" ]; then
+ sudo docker rmi -f $ECR_URI:$IMAGE_TAG
+fi
+
+# 최신 이미지 Pull
+sudo docker pull $ECR_URI:$IMAGE_TAG
+
+# docker-compose 실행 경로 설정
+cd /home/ubuntu
+
+# docker-compose 실행
+sudo docker-compose -f docker-compose.yml up -d --force-recreate
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..09b0ea7
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,33 @@
+services:
+ app:
+ image: "863518453426.dkr.ecr.ap-northeast-2.amazonaws.com/game_mate:latest"
+ platform: linux/amd64
+ container_name: app
+ ports:
+ - "8080:8080"
+ environment:
+ MYSQL_USERNAME: "${MYSQL_USERNAME}"
+ MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
+ MYSQL_URL: "${MYSQL_URL}"
+ MYSQL_PROD_URL: "${MYSQL_PROD_URL}"
+ MYSQL_DEV_URL: "${MYSQL_DEV_URL}"
+ JPA_HIBERNATE_DDL: "${JPA_HIBERNATE_DDL}"
+ JPA_HIBERNATE_DDL_PROD: "${JPA_HIBERNATE_DDL_PROD}"
+ JPA_HIBERNATE_DDL_DEV: "${JPA_HIBERNATE_DDL_DEV}"
+ JWT_SECRET: "${JWT_SECRET}"
+ AWS_ACCESS_KEY: "${AWS_ACCESS_KEY}"
+ AWS_SECRET_KEY: "${AWS_SECRET_KEY}"
+ AWS_BUCKET: "${AWS_BUCKET}"
+ AWS_REGION: "${AWS_REGION}"
+ AWS_STACK_AUTO: "${AWS_STACK_AUTO}"
+ OAUTH2_GOOGLE_CLIENT_ID: "${OAUTH2_GOOGLE_CLIENT_ID}"
+ OAUTH2_GOOGLE_CLIENT_SECRET: "${OAUTH2_GOOGLE_CLIENT_SECRET}"
+ OAUTH2_KAKAO_CLIENT_ID : "${OAUTH2_KAKAO_CLIENT_ID}"
+ OAUTH2_KAKAO_CLIENT_SECRET: "${OAUTH2_KAKAO_CLIENT_SECRET}"
+ EMAIL_USERNAME: "${EMAIL_USERNAME}"
+ EMAIL_APP_PASSWORD: "${EMAIL_APP_PASSWORD}"
+ GEMINI_URL: "${GEMINI_URL}"
+ GEMINI_KEY: "${GEMINI_KEY}"
+ REDIS_HOST: "${REDIS_HOST}"
+
+
diff --git a/keys b/keys
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/java/com/example/gamemate/GameMateApplication.java b/src/main/java/com/example/gamemate/GameMateApplication.java
index 80b6a71..77c5470 100644
--- a/src/main/java/com/example/gamemate/GameMateApplication.java
+++ b/src/main/java/com/example/gamemate/GameMateApplication.java
@@ -2,8 +2,12 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
+@EnableJpaAuditing
+@EnableScheduling
public class GameMateApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java b/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java
new file mode 100644
index 0000000..ed65594
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java
@@ -0,0 +1,85 @@
+package com.example.gamemate.domain.auth.controller;
+
+import com.example.gamemate.domain.auth.dto.*;
+import com.example.gamemate.domain.auth.service.AuthService;
+import com.example.gamemate.domain.auth.service.EmailService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthService authService;
+ private final EmailService emailService;
+
+ @PostMapping("/signup")
+ public ResponseEntity signup(
+ @Valid @RequestBody SignupRequestDto requestDto
+ ) {
+ SignupResponseDto responseDto = authService.signup(requestDto);
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+ @PostMapping("/email/verification-request")
+ public ResponseEntity sendVerificationEmail(
+ @Valid @RequestBody EmailVerificationCodeRequestDto requestDto
+ ) {
+ emailService.sendVerificationEmail(requestDto.getEmail());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ @PostMapping("/email/verify")
+ public ResponseEntity verifyEmail(
+ @Valid @RequestBody EamilVerifyRequestDto requestDto
+ ) {
+ emailService.verifyEmail(requestDto.getEmail(), requestDto.getCode());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+
+ @PostMapping("/login")
+ public ResponseEntity localLogin(
+ @Valid @RequestBody LocalLoginRequestDto requestDto,
+ HttpServletResponse response
+ ) {
+ LoginTokenResponseDto responseDto = authService.localLogin(requestDto, response);
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ @PostMapping("/oauth2/set-password")
+ public ResponseEntity setPassword(
+ @Valid @RequestBody OAuth2PasswordSetRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ authService.setOAuth2Password(customUserDetails.getUser(), requestDto.getPassword());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity logout(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ HttpServletRequest request,
+ HttpServletResponse response
+ ) {
+ authService.logout(customUserDetails.getUser(), request, response);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ @PostMapping("/refresh")
+ public ResponseEntity refreshToken(
+ @CookieValue(name = "refresh_token") String refreshToken
+ ) {
+ TokenRefreshResponseDto responseDto = authService.refreshAccessToken(refreshToken);
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java
new file mode 100644
index 0000000..2149b02
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java
@@ -0,0 +1,19 @@
+package com.example.gamemate.domain.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class EamilVerifyRequestDto {
+
+ @NotBlank(message = "이메일을 입력해주세요.")
+ @Email(message = "이메일 형식을 확인해주세요.")
+ private final String email;
+
+ @NotBlank(message = "인증 코드를 입력해주세요.")
+ private final String code;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java
new file mode 100644
index 0000000..9993ec7
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class EmailVerificationCodeRequestDto {
+
+ @NotBlank(message = "이메일을 입력해주세요.")
+ @Email(message = "이메일 형식을 확인해주세요.")
+ private final String email;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java
new file mode 100644
index 0000000..885417e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java
@@ -0,0 +1,17 @@
+package com.example.gamemate.domain.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class LocalLoginRequestDto {
+
+ @NotBlank(message = "이메일을 입력해주세요.")
+ private final String email;
+
+ @NotBlank(message = "비밀번호를 입력해주세요.")
+ private final String password;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java
new file mode 100644
index 0000000..21d178f
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.auth.dto;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class LoginTokenResponseDto {
+
+ private final String accessToken;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java
new file mode 100644
index 0000000..aa79043
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.auth.dto;
+
+import com.example.gamemate.domain.user.enums.AuthProvider;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class OAuth2LoginResponseDto {
+
+ private final String providerId;
+ private final String email;
+ private final String name;
+ private final AuthProvider provider;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java
new file mode 100644
index 0000000..299fbdb
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class OAuth2PasswordSetRequestDto {
+
+ @NotBlank(message = "비밀번호를 입력해주세요.")
+// @Size(min = 8, message = "비밀번호는 8글자 이상으로 입력해주세요.")
+// @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*[!?@#$%^&*+=-])(?=.*\\d).{8,15}$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자 최소 1글자씩 입력해주세요.")
+ private final String password;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java
new file mode 100644
index 0000000..791be67
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.auth.dto;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class OAuthUrlResponseDto {
+
+ private final String authorizationUrl;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java
new file mode 100644
index 0000000..6b6c242
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java
@@ -0,0 +1,31 @@
+package com.example.gamemate.domain.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public class SignupRequestDto {
+
+ @NotBlank(message = "이메일을 입력해주세요.")
+ @Email(message = "이메일 형식을 확인해주세요.")
+ private final String email;
+
+ @NotBlank(message = "이름을 입력해주세요.")
+ private final String name;
+
+ @NotBlank(message = "닉네임을 입력해주세요.")
+ private final String nickname;
+
+ @NotBlank(message = "비밀번호를 입력해주세요.")
+// @Size(min = 8, message = "비밀번호는 8글자 이상으로 입력해주세요.")
+// @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자 최소 1글자씩 입력해주세요.")
+ private final String password;
+
+ @NotNull(message = "이메일 인증이 필요합니다.")
+ private final Boolean isEmailVerified;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java
new file mode 100644
index 0000000..fe95d7b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.auth.dto;
+
+import com.example.gamemate.domain.user.entity.User;
+import lombok.Getter;
+
+@Getter
+public class SignupResponseDto {
+
+ private final Long id;
+ private final String email;
+ private final String name;
+ private final String nickname;
+
+ public SignupResponseDto(User user) {
+ this.id = user.getId();
+ this.email = user.getEmail();
+ this.name = user.getName();
+ this.nickname = user.getNickname();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java
new file mode 100644
index 0000000..a20b917
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.auth.dto;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class TokenRefreshResponseDto {
+
+ private final String accessToken;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java b/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java
new file mode 100644
index 0000000..2f8e6be
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java
@@ -0,0 +1,128 @@
+package com.example.gamemate.domain.auth.service;
+
+import com.example.gamemate.domain.auth.dto.*;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.AuthProvider;
+import com.example.gamemate.domain.user.enums.UserStatus;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import com.example.gamemate.global.provider.JwtTokenProvider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class AuthService {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final TokenService tokenService;
+ private final RefreshTokenService refreshTokenService;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final EmailService emailService;
+
+ public SignupResponseDto signup(SignupRequestDto requestDto) {
+ // 기존 사용자 중복 체크
+ Optional findUser = userRepository.findByEmail(requestDto.getEmail());
+
+ if(findUser.isPresent()) {
+ if(findUser.get().getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ }
+ throw new ApiException(ErrorCode.DUPLICATE_EMAIL);
+ }
+
+ // 이메일 인증 여부 확인
+ if (!emailService.isEmailVerified(requestDto.getEmail())) {
+ throw new ApiException(ErrorCode.EMAIL_NOT_VERIFIED);
+ }
+
+ // 비밀번호 암호화
+ String rawPassword = requestDto.getPassword();
+ String encodedPassword = passwordEncoder.encode(rawPassword);
+
+ // 새로운 사용자 생성 및 저장
+ User newUser = new User(requestDto.getEmail(), requestDto.getName(), requestDto.getNickname(), encodedPassword);
+ User savedUser = userRepository.save(newUser);
+
+ return new SignupResponseDto(savedUser);
+ }
+
+ public LoginTokenResponseDto localLogin(LocalLoginRequestDto requestDto, HttpServletResponse response) {
+
+ User findUser = userRepository.findByEmail(requestDto.getEmail())
+ .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if(findUser.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ }
+
+ if (findUser.getPassword().equals("OAUTH2_USER")) {
+ throw new ApiException(ErrorCode.SOCIAL_PASSWORD_REQUIRED);
+ }
+
+ if(!passwordEncoder.matches(requestDto.getPassword(), findUser.getPassword())) {
+ throw new ApiException(ErrorCode.INVALID_PASSWORD);
+ }
+
+ findUser.updateModifiedAt();
+
+ return tokenService.generateLoginTokens(findUser, response);
+ }
+
+ public void setOAuth2Password(User user, String password) {
+ if(user.getProvider() == AuthProvider.LOCAL) {
+ throw new ApiException(ErrorCode.SOCIAL_PASSWORD_FORBIDDEN);
+ }
+
+ if(!"OAUTH2_USER".equals(user.getPassword())) {
+ throw new ApiException(ErrorCode.SOCIAL_PASSWORD_ALREADY_SET);
+ }
+
+ String encodedPassword = passwordEncoder.encode(password);
+ user.updatePassword(encodedPassword);
+ userRepository.save(user);
+ }
+
+ public TokenRefreshResponseDto refreshAccessToken(String refreshToken) {
+ if(!jwtTokenProvider.validateToken(refreshToken)) {
+ throw new ApiException(ErrorCode.INVALID_TOKEN);
+ }
+
+ String email = jwtTokenProvider.getEmailFromToken(refreshToken);
+ String storedToken = refreshTokenService.getRefreshToken(email);
+
+ if(!refreshToken.equals(storedToken)) {
+ throw new ApiException(ErrorCode.INVALID_TOKEN);
+ }
+
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ String newAccessToken = jwtTokenProvider.createAccessToken(email, user.getRole());
+ return new TokenRefreshResponseDto(newAccessToken);
+ }
+
+ public void logout(User user, HttpServletRequest request, HttpServletResponse response) {
+ // 엑세스 토큰 블랙리스트 추가
+ String accessToken = tokenService.extractToken(request);
+ if(accessToken != null) {
+ tokenService.blacklistToken(accessToken);
+ }
+
+ // 리프레시 토큰 처리
+ String refreshToken = refreshTokenService.extractRefreshTokenFromCookie(request);
+ if(refreshToken != null) {
+ refreshTokenService.removeRefreshToken(user.getEmail(), response);
+ }
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java b/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java
new file mode 100644
index 0000000..7ce3372
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java
@@ -0,0 +1,149 @@
+package com.example.gamemate.domain.auth.service;
+
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.mail.MailException;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class EmailService {
+
+ private static final long VERIFICATION_TIME_LIMIT = 5; //5분
+ private final JavaMailSender emailSender;
+ private final UserRepository userRepository;
+ private final Map verificationMap = new ConcurrentHashMap<>();
+
+ public void sendVerificationEmail(String email) {
+ // 이미 가입된 이메일인지 확인
+ if (userRepository.findByEmail(email).isPresent()) {
+ throw new ApiException(ErrorCode.DUPLICATE_EMAIL);
+ }
+
+ String verificationCode = generateVerificationCode();
+
+ try {
+ MimeMessage message = createEmailMessage(email, verificationCode);
+ emailSender.send(message);
+
+ // 메모리에 인증 정보 저장
+ // Todo: 추후 Redis로 수정 예정
+ verificationMap.put(email, new VerificationInfo(
+ verificationCode,
+ LocalDateTime.now().plusMinutes(VERIFICATION_TIME_LIMIT)
+ ));
+ } catch (MailException e) {
+ throw new ApiException(ErrorCode.EMAIL_SEND_ERROR);
+ }
+ }
+
+ public boolean verifyEmail(String email, String code) {
+ // 인증 정보 확인
+ VerificationInfo verificationInfo = getVerificationInfo(email);
+
+ //인증 정보가 없는 경우
+ if (verificationInfo == null) {
+ throw new ApiException(ErrorCode.VERIFICATION_NOT_FOUND);
+ }
+
+ // 인증 정보가 만료된 경우
+ if (LocalDateTime.now().isAfter(verificationInfo.getExpiryTime())) {
+ verificationMap.remove(email);
+ throw new ApiException(ErrorCode.VERIFICATION_TIME_EXPIRED);
+ }
+
+ // 인증 코드 불일치
+ if (!verificationInfo.getCode().equals(code)) {
+ throw new ApiException(ErrorCode.INVALID_VERIFICATION_CODE);
+ }
+
+ // 인증 성공 시 상태 변경
+ verificationInfo.markAsVerified();
+ verificationMap.put(email, verificationInfo);
+
+ // 만료된 모든 인증 정보 제거
+ verificationMap.entrySet().removeIf(entry ->
+ LocalDateTime.now().isAfter(entry.getValue().getExpiryTime())
+ );
+
+ return true;
+ }
+
+ public boolean isEmailVerified(String email) {
+ VerificationInfo info = verificationMap.get(email);
+
+ return info != null
+ && !LocalDateTime.now().isAfter(info.getExpiryTime())
+ && info.isVerified();
+ }
+
+ // Todo: 추후 Redis로 수정 예정
+ private VerificationInfo getVerificationInfo(String email) {
+ VerificationInfo info = verificationMap.get(email);
+
+ if (info != null && LocalDateTime.now().isAfter(info.getExpiryTime())) {
+ verificationMap.remove(email);
+ return null;
+ }
+
+ return info;
+ }
+
+ private String generateVerificationCode() {
+ return UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ private MimeMessage createEmailMessage(String email, String code) {
+ try {
+ MimeMessage message = emailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+
+ helper.setFrom("lee24pm@gmail.com");
+ helper.setTo(email);
+ helper.setSubject("[GameMate] 이메일 인증");
+ helper.setText(createEmailContent(code), true);
+
+ return message;
+ } catch (MessagingException e) {
+ throw new ApiException(ErrorCode.EMAIL_SEND_ERROR);
+ }
+ }
+
+ private String createEmailContent(String code) {
+ return String.format(
+ "" +
+ "
GameMate 이메일 인증 " +
+ "
" +
+ "
아래 인증 코드를 입력해주세요.
" +
+ "
" +
+ "
인증 코드 : %s
" +
+ "
", code);
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ private static class VerificationInfo {
+ private final String code;
+ private final LocalDateTime expiryTime;
+ private boolean verified = false;
+
+ public void markAsVerified() {
+ this.verified = true;
+ }
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java b/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java
new file mode 100644
index 0000000..438f7a1
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java
@@ -0,0 +1,162 @@
+package com.example.gamemate.domain.auth.service;
+
+import com.example.gamemate.domain.auth.dto.OAuth2LoginResponseDto;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.AuthProvider;
+import com.example.gamemate.domain.user.enums.UserStatus;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class OAuth2Service {
+
+ private final UserRepository userRepository;
+
+ public OAuth2LoginResponseDto extractOAuth2Attributes(AuthProvider provider, Map attributes) {
+ if(provider == AuthProvider.GOOGLE) {
+ return extractGoogleAttributes(attributes);
+ } else if(provider == AuthProvider.KAKAO) {
+ return extractKakaoAttributes(attributes);
+ }
+ throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE);
+ }
+
+ public User processOAuth2User(OAuth2LoginResponseDto responseDto) {
+ // 기존 사용자 조회
+ Optional findUser = userRepository.findByEmail(responseDto.getEmail());
+
+ // 기존 사용자 존재하는 경우
+ if (findUser.isPresent()) {
+ User existingUser = findUser.get();
+
+ // 탈퇴한 사용자 체크
+ if (existingUser.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ }
+
+ // 이메일로 가입한 계정이 소셜 로그인 시도한 경우
+ if (existingUser.getProvider() == AuthProvider.LOCAL) {
+ existingUser.integrateOAuthProvider(responseDto.getProvider(), responseDto.getProviderId());
+ return userRepository.save(existingUser);
+ }
+
+ // 다른 OAuth 제공자로 로그인 시도한 경우
+ if (!existingUser.getProvider().equals(responseDto.getProvider())) {
+ throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE);
+ }
+
+ return existingUser;
+ }
+
+ // 새로운 사용자 생성
+ User newUser = new User(
+ responseDto.getEmail(),
+ responseDto.getName(),
+ responseDto.getName(),
+ responseDto.getProvider(),
+ responseDto.getProviderId()
+ );
+ return userRepository.save(newUser);
+ }
+
+// public String generateAuthorizationUrl(String providerName, String redirectUri) {
+//
+// AuthProvider provider = AuthProvider.fromString(providerName);
+// ClientRegistration clientRegistration = getClientRegistration(provider);
+// String state = UUID.randomUUID().toString();
+// log.info("Generated OAuth2 State for provider {}: {}", providerName, state);
+//
+// String authorizationUrl = UriComponentsBuilder
+// .fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri())
+// .queryParam("client_id", clientRegistration.getClientId())
+// .queryParam("redirect_uri", redirectUri != null ? redirectUri : clientRegistration.getRedirectUri())
+// .queryParam("response_type", "code")
+// .queryParam("scope", String.join(" ", clientRegistration.getScopes()))
+// .queryParam("state", state)
+// .build()
+// .toUriString();
+// log.info("Generated Authorization URL for provider {}: {}", providerName, authorizationUrl);
+// return authorizationUrl;
+// }
+
+// private ClientRegistration getClientRegistration(AuthProvider provider) {
+// String registrationId = provider.name().toLowerCase();
+//
+// OAuth2ClientProperties.Registration registration =
+// clientProperties.getRegistration().get(registrationId);
+// OAuth2ClientProperties.Provider providerConfig =
+// clientProperties.getProvider().get(registrationId);
+//
+// if (registration == null || providerConfig == null) {
+// throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE);
+// }
+//
+// return ClientRegistration.withRegistrationId(registrationId)
+// .clientId(registration.getClientId())
+// .clientSecret(registration.getClientSecret())
+// .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+// .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+// .redirectUri(registration.getRedirectUri())
+// .scope(registration.getScope())
+// .authorizationUri(providerConfig.getAuthorizationUri())
+// .tokenUri(providerConfig.getTokenUri())
+// .userInfoUri(providerConfig.getUserInfoUri())
+// .userNameAttributeName(providerConfig.getUserNameAttribute())
+// .clientName(registrationId)
+// .build();
+// }
+
+
+ private OAuth2LoginResponseDto extractGoogleAttributes(Map attributes) {
+ return new OAuth2LoginResponseDto(
+ getSafeString(attributes.get("sub")),
+ getSafeString(attributes.get("email")),
+ getSafeString(attributes.get("name")),
+ AuthProvider.GOOGLE
+ );
+ }
+
+ private OAuth2LoginResponseDto extractKakaoAttributes(Map attributes) {
+ String providerId = getSafeString(attributes.get("id"));
+
+ Map kakaoAccount = getSafeMap(attributes, "kakao_account");
+ Map profile = getSafeMap(kakaoAccount, "profile");
+
+ return new OAuth2LoginResponseDto(
+ providerId,
+ getSafeString(kakaoAccount.get("email")),
+ getSafeString(profile.get("nickname")),
+ AuthProvider.KAKAO
+ );
+ }
+
+ private String getSafeString(Object obj) {
+ if (obj == null) {
+ throw new ApiException(ErrorCode.INVALID_OAUTH2_ATTRIBUTE);
+ }
+ return obj.toString();
+ }
+
+ private Map getSafeMap(Map attributes, String attributeName) {
+ Object attributeValue = attributes.get(attributeName);
+ // instanceof 검사를 통해 타입 안정성 확보, null 체크 포함
+ if (!(attributeValue instanceof Map)) {
+ throw new ApiException(ErrorCode.INVALID_OAUTH2_ATTRIBUTE);
+ }
+ return (Map) attributeValue;
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java b/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java
new file mode 100644
index 0000000..890884b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java
@@ -0,0 +1,75 @@
+package com.example.gamemate.domain.auth.service;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+
+@Service
+@Transactional
+public class RefreshTokenService {
+
+ private final StringRedisTemplate refreshTokenRedisTemplate;
+ private final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7);
+ private int refreshTokenMaxAge = 60 * 60 * 24 * 7; // 7일
+
+ public RefreshTokenService(
+ @Qualifier("refreshTokenRedisTemplate") StringRedisTemplate refreshTokenRedisTemplate
+ ) {
+ this.refreshTokenRedisTemplate = refreshTokenRedisTemplate;
+ }
+
+ public void saveRefreshToken(String email, String refreshToken, HttpServletResponse response) {
+ String key = getKey(email);
+ refreshTokenRedisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_TTL);
+ addRefreshTokenToCookie(response, refreshToken);
+ }
+
+ public String getRefreshToken(String email) {
+ String key = getKey(email);
+ return refreshTokenRedisTemplate.opsForValue().get(key);
+ }
+
+ public void removeRefreshToken(String email, HttpServletResponse response) {
+ String key = getKey(email);
+ refreshTokenRedisTemplate.delete(key);
+ removeRefreshTokenCookie(response);
+ }
+
+ private String getKey(String email) {
+ return "refresh_token:" + email;
+ }
+
+ private void addRefreshTokenToCookie(HttpServletResponse response, String refreshToken) {
+ Cookie cookie = new Cookie("refresh_token", refreshToken);
+ cookie.setHttpOnly(true); // 자바 스크립트에서 접근 불가
+ cookie.setSecure(true); // HTTPS에서만 동작
+ cookie.setPath("/"); // 모든 경로에서 유효
+ cookie.setMaxAge(refreshTokenMaxAge);
+ response.addCookie(cookie); // 쿠키를 응답에 추가
+ }
+
+ public void removeRefreshTokenCookie(HttpServletResponse response) {
+ Cookie cookie = new Cookie("refresh_token", null);
+ cookie.setMaxAge(0);
+ cookie.setPath("/");
+ response.addCookie(cookie);
+ }
+
+ public String extractRefreshTokenFromCookie(HttpServletRequest request) {
+ Cookie[] cookies = request.getCookies();
+ if (cookies != null) {
+ for (Cookie cookie : cookies) {
+ if ("refresh_token".equals(cookie.getName())) {
+ return cookie.getValue();
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java b/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java
new file mode 100644
index 0000000..21fce38
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java
@@ -0,0 +1,69 @@
+package com.example.gamemate.domain.auth.service;
+
+import com.example.gamemate.domain.auth.dto.LoginTokenResponseDto;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.provider.JwtTokenProvider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+
+@Service
+@Transactional
+public class TokenService {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final RefreshTokenService refreshTokenService;
+ private final StringRedisTemplate tokenBlacklistRedisTemplate;
+
+ public TokenService(
+ JwtTokenProvider jwtTokenProvider,
+ RefreshTokenService refreshTokenService,
+ @Qualifier("blacklistRedisTemplate") StringRedisTemplate tokenBlacklistRedisTemplate) {
+ this.jwtTokenProvider = jwtTokenProvider;
+ this.refreshTokenService = refreshTokenService;
+ this.tokenBlacklistRedisTemplate = tokenBlacklistRedisTemplate;
+ }
+
+ public LoginTokenResponseDto generateLoginTokens(User user, HttpServletResponse response) {
+ String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole());
+ String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail());
+
+ refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken, response);
+ return new LoginTokenResponseDto(accessToken);
+ }
+
+ public void blacklistToken(String token) {
+ long expirationTime = jwtTokenProvider.getExpirationFromToken(token);
+ Duration ttl = Duration.ofMillis(expirationTime - System.currentTimeMillis());
+ if (!ttl.isNegative()) {
+ tokenBlacklistRedisTemplate.opsForValue().set(getBlacklistKey(token), "1", ttl);
+ }
+ }
+
+ public boolean validateToken(String token) {
+ return !isBlacklisted(token) && jwtTokenProvider.validateToken(token);
+ }
+
+ public String extractToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+
+ private boolean isBlacklisted(String token) {
+ return Boolean.TRUE.equals(tokenBlacklistRedisTemplate.hasKey(getBlacklistKey(token)));
+ }
+
+ private String getBlacklistKey(String token) {
+ return "blacklist:" + token;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java b/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java
new file mode 100644
index 0000000..f88bdcb
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java
@@ -0,0 +1,157 @@
+package com.example.gamemate.domain.board.controller;
+
+import com.example.gamemate.domain.board.dto.BoardRequestDto;
+import com.example.gamemate.domain.board.dto.BoardResponseDto;
+import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto;
+import com.example.gamemate.domain.board.dto.BoardFindOneResponseDto;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.example.gamemate.domain.board.service.BoardService;
+import com.example.gamemate.domain.board.service.BoardViewService;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 게시글 관련 API를 처리하는 컨트롤러 클래스입니다.
+ * 게시글의 생성, 조회, 수정, 삭제 기능을 제공합니다.
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/boards")
+public class BoardController {
+
+ private final BoardService boardService;
+ private final BoardViewService boardViewService;
+
+ /**
+ * 게시글 생성 API 입니다.
+ *
+ * @param dto 게시글 생성 dto
+ * @param customUserDetails 인증 정보
+ * @return 생성된 게시글 정보를 포함한 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createBoard(
+ @Valid @RequestBody BoardRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+
+ BoardResponseDto responseDto = boardService.createBoard(customUserDetails.getUser(), dto);
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+
+ /**
+ * 게시글 조회하고 검색하는 API 입니다.
+ *
+ * @param category 카테고리 종류
+ * @return 게시글 목록을 포함한 ResponseEntity
+ */
+ @GetMapping("/rankings")
+ public ResponseEntity> findTopBoards(
+ @RequestParam(required = false) String category
+ ) {
+
+ BoardCategory boardCategory = null;
+ if (category != null) {
+ boardCategory = BoardCategory.fromName(category);
+ }
+
+ List dtos = boardViewService.findTopBoards(boardCategory);
+ if(dtos.isEmpty()){
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+ return new ResponseEntity<>(dtos, HttpStatus.OK);
+ }
+
+ /**
+ * 게시글 조회하고 검색하는 API 입니다.
+ *
+ * @param page 페이지 번호(기본값 : 0)
+ * @param category 카테고리 종류
+ * @param title 게시글 제목
+ * @param content 게시글 내용
+ * @return 게시글 목록을 포함한 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> findAllBoards(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(required = false) String category,
+ @RequestParam(required = false) String title,
+ @RequestParam(required = false) String content
+ ) {
+
+ BoardCategory boardCategory = null;
+ if (category != null) {
+ boardCategory = BoardCategory.fromName(category);
+ }
+
+ List dtos = boardService.findAllBoards(page,boardCategory,title,content);
+ if(dtos.isEmpty()){
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+ return new ResponseEntity<>(dtos, HttpStatus.OK);
+ }
+
+ /**
+ * 게시글 단건 조회하는 API 입니다.
+ *
+ * @param id 게시글 식별자
+ * @return 게시글 ResponseEntity
+ */
+ @GetMapping("/{id}")
+ public ResponseEntity findBoardById(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+
+ User loginUser = customUserDetails != null ? customUserDetails.getUser() : null;
+
+ BoardFindOneResponseDto dto = boardService.findBoardById(id, loginUser);
+ return new ResponseEntity<>(dto, HttpStatus.OK);
+ }
+
+
+ /**
+ * 게시글 업데이트하는 API 입니다.
+ *
+ * @param id 게시글 식별자
+ * @param dto 게시글 업데이트 dto
+ * @param customUserDetails 인증 정보
+ * @return Void ResponseEntity
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateBoard(
+ @PathVariable Long id,
+ @Valid @RequestBody BoardRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+
+ boardService.updateBoard(customUserDetails.getUser(), id, dto);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 게시글 삭제하는 API 입니다.
+ *
+ * @param id 게시글 식별자
+ * @param customUserDetails 인증 정보
+ * @return Void ResponseEntity
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteBoard(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+
+ boardService.deleteBoard(customUserDetails.getUser(), id);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java
new file mode 100644
index 0000000..f682af3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java
@@ -0,0 +1,32 @@
+package com.example.gamemate.domain.board.dto;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class BoardFindAllResponseDto {
+ private final Long id;
+ private final BoardCategory category;
+ private final String title;
+ private final LocalDateTime createdAt;
+ private final int views;
+
+ public BoardFindAllResponseDto(Long id, BoardCategory category, String title, LocalDateTime createdAt, int views) {
+ this.id = id;
+ this.category = category;
+ this.title = title;
+ this.createdAt = createdAt;
+ this.views = views;
+ }
+
+ public BoardFindAllResponseDto(Board board) {
+ this.id = board.getId();
+ this.category = board.getCategory();
+ this.title = board.getTitle();
+ this.createdAt = board.getCreatedAt();
+ this.views = board.getViews();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java
new file mode 100644
index 0000000..1222f69
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java
@@ -0,0 +1,54 @@
+package com.example.gamemate.domain.board.dto;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.example.gamemate.domain.boardImage.entity.BoardImage;
+import com.example.gamemate.domain.comment.dto.CommentFindResponseDto;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+public class BoardFindOneResponseDto {
+ private final Long id;
+ private final BoardCategory category;
+ private final String title;
+ private final String content;
+ private final String nickname;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime modifiedAt;
+ private final List fileName;
+ private final List imageUrl;
+
+ public BoardFindOneResponseDto(Long id, BoardCategory category, String title, String content, String nickname, LocalDateTime createdAt, LocalDateTime modifiedAt, List fileName, List imageUrl) {
+ this.id = id;
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ this.nickname = nickname;
+ this.createdAt = createdAt;
+ this.modifiedAt = modifiedAt;
+ this.fileName = fileName;
+ this.imageUrl = imageUrl;
+ }
+
+ public BoardFindOneResponseDto(Board board) {
+ this.id = board.getId();
+ this.category = board.getCategory();
+ this.title = board.getTitle();
+ this.content = board.getContent();
+ this.nickname = board.getUser().getNickname();
+ this.createdAt = board.getCreatedAt();
+ this.modifiedAt = board.getModifiedAt();
+ this.fileName = board.getBoardImages().isEmpty() ? null :
+ board.getBoardImages().stream()
+ .map(BoardImage::getFileName)
+ .toList();
+ this.imageUrl = board.getBoardImages().isEmpty() ? null :
+ board.getBoardImages().stream()
+ .map(BoardImage::getFilePath)
+ .toList();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java
new file mode 100644
index 0000000..8046759
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java
@@ -0,0 +1,23 @@
+package com.example.gamemate.domain.board.dto;
+
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+
+@Getter
+public class BoardRequestDto {
+ @NotNull(message = "카테고리를 선택하세요.")
+ private final BoardCategory category;
+ @NotBlank(message = "제목을 입력하세요.")
+ private final String title;
+ @NotBlank(message = "내용을 입력하세요.")
+ private final String content;
+
+
+ public BoardRequestDto(BoardCategory category, String title, String content) {
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java
new file mode 100644
index 0000000..b7465d0
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java
@@ -0,0 +1,27 @@
+package com.example.gamemate.domain.board.dto;
+
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class BoardResponseDto {
+ private final Long id;
+ private final BoardCategory category;
+ private final String title;
+ private final String content;
+ private final String nickname;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+
+ public BoardResponseDto(Long id, BoardCategory category, String title, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.id = id;
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ this.nickname = nickname;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/entity/Board.java b/src/main/java/com/example/gamemate/domain/board/entity/Board.java
new file mode 100644
index 0000000..c15f364
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/entity/Board.java
@@ -0,0 +1,57 @@
+package com.example.gamemate.domain.board.entity;
+
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.example.gamemate.domain.boardImage.entity.BoardImage;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "board")
+public class Board extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ private BoardCategory category;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Column(nullable = false)
+ private String content;
+
+ private int views = 0;
+
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @OneToMany(mappedBy = "board")
+ private List boardImages;
+
+ public Board(BoardCategory category, String title, String content, User user) {
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ this.user = user;
+ }
+
+ public void updateBoard(BoardCategory category, String title, String content) {
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ }
+
+ public void updateViewCount(int views){
+ this.views=views;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java b/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java
new file mode 100644
index 0000000..9b5eda4
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java
@@ -0,0 +1,37 @@
+package com.example.gamemate.domain.board.enums;
+
+import com.example.gamemate.global.exception.ApiException;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import static com.example.gamemate.global.constant.ErrorCode.INVALID_INPUT;
+
+@Getter
+public enum BoardCategory {
+ FREE("자유_게시판"),
+ INFO("정보_게시판"),
+ RECRUIT("모집_게시판");
+
+ private final String name;
+ BoardCategory(String name) {
+ this.name = name;
+ }
+
+ @JsonValue
+ public String getName(){
+ return name;
+ }
+
+ @JsonCreator
+ public static BoardCategory fromName(String name) {
+ for(BoardCategory category : BoardCategory.values()){
+ if(category.name().equalsIgnoreCase(name) || category.getName().equals(name)){
+ return category;
+ }
+ }
+ throw new ApiException(INVALID_INPUT);
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java b/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java
new file mode 100644
index 0000000..000f1e2
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.board.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum ListSize {
+ BOARD_LIST_SIZE(15),
+ COMMENT_LIST_SIZE(25),;
+
+
+ private final int size;
+ ListSize(int size) {
+ this.size = size;
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java
new file mode 100644
index 0000000..b92479d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.board.repository;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+
+public interface BoardQuerydslRepository {
+ Page searchBoardQuerydsl(BoardCategory category, String title, String content, Pageable pageable);
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java
new file mode 100644
index 0000000..d1ae209
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java
@@ -0,0 +1,63 @@
+package com.example.gamemate.domain.board.repository;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.entity.QBoard;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.support.PageableExecutionUtils;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+public class BoardQuerydslRepositoryImpl implements BoardQuerydslRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+
+ /**
+ * 게시글 조회
+ * @param category
+ * @param title
+ * @param content
+ * @return
+ */
+ @Override
+ public Page searchBoardQuerydsl(BoardCategory category, String title, String content, Pageable pageable) {
+
+ QBoard board = QBoard.board;
+
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if(category != null) {
+ builder.and(board.category.eq(category));
+ }
+
+ if(title != null) {
+ builder.and(board.title.like("%"+title+"%"));
+ }
+
+ if(content != null) {
+ builder.and(board.content.like("%"+content+"%"));
+ }
+
+ JPAQuery query = jpaQueryFactory.selectFrom(board)
+ .where(builder)
+ .orderBy(new OrderSpecifier<>(Order.DESC, board.createdAt))
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize());
+
+ List contentList = query.fetch();
+
+ JPAQuery countQuery = jpaQueryFactory.select(board.count())
+ .from(board)
+ .where(builder);
+
+ return PageableExecutionUtils.getPage(contentList, pageable, countQuery::fetchOne);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java
new file mode 100644
index 0000000..a66b356
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java
@@ -0,0 +1,29 @@
+package com.example.gamemate.domain.board.repository;
+
+import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto;
+import com.example.gamemate.domain.board.entity.Board;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BoardRepository extends JpaRepository, BoardQuerydslRepository{
+ Page findByCategory(String category, Pageable pageable);
+
+ List findTop5ByOrderByViewsDesc();
+
+ @Query("select b from Board b where b.id IN :boardIds order by b.views desc, b.createdAt desc")
+ List findByIdInOrderByCreatedAtDesc(@Param("boardIds") List boardIds);
+
+ List findTop5ByOrderByCreatedAtDesc();
+
+ @Query("select b from Board b where b.id IN :boardIds order by b.views desc, b.createdAt desc")
+ List findByIdInOrderByViewsDescCreatedAtDesc(@Param("boardIds") List boardIds);
+
+ List findByIdIn(List boardIds);
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/service/BoardService.java b/src/main/java/com/example/gamemate/domain/board/service/BoardService.java
new file mode 100644
index 0000000..12ae91b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/service/BoardService.java
@@ -0,0 +1,148 @@
+package com.example.gamemate.domain.board.service;
+
+import com.example.gamemate.domain.board.dto.BoardRequestDto;
+import com.example.gamemate.domain.board.dto.BoardResponseDto;
+import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto;
+import com.example.gamemate.domain.board.dto.BoardFindOneResponseDto;
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.example.gamemate.domain.board.enums.ListSize;
+import com.example.gamemate.domain.board.repository.BoardRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class BoardService {
+
+ private final BoardRepository boardRepository;
+ private final BoardViewService boardViewService;
+
+ /**
+ * 게시글 생성 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param dto 게시글 생성 dto
+ * @return 게시글 생성 응답 ResponseDto
+ */
+ @Transactional
+ public BoardResponseDto createBoard(User loginUser, BoardRequestDto dto) {
+ // 게시글 생성
+ Board newBoard = new Board(dto.getCategory(),dto.getTitle(),dto.getContent(), loginUser);
+ Board createdBoard = boardRepository.save(newBoard);
+ return new BoardResponseDto(
+ createdBoard.getId(),
+ createdBoard.getCategory(),
+ createdBoard.getTitle(),
+ createdBoard.getContent(),
+ createdBoard.getUser().getNickname(),
+ createdBoard.getCreatedAt(),
+ createdBoard.getModifiedAt()
+ );
+ }
+
+ /**
+ * 게시판 리스트 조회 메서드입니다.
+ *
+ * @param page 페이지 번호 (기본값 : 0)
+ * @param category 게시글 카테고리
+ * @param title 게시글 제목
+ * @param content 게시글 내용
+ * @return 게시글 리스트 ResponseDto List
+ */
+ public List findAllBoards(int page, BoardCategory category, String title, String content) {
+
+ Pageable pageable = PageRequest.of(page, ListSize.BOARD_LIST_SIZE.getSize(), Sort.by(Sort.Order.desc("createdAt")));
+
+ Page boardPage = boardRepository.searchBoardQuerydsl(category, title, content, pageable);
+
+ return boardPage.stream()
+ .map(board -> new BoardFindAllResponseDto(
+ board.getId(),
+ board.getCategory(),
+ board.getTitle(),
+ board.getCreatedAt(),
+ boardViewService.getViewCount(board.getId())
+ ))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 게시글 단건 조회 메서드입니다.
+ *
+ * @param id 게시글 식별자
+ * @return 게시글 조회 ResponseDto
+ */
+ @Transactional
+ public BoardFindOneResponseDto findBoardById(Long id, User loginUser) {
+ // 조회수 증가(Redis 저장)
+ if(loginUser == null) {
+ boardViewService.increaseViewCount(id, null);
+ }else{
+ boardViewService.increaseViewCount(id, loginUser.getId());
+ }
+
+ // 게시글 조회
+ Board findBoard = boardRepository.findById(id)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ return new BoardFindOneResponseDto(findBoard);
+ }
+
+
+ /**
+ * 게시글 업데이트 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 게시글 식별자
+ * @param dto 게시글 업데이트 요청 Dto
+ */
+ @Transactional
+ public void updateBoard(User loginUser, Long id, BoardRequestDto dto) {
+ // 게시글 조회
+ Board findBoard = boardRepository.findById(id)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ // 게시글 작성자와 로그인한 사용자 확인
+ if(!findBoard.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ findBoard.updateBoard(dto.getCategory(),dto.getTitle(),dto.getContent());
+ boardRepository.save(findBoard);
+ }
+
+ /**
+ * 게시글 삭제 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 게시글 식별자
+ */
+ @Transactional
+ public void deleteBoard(User loginUser, Long id) {
+ //게시글 조회
+ Board findBoard = boardRepository.findById(id)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ // 게시글 작성자와 로그인한 사용자 확인
+ if(!findBoard.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ boardRepository.delete(findBoard);
+ }
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java b/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java
new file mode 100644
index 0000000..b0f4d1f
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java
@@ -0,0 +1,210 @@
+package com.example.gamemate.domain.board.service;
+
+import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto;
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.BoardCategory;
+import com.example.gamemate.domain.board.repository.BoardRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class BoardViewService {
+
+ private final BoardRepository boardRepository;
+ private final String VIEW_COUNT_KEY = "board:view:";
+ private final String VIEW_RANKING_KEY = "board:ranking:";
+ private final StringRedisTemplate redisTemplate;
+ private final HttpServletRequest request;
+
+ public BoardViewService(
+ BoardRepository boardRepository,
+ @Qualifier("viewCountRedisTemplate")StringRedisTemplate redisTemplate,
+ HttpServletRequest request) {
+ this.boardRepository = boardRepository;
+ this.redisTemplate = redisTemplate;
+ this.request = request;
+ }
+
+ /**
+ * 조회수 높은 게시글 조회 하는 메서드입니다.
+ *
+ * @param boardCategory 카테고리 종류
+ * @return 게시글 조회 List
+ */
+ public List findTopBoards(BoardCategory boardCategory) {
+ List top5Boards = getTop5Boards();
+
+ List result = new ArrayList<>();
+
+ for(Board board : top5Boards) {
+ int redisViewCount = getViewCount(board.getId());
+ result.add(new BoardFindAllResponseDto(
+ board.getId(),
+ board.getCategory(),
+ board.getTitle(),
+ board.getCreatedAt(),
+ redisViewCount
+ ));
+ }
+
+ return result;
+ }
+
+ /**
+ * 조회수 증가시키는 메서드입니다.
+ *
+ * @param boardId 게시글 식별자
+ */
+ @Transactional
+ public void increaseViewCount(Long boardId, Long userId) {
+ String uniqueKey;
+
+ if (userId != null) {
+ // 회원 : userId 기반으로 조회 제한
+ uniqueKey = VIEW_COUNT_KEY + boardId + ":" + userId;
+ } else {
+ // 비회원
+ String ipAddress = getClientIp();
+ //String hashedIp = hashIpAddress(ipAddress);
+ uniqueKey = VIEW_COUNT_KEY + boardId + ":" + ipAddress;
+ }
+
+ if (Boolean.FALSE.equals(redisTemplate.hasKey(uniqueKey))) {
+ redisTemplate.opsForValue().set(uniqueKey, "1", Duration.ofHours(1));
+ redisTemplate.opsForValue().increment(VIEW_COUNT_KEY + boardId);
+ redisTemplate.opsForZSet().incrementScore(VIEW_RANKING_KEY, String.valueOf(boardId),1);
+ }
+ }
+
+ /**
+ * 조회수 가져오는 메서드 입니다.
+ *
+ * @param boardId 게시글 식별자
+ * @return 조회수
+ */
+ public int getViewCount(Long boardId){
+
+ String key = VIEW_COUNT_KEY + boardId;
+ String count = redisTemplate.opsForValue().get(key);
+
+ if(count != null){
+ return Integer.parseInt(count);
+ }
+
+ // Redis에 값이 없으면 DB에서 조회 후 Redis 에 반영
+ Board board = boardRepository.findById(boardId)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ int dbViewCount = board.getViews();
+
+ // Redis 에 저장(초기화)
+ redisTemplate.opsForValue().set(key, String.valueOf(dbViewCount));
+
+ return dbViewCount;
+ }
+
+ /**
+ * 클라이언트 IP 가져오는 메서드입니다.(프록시)
+ *
+ * @return ip 주소
+ */
+ private String getClientIp(){
+
+ String ip = request.getHeader("x-forwarded-for");
+ if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("Proxy-Client-IP");
+ }
+
+ if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("WL-Proxy-Client-IP");
+ }
+
+ if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getRemoteAddr();
+ }
+ return ip;
+ }
+
+
+ /**
+ * 매일 00시 정각 시간마다 동기화
+ */
+ @Scheduled(cron = "0 0 0 * * *")
+ @Transactional
+ public void syncRedisToDb(){
+ log.info("Redis 조회수 데이터 DB로 동기화");
+
+ // 조회수 기반으로 DB에 업데이트
+ Set keys = redisTemplate.keys(VIEW_COUNT_KEY + "*")
+ .stream()
+ .filter(key -> key.split(":").length == 3) // board:view:{boardId} 형식만 남김
+ .collect(Collectors.toSet());
+ if(!keys.isEmpty()){
+ List updatedBoards = new ArrayList<>();
+ for(String key : keys){
+ Long boardId = Long.parseLong(key.split(":")[2]);
+ int viewCount = getViewCount(boardId);
+ if(viewCount > 0){
+ Board findBoard = boardRepository.findById(boardId)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+ int boardViewCount = findBoard.getViews();
+ findBoard.updateViewCount( viewCount);
+ updatedBoards.add(findBoard);
+
+ // redis 값을 유지(db 값 반영 후 덮어쓰기)
+ redisTemplate.opsForValue().set(VIEW_COUNT_KEY + boardId, String.valueOf(viewCount));
+ }
+ }
+ // 업데이트
+ boardRepository.saveAll(updatedBoards);
+ log.info("업데이트 완료");
+ }
+
+ }
+
+ /**
+ * 조회수 높은 5개의 게시글 조회
+ *
+ * @return 조회수 높은 게시글 리스트
+ */
+ public List getTop5Boards(){
+ Set topBoardIds = redisTemplate.opsForZSet().reverseRange(VIEW_RANKING_KEY, 0, 4);
+
+ if(topBoardIds == null || topBoardIds.isEmpty()){
+ return boardRepository.findTop5ByOrderByCreatedAtDesc();
+ }
+
+ List boardIds = topBoardIds.stream().map(Long::parseLong).toList();
+
+ // DB 에서 해당 게시글 조회(조회수 순으로 정렬)
+ List boards = boardRepository.findByIdIn(boardIds);
+
+ // 조회수는 redis 값으로 최신화
+ boards.forEach(board -> {
+ int redisViewCount = getViewCount(board.getId());
+ board.updateViewCount(redisViewCount);
+ });
+
+ boards.sort(Comparator.comparing(Board::getViews).reversed()
+ .thenComparing(Board::getCreatedAt, Comparator.reverseOrder()));
+
+ return boards;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java b/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java
new file mode 100644
index 0000000..ab7d84a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java
@@ -0,0 +1,75 @@
+package com.example.gamemate.domain.boardImage.controller;
+
+import com.example.gamemate.domain.boardImage.service.BoardImageService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import java.io.IOException;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/boards/{boardId}/files")
+public class BoardImageController {
+
+ private final BoardImageService boardImageService;
+
+ /**
+ * 게시글 첨부파일 추가 API
+ * @param boardId 보드 식별자
+ * @param image 게시글에 첨부할 이미지
+ * @param customUserDetails 인증된 사용자 정보
+ * @return String ResponseEntity
+ * @throws IOException 오류 발생
+ */
+ @PostMapping
+ public ResponseEntity createBoardImage(
+ @PathVariable Long boardId,
+ @RequestParam("image") MultipartFile image,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) throws IOException {
+ boardImageService.createBoardImage(customUserDetails.getUser(), boardId, image);
+ return new ResponseEntity<>("업로드", HttpStatus.CREATED);
+ }
+
+ /**
+ * 게시글 첨부파일 수정 API
+ *
+ * @param id 이미지 식별자
+ * @param image 수정할 게시글 이미지
+ * @param customUserDetails 인증된 사용자 정보
+ * @return Void ResponseEntity
+ */
+ @PutMapping("/{id}")
+ public ResponseEntity updateBoardImage(
+ @PathVariable Long id,
+ @RequestParam("image") MultipartFile image,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) throws IOException {
+ boardImageService.updateBoardImage(customUserDetails.getUser(), id, image);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+
+ /**
+ * 이미지 삭제 API
+ *
+ * @param id 이미지 식별자
+ * @param customUserDetails 인증된 사용자 정보
+ * @return Void ResponseEntity
+ * @throws IOException 오류 발생
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteBoardImage(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) throws IOException {
+ boardImageService.deleteImage(customUserDetails.getUser(), id);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java b/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java
new file mode 100644
index 0000000..6e88cbb
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java
@@ -0,0 +1,48 @@
+package com.example.gamemate.domain.boardImage.entity;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.global.common.BaseCreatedEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "board_image")
+public class BoardImage extends BaseCreatedEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne
+ @JoinColumn(name = "board_id")
+ private Board board;
+
+ @Column(nullable = false)
+ private String fileName;
+
+ @Column(nullable = false)
+ private String fileType;
+
+ @Column(nullable = false)
+ private String filePath;
+
+ @Column(nullable = false)
+ private Long fileSize;
+
+ public BoardImage(String fileName, String fileType, String filePath, Long fileSize, Board board) {
+ this.fileName = fileName;
+ this.fileType = fileType;
+ this.filePath = filePath;
+ this.fileSize = fileSize;
+ this.board = board;
+ }
+
+ public void updateBoardImage(String fileName, String fileType, String filePath, Long fileSize) {
+ this.fileName = fileName;
+ this.fileType = fileType;
+ this.filePath = filePath;
+ this.fileSize = fileSize;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java b/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java
new file mode 100644
index 0000000..5b7db3a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java
@@ -0,0 +1,7 @@
+package com.example.gamemate.domain.boardImage.repository;
+
+import com.example.gamemate.domain.boardImage.entity.BoardImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface BoardImageRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java b/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java
new file mode 100644
index 0000000..5fa8aa6
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java
@@ -0,0 +1,117 @@
+package com.example.gamemate.domain.boardImage.service;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.boardImage.entity.BoardImage;
+import com.example.gamemate.domain.boardImage.repository.BoardImageRepository;
+import com.example.gamemate.domain.board.repository.BoardRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import com.example.gamemate.global.s3.S3Service;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class BoardImageService {
+
+ private final S3Service s3Service;
+ private final BoardImageRepository boardImageRepository;
+ private final BoardRepository boardRepository;
+
+ /**
+ * 이미지 업로드 메서드
+ *
+ * @param loginUser 로그인한 유저
+ * @param boardId 게시글 식별자
+ * @param image 게시글에 업로드할 이미지
+ * @throws IOException 오류 발생
+ */
+ @Transactional
+ public void createBoardImage(User loginUser, Long boardId, MultipartFile image) throws IOException {
+ // 게시글 조회
+ Board findBoard = boardRepository.findById(boardId)
+ .orElseThrow(()-> new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ // 게시글 작성자와 로그인한 유저 확인
+ if(!findBoard.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ //업로드 된 이미지 파일 주소
+ String publicUrl = s3Service.uploadFile(image);
+ //BoardImage 테이블에 담을 변수 생성
+ BoardImage boardImage = new BoardImage(image.getOriginalFilename(), image.getContentType(), publicUrl, image.getSize(),findBoard);
+
+ //BoardImage 테이블에 저장
+ boardImageRepository.save(boardImage);
+ }
+
+ /**
+ * 이미지 업데이트 메서드
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 이미지 식별자
+ * @param image 게시글에 업데이트할 이미지
+ * @throws IOException 오류 발생
+ */
+ @Transactional
+ public void updateBoardImage(User loginUser, Long id, MultipartFile image) throws IOException {
+ // 이미지 조회
+ BoardImage findBoardImage = boardImageRepository.findById(id)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_IMAGE_NOT_FOUND));
+
+ // 이미지 업로드 유저와 로그인 유저 확인
+ if(!findBoardImage.getBoard().getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ //업로드 된 이미지 파일 주소
+ String publicUrl = s3Service.uploadFile(image);
+ //BoardImage 테이블에 담을 변수 생성
+ findBoardImage.updateBoardImage(image.getOriginalFilename(), image.getContentType(), publicUrl, image.getSize());
+
+ //BoardImage 테이블에 저장
+ boardImageRepository.save(findBoardImage);
+
+ try{
+ // S3 에서 이미지 삭제
+ s3Service.deleteFile(findBoardImage.getFilePath());
+ } catch(Exception e){
+ log.error("파일 업로드 에러 발생 : {}",e.getMessage());
+ }
+
+ }
+
+
+ /**
+ * 이미지 삭제 메서드
+ * @param loginUser 로그인한 유저
+ * @param id 이미지 식별자
+ */
+ @Transactional
+ public void deleteImage(User loginUser, Long id) {
+ // 이미지 조회
+ BoardImage findBoardImage = boardImageRepository.findById(id)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_IMAGE_NOT_FOUND));
+
+ // 이미지 업로드 유저와 로그인 유저 확인
+ if(!findBoardImage.getBoard().getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ // S3 에서 이미지 삭제
+ s3Service.deleteFile(findBoardImage.getFilePath());
+
+ // 이미지 삭제
+ boardImageRepository.delete(findBoardImage);
+ }
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java b/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java
new file mode 100644
index 0000000..d4fa8f2
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java
@@ -0,0 +1,88 @@
+package com.example.gamemate.domain.comment.controller;
+
+import com.example.gamemate.domain.comment.dto.CommentFindResponseDto;
+import com.example.gamemate.domain.comment.dto.CommentRequestDto;
+import com.example.gamemate.domain.comment.dto.CommentResponseDto;
+import com.example.gamemate.domain.comment.service.CommentService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/boards/{boardId}/comments")
+public class CommentController {
+
+ private final CommentService commentService;
+
+ /**
+ * 댓글 생성 API 입니다.
+ *
+ * @param boardId 게시글 식별자
+ * @param requestDto 댓글 요청 Dto
+ * @return 생성된 댓글 정보를 포함한 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createComment(
+ @PathVariable Long boardId,
+ @RequestBody CommentRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ CommentResponseDto dto = commentService.createComment(customUserDetails.getUser(),boardId, requestDto);
+ return new ResponseEntity<>(dto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 댓글/대댓글 조회 입니다.
+ *
+ * @param boardId 댓글 식별자
+ * @param page 페이지 번호(기본값 : 0)
+ * @return 댓글 리스트 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> getComments(
+ @PathVariable Long boardId,
+ @RequestParam(defaultValue = "0") int page
+ ){
+ List dtos = commentService.getComments(boardId, page);
+ return new ResponseEntity<>(dtos, HttpStatus.OK);
+ }
+
+ /**
+ * 댓글 수정 API 입니다.
+ *
+ * @param id 댓글 식별자
+ * @param requestDto 업데이트할 댓글 Dto
+ * @return Void
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateComment(
+ @PathVariable Long id,
+ @RequestBody CommentRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ commentService.updateComment(customUserDetails.getUser(), id, requestDto);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 댓글 삭제 API 입니다.
+ *
+ * @param id 댓글 식별자
+ * @param customUserDetails 인증된 사용자 정보
+ * @return Void
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteComment(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ commentService.deleteComment(customUserDetails.getUser(), id);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java
new file mode 100644
index 0000000..a45c897
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java
@@ -0,0 +1,27 @@
+package com.example.gamemate.domain.comment.dto;
+
+import com.example.gamemate.domain.reply.dto.ReplyFindResponseDto;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+@Getter
+public class CommentFindResponseDto {
+ private final Long commentId;
+ private final String content;
+ private final String nickname;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+ private final List replies;
+
+ public CommentFindResponseDto(Long commentId, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt, List replies) {
+ this.commentId = commentId;
+ this.content = content;
+ this.nickname = nickname;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ this.replies = replies;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java
new file mode 100644
index 0000000..885e563
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java
@@ -0,0 +1,14 @@
+package com.example.gamemate.domain.comment.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class CommentRequestDto {
+ private String content;
+
+ public CommentRequestDto(String content) {
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java
new file mode 100644
index 0000000..a30a11b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java
@@ -0,0 +1,22 @@
+package com.example.gamemate.domain.comment.dto;
+
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class CommentResponseDto {
+ private final Long id;
+ private final String content;
+ private final String nickname;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+
+ public CommentResponseDto(Long id, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.id = id;
+ this.content = content;
+ this.nickname = nickname;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java b/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java
new file mode 100644
index 0000000..2c1bd9c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java
@@ -0,0 +1,42 @@
+package com.example.gamemate.domain.comment.entity;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.reply.entity.Reply;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "comment")
+public class Comment extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String content;
+
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @ManyToOne
+ @JoinColumn(name = "board_id")
+ private Board board;
+
+ public Comment(String content, Board board, User user) {
+ this.content = content;
+ this.board = board;
+ this.user = user;
+ }
+
+ public void updateComment(String content) {
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java
new file mode 100644
index 0000000..2b88a0e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java
@@ -0,0 +1,14 @@
+package com.example.gamemate.domain.comment.repository;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.comment.entity.Comment;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface CommentRepository extends JpaRepository {
+
+ Page findByBoard(Board findBoard, Pageable pageable);
+}
diff --git a/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java b/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java
new file mode 100644
index 0000000..fe3e964
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java
@@ -0,0 +1,172 @@
+package com.example.gamemate.domain.comment.service;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.board.enums.ListSize;
+import com.example.gamemate.domain.board.repository.BoardRepository;
+import com.example.gamemate.domain.comment.dto.CommentFindResponseDto;
+import com.example.gamemate.domain.comment.dto.CommentRequestDto;
+import com.example.gamemate.domain.comment.dto.CommentResponseDto;
+import com.example.gamemate.domain.comment.entity.Comment;
+import com.example.gamemate.domain.comment.repository.CommentRepository;
+import com.example.gamemate.domain.reply.dto.ReplyFindResponseDto;
+import com.example.gamemate.domain.reply.entity.Reply;
+import com.example.gamemate.domain.reply.repository.ReplyRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.eventListener.event.CommentCreatedEvent;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class CommentService {
+
+ private final CommentRepository commentRepository;
+ private final ReplyRepository replyRepository;
+ private final BoardRepository boardRepository;
+ private final ApplicationEventPublisher publisher;
+
+ /**
+ * 댓글 생성 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param boardId 게시글 식별자
+ * @param requestDto 댓글 생성할 requestDto
+ * @return CommentResponseDto
+ */
+ @Transactional
+ public CommentResponseDto createComment(User loginUser, Long boardId, CommentRequestDto requestDto) {
+ // 게시글 조회
+ Board findBoard = boardRepository.findById(boardId)
+ .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ Comment comment = new Comment(requestDto.getContent(), findBoard, loginUser);
+ Comment createComment = commentRepository.save(comment);
+
+ publisher.publishEvent(new CommentCreatedEvent(this, createComment));
+
+ return new CommentResponseDto(
+ createComment.getId(),
+ createComment.getContent(),
+ createComment.getUser().getNickname(),
+ createComment.getCreatedAt(),
+ createComment.getModifiedAt()
+ );
+ }
+
+ /**
+ * 댓글 업데이트 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 댓글 식별자
+ * @param requestDto 업데이트할 댓글 dto
+ */
+ @Transactional
+ public void updateComment(User loginUser, Long id, CommentRequestDto requestDto) {
+ // 댓글 조회
+ Comment findComment = commentRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+
+ // 댓글 작성자와 로그인한 유저 확인
+ if(!findComment.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ findComment.updateComment(requestDto.getContent());
+ commentRepository.save(findComment);
+ }
+
+ /**
+ * 댓글 삭제 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 댓글 식별자
+ */
+ @Transactional
+ public void deleteComment(User loginUser, Long id) {
+ // 댓글 조회
+ Comment findComment = commentRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+
+ // 댓글 작성자와 로그인한 유저 확인
+ if(!findComment.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ commentRepository.delete(findComment);
+ }
+
+ /**
+ * 댓글 조회 메서드입니다.
+ *
+ * @param boardId 게시글 식별자
+ * @param page 페이지 번호(기본값 : 0)
+ * @return Comment 조회 Do
+ */
+ public List getComments(Long boardId, int page) {
+ // page는 댓글 페이지네이션을 위해 필요
+ Pageable pageable = PageRequest.of(page, ListSize.COMMENT_LIST_SIZE.getSize(), Sort.by(Sort.Order.asc("createdAt")));
+
+ //게시글 조회
+ Board findBoard = boardRepository.findById(boardId)
+ .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND));
+
+ // 댓글 조회
+ Page comments = commentRepository.findByBoard(findBoard,pageable);
+
+ return comments.stream()
+ .map(this::convertCommentDto)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 댓글 Dto 변환입니다.
+ *
+ * @param comment comment
+ * @return 댓글 조회 Dto
+ */
+ private CommentFindResponseDto convertCommentDto(Comment comment) {
+ List replyDtos = Optional.ofNullable(replyRepository.findByComment(comment))
+ .orElse(Collections.emptyList())
+ .stream()
+ .map(this::convertReplyDto)
+ .collect(Collectors.toList());
+ return new CommentFindResponseDto(
+ comment.getId(),
+ comment.getContent(),
+ comment.getUser().getNickname(),
+ comment.getCreatedAt(),
+ comment.getModifiedAt(),
+ replyDtos
+ );
+ }
+
+ /**
+ * 대댓글 Dto 변환입니다.
+ *
+ * @param reply 대댓글
+ * @return 대댓글 조회 Dto
+ */
+ private ReplyFindResponseDto convertReplyDto(Reply reply) {
+ String findUserName = reply.getParentReply() == null ? null : reply.getParentReply().getUser().getNickname();
+ return new ReplyFindResponseDto(
+ reply.getId(),
+ findUserName,
+ reply.getContent(),
+ reply.getCreatedAt(),
+ reply.getModifiedAt()
+ );
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java b/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java
new file mode 100644
index 0000000..6fcd1f6
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java
@@ -0,0 +1,58 @@
+package com.example.gamemate.domain.coupon.controller;
+
+import com.example.gamemate.domain.coupon.dto.CouponCreateRequestDto;
+import com.example.gamemate.domain.coupon.dto.CouponCreateResponseDto;
+import com.example.gamemate.domain.coupon.dto.CouponIssueResponseDto;
+import com.example.gamemate.domain.coupon.service.CouponService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/coupons")
+@RequiredArgsConstructor
+public class CouponController {
+ private final CouponService couponService;
+
+ @PostMapping("/create")
+ public ResponseEntity createCoupon(
+ @Valid @RequestBody CouponCreateRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ CouponCreateResponseDto responseDto = couponService.createCoupon(requestDto, customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+ @PostMapping("/{couponId}/issue")
+ public ResponseEntity issueCoupon(
+ @PathVariable Long couponId,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ CouponIssueResponseDto responseDto = couponService.issueCoupon(couponId, customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ @GetMapping("/my")
+ public ResponseEntity> findMyCoupons(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ List responseDtos = couponService.findMyCoupons(customUserDetails.getUser());
+ return new ResponseEntity<>(responseDtos, HttpStatus.OK);
+ }
+
+ @PostMapping("/user-coupons/{userCouponId}/use")
+ public ResponseEntity useCoupon(
+ @PathVariable Long userCouponId,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+ couponService.useCoupon(userCouponId, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java
new file mode 100644
index 0000000..8df4e42
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java
@@ -0,0 +1,35 @@
+package com.example.gamemate.domain.coupon.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@RequiredArgsConstructor
+public class CouponCreateRequestDto {
+
+ @NotBlank(message = "쿠폰 코드를 입력해주세요.")
+ private final String code;
+
+ @NotBlank(message = "쿠폰 이름을 입력해주세요.")
+ private final String name;
+
+ @Positive(message = "할인 금액을 양수로 입력해주세요.")
+ @NotNull(message = "할인 금액을 입력해주세요.")
+ private final Integer discountAmount;
+
+ @NotNull(message = "시작 시간을 입력해주세요.")
+ private final LocalDateTime startAt;
+
+ @NotNull(message = "종료 시간을 입력해주세요.")
+ private final LocalDateTime expiredAt;
+
+ @NotNull(message = "쿠폰 수량을 입력해주세요.")
+ private final Integer quantity;
+
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java
new file mode 100644
index 0000000..d02dc4c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java
@@ -0,0 +1,27 @@
+package com.example.gamemate.domain.coupon.dto;
+
+import com.example.gamemate.domain.coupon.entity.Coupon;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class CouponCreateResponseDto {
+ private final Long id;
+ private final String code;
+ private final String name;
+ private final Integer discountAmount;
+ private final LocalDateTime startAt;
+ private final LocalDateTime expiredAt;
+ private final Integer quantity;
+
+ public CouponCreateResponseDto(Coupon coupon) {
+ this.id = coupon.getId();
+ this.code = coupon.getCode();
+ this.name = coupon.getName();
+ this.discountAmount = coupon.getDiscountAmount();
+ this.startAt = coupon.getStartAt();
+ this.expiredAt = coupon.getExpiredAt();
+ this.quantity = coupon.getTotalQuantity();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java
new file mode 100644
index 0000000..2ea4161
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java
@@ -0,0 +1,33 @@
+package com.example.gamemate.domain.coupon.dto;
+
+import com.example.gamemate.domain.coupon.entity.Coupon;
+import com.example.gamemate.domain.coupon.entity.UserCoupon;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class CouponIssueResponseDto {
+
+ private final Long id;
+ private final String code;
+ private final String name;
+ private final Integer discountAmount;
+ private final LocalDateTime issuedAt;
+ private final LocalDateTime startAt;
+ private final LocalDateTime expiredAt;
+ private final Boolean isUsed;
+
+ public CouponIssueResponseDto(UserCoupon userCoupon) {
+ Coupon coupon = userCoupon.getCoupon();
+ this.id = userCoupon.getId();
+ this.code = coupon.getCode();
+ this.name = coupon.getName();
+ this.discountAmount = coupon.getDiscountAmount();
+ this.startAt = coupon.getStartAt();
+ this.expiredAt = coupon.getExpiredAt();
+ this.issuedAt = userCoupon.getIssuedAt();
+ this.isUsed = userCoupon.getIsUsed();
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java b/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java
new file mode 100644
index 0000000..25e7645
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java
@@ -0,0 +1,67 @@
+package com.example.gamemate.domain.coupon.entity;
+
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "coupon")
+public class Coupon extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String code;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private Integer discountAmount;
+
+ @Column(nullable = false)
+ private Integer totalQuantity;
+
+ @Column(nullable = false)
+ private Integer issuedQuantity = 0;
+
+ @Column(nullable = false)
+ private LocalDateTime startAt;
+
+ @Column(nullable = false)
+ private LocalDateTime expiredAt;
+
+ @OneToMany(mappedBy = "coupon")
+ private List userCoupons = new ArrayList<>();
+
+ public Coupon(String code, String name, Integer discountAmount, Integer totalQuantity, LocalDateTime startAt, LocalDateTime expiredAt) {
+ this.code = code;
+ this.name = name;
+ this.discountAmount = discountAmount;
+ this.totalQuantity = totalQuantity;
+ this.startAt = startAt;
+ this.expiredAt = expiredAt;
+ }
+
+ public boolean isIssuable() {
+ LocalDateTime now = LocalDateTime.now();
+ return now.isAfter(startAt) && now.isBefore(expiredAt);
+ }
+
+ public boolean isExhausted() {
+ return issuedQuantity >= totalQuantity;
+ }
+
+ public void incrementIssuedQuantity() {
+ this.issuedQuantity++;
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java b/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java
new file mode 100644
index 0000000..e89ffe0
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java
@@ -0,0 +1,50 @@
+package com.example.gamemate.domain.coupon.entity;
+
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "user_coupon")
+public class UserCoupon extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private Boolean isUsed = false;
+
+ @Column(nullable = false)
+ private LocalDateTime issuedAt;
+
+ private LocalDateTime usedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "coupon_id", nullable = false)
+ private Coupon coupon;
+
+ public UserCoupon(User user, Coupon coupon) {
+ this.user = user;
+ this.coupon = coupon;
+ this.issuedAt = LocalDateTime.now();
+ }
+
+ public void updateIsUsed(Boolean isUsed) {
+ this.isUsed = isUsed;
+ }
+
+ public void updateUsedAt() {
+ this.usedAt = LocalDateTime.now();
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java b/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java
new file mode 100644
index 0000000..0fdc3e3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.coupon.repository;
+
+import com.example.gamemate.domain.coupon.entity.Coupon;
+import jakarta.persistence.LockModeType;
+import jakarta.persistence.QueryHint;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.jpa.repository.QueryHints;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Optional;
+
+public interface CouponRepository extends JpaRepository {
+ boolean existsByCode(String code);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select c from Coupon c where c.id = :id")
+ Optional findByIdWithPessimisticLock(@Param("id") Long id);
+}
diff --git a/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java b/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java
new file mode 100644
index 0000000..eac2441
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.coupon.repository;
+
+import com.example.gamemate.domain.coupon.entity.UserCoupon;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface UserCouponRepository extends JpaRepository {
+ boolean existsByUserIdAndCouponId(Long userId, Long couponId);
+ List findByUserId(Long userId);
+ long countByCouponId(Long couponId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java b/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java
new file mode 100644
index 0000000..cd4d5ab
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java
@@ -0,0 +1,118 @@
+package com.example.gamemate.domain.coupon.service;
+
+import com.example.gamemate.domain.coupon.dto.CouponCreateRequestDto;
+import com.example.gamemate.domain.coupon.dto.CouponCreateResponseDto;
+import com.example.gamemate.domain.coupon.dto.CouponIssueResponseDto;
+import com.example.gamemate.domain.coupon.entity.Coupon;
+import com.example.gamemate.domain.coupon.entity.UserCoupon;
+import com.example.gamemate.domain.coupon.repository.CouponRepository;
+import com.example.gamemate.domain.coupon.repository.UserCouponRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.Role;
+import com.example.gamemate.global.common.aop.DistributedLock;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+@Slf4j
+public class CouponService {
+ private final CouponRepository couponRepository;
+ private final UserCouponRepository userCouponRepository;
+
+ public CouponCreateResponseDto createCoupon(CouponCreateRequestDto requestDto, User loginUser) {
+ // 관리자 권한 체크
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ // 쿠폰 코드 중복 확인
+ if (couponRepository.existsByCode(requestDto.getCode())) {
+ throw new ApiException(ErrorCode.COUPON_CODE_DUPLICATED);
+ }
+
+ // 쿠폰 사용 기간 유효성 검증
+ validateCouponDates(requestDto.getStartAt(), requestDto.getExpiredAt());
+
+ // 쿠폰 생성
+ Coupon coupon = new Coupon(requestDto.getCode(), requestDto.getName(), requestDto.getDiscountAmount(), requestDto.getQuantity(), requestDto.getStartAt(), requestDto.getExpiredAt());
+ Coupon savedCoupon = couponRepository.save(coupon);
+
+ return new CouponCreateResponseDto(savedCoupon);
+ }
+
+ @DistributedLock(key = "'LOCK:coupon:' + #couponId")
+ public CouponIssueResponseDto issueCoupon(Long couponId, User loginUser) {
+ Coupon coupon = couponRepository.findById(couponId)
+ .orElseThrow(() -> new ApiException(ErrorCode.COUPON_NOT_FOUND));
+
+ // 발급 가능 체크
+ if (!coupon.isIssuable()) {
+ throw new ApiException(ErrorCode.COUPON_NOT_ISSUABLE);
+ }
+
+ // 중복 발급 체크
+ if (userCouponRepository.existsByUserIdAndCouponId(loginUser.getId(), couponId)) {
+ throw new ApiException(ErrorCode.COUPON_ALREADY_ISSUED);
+ }
+
+ // 수량 체크
+ if (coupon.isExhausted()) {
+ throw new ApiException(ErrorCode.COUPON_EXHAUSTED);
+ }
+
+ // 쿠폰 발급
+ coupon.incrementIssuedQuantity();
+ UserCoupon userCoupon = new UserCoupon(loginUser, coupon);
+ UserCoupon savedUserCoupon = userCouponRepository.save(userCoupon);
+
+ return new CouponIssueResponseDto(savedUserCoupon);
+ }
+
+ @Transactional(readOnly = true)
+ public List findMyCoupons(User loginUser) {
+ List userCoupons = userCouponRepository.findByUserId(loginUser.getId());
+ return userCoupons.stream()
+ .map(CouponIssueResponseDto::new)
+ .collect(Collectors.toList());
+ }
+
+ public void useCoupon(Long userCouponId, User loginUser) {
+ UserCoupon userCoupon = userCouponRepository.findById(userCouponId)
+ .orElseThrow(()-> new ApiException(ErrorCode.COUPON_NOT_FOUND));
+
+ // 본인 쿠폰인지 확인
+ if (!userCoupon.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ // 이미 사용된 쿠폰인지 확인
+ if (userCoupon.getIsUsed()) {
+ throw new ApiException(ErrorCode.COUPON_ALREADY_USED);
+ }
+
+ // 쿠폰 유효기간 확인
+ if (LocalDateTime.now().isAfter(userCoupon.getCoupon().getExpiredAt())) {
+ throw new ApiException(ErrorCode.COUPON_EXPIRED);
+ }
+
+ // 쿠폰 사용 처리
+ userCoupon.updateIsUsed(true);
+ userCoupon.updateUsedAt();
+ }
+
+ private void validateCouponDates(LocalDateTime startAt, LocalDateTime expiredAt) {
+ if (startAt.isAfter(expiredAt)) {
+ throw new ApiException(ErrorCode.INVALID_COUPON_DATE);
+ }
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java b/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java
new file mode 100644
index 0000000..c1cd1f3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java
@@ -0,0 +1,104 @@
+package com.example.gamemate.domain.follow.controller;
+
+import com.example.gamemate.domain.follow.service.FollowService;
+import com.example.gamemate.domain.follow.dto.*;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 팔로우 기능을 처리하는 컨트롤러 클래스입니다.
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/follows")
+public class FollowController {
+
+ private final FollowService followService;
+
+ /**
+ * 사용자간의 팔로우를 생성을 처리합니다.
+ *
+ * @param dto FollowCreateRequestDto 팔로우할 상대방의 email
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 팔로우 처리 결과를 담은 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createFollow(
+ @RequestBody FollowCreateRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ FollowResponseDto followResponseDto = followService.createFollow(dto, customUserDetails.getUser());
+ return new ResponseEntity<>(followResponseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 사용자간의 팔로우 취소를 처리합니다.
+ *
+ * @param id 취소할 팔로우 ID
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT 성공했지만 반환값이 없음
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteFollow(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ followService.deleteFollow(id, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 팔로우 상태를 확인합니다. (loginUser 가 followee 를 팔로우 했는지 확인)
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @param email 팔로우 상태를 확인할 상대방 이메일
+ * @return 로그인한 사용자가 상대방을 팔로우 했을시 true, 아닐시 false
+ */
+ @GetMapping("/status")
+ public ResponseEntity findFollow(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @RequestParam String email
+ ) {
+
+ FollowBooleanResponseDto followBooleanResponseDto = followService.findFollow(customUserDetails.getUser(), email);
+ return new ResponseEntity<>(followBooleanResponseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 특정 유저의 팔로워 목록를 조회합니다.
+ *
+ * @param email 팔로워 목록을 보고 싶은 유저 email
+ * @return 특정 유저의 팔로워 목록을 담은 ResponseEntity
+ */
+ @GetMapping("/followers")
+ public ResponseEntity> findFollowers(
+ @RequestParam String email
+ ) {
+
+ List followFindResponseDtoList = followService.findFollowers(email);
+ return new ResponseEntity<>(followFindResponseDtoList, HttpStatus.OK);
+ }
+
+ /**
+ * 특정 유저의 팔로잉 목록을 조회합니다.
+ *
+ * @param email 팔로잉 목록을 보고 싶은 유저 email
+ * @return 특정 유저의 팔로잉 목록을 담은 ResponseEntity
+ */
+ @GetMapping("/following")
+ public ResponseEntity> findFollowing(
+ @RequestParam String email
+ ) {
+
+ List followFindResponseDtoList = followService.findFollowing(email);
+ return new ResponseEntity<>(followFindResponseDtoList, HttpStatus.OK);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java
new file mode 100644
index 0000000..b351d7d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.follow.dto;
+
+import lombok.Getter;
+
+@Getter
+public class FollowBooleanResponseDto {
+ private boolean isFollowing;
+ private Long followerId;
+ private Long followeeId;
+
+ public FollowBooleanResponseDto(boolean isFollowing, Long followerId, Long followeeId) {
+ this.isFollowing = isFollowing;
+ this.followerId = followerId;
+ this.followeeId = followeeId;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java
new file mode 100644
index 0000000..677696d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java
@@ -0,0 +1,12 @@
+package com.example.gamemate.domain.follow.dto;
+
+import lombok.Getter;
+
+@Getter
+public class FollowCreateRequestDto {
+ private String email;
+
+ public FollowCreateRequestDto(String email) {
+ this.email = email;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java
new file mode 100644
index 0000000..6013f2e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java
@@ -0,0 +1,19 @@
+package com.example.gamemate.domain.follow.dto;
+
+import com.example.gamemate.domain.user.entity.User;
+import lombok.Getter;
+
+@Getter
+public class FollowFindResponseDto {
+ private Long id;
+ private String nickname;
+
+ public FollowFindResponseDto(Long id, String nickname) {
+ this.id = id;
+ this.nickname = nickname;
+ }
+
+ public static FollowFindResponseDto toDto(User user) {
+ return new FollowFindResponseDto(user.getId(), user.getNickname());
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java
new file mode 100644
index 0000000..78b0d4f
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.follow.dto;
+
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class FollowResponseDto {
+ private Long id;
+ private Long followerId;
+ private Long followeeId;
+ private LocalDateTime createdAt;
+
+ public FollowResponseDto(Long id, Long followerId, Long followeeId, LocalDateTime createdAt) {
+ this.id = id;
+ this.followerId = followerId;
+ this.followeeId = followeeId;
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java b/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java
new file mode 100644
index 0000000..837a819
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java
@@ -0,0 +1,30 @@
+package com.example.gamemate.domain.follow.entity;
+
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseCreatedEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+
+@Entity
+@Getter
+public class Follow extends BaseCreatedEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "follower_id", nullable = false)
+ private User follower;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "followee_id", nullable = false)
+ private User followee;
+
+ public Follow() {
+ }
+
+ public Follow(User follower, User followee) {
+ this.follower = follower;
+ this.followee = followee;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java b/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java
new file mode 100644
index 0000000..c8d7441
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java
@@ -0,0 +1,32 @@
+package com.example.gamemate.domain.follow.repository;
+
+import com.example.gamemate.domain.follow.dto.FollowFindResponseDto;
+import com.example.gamemate.domain.follow.entity.Follow;
+import com.example.gamemate.domain.user.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface FollowRepository extends JpaRepository {
+ Boolean existsByFollowerAndFollowee(User follower, User followee);
+ List findByFollowee(User followee);
+ List findByFollower(User follower);
+
+ @Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.follower.id, f.follower.nickname) " +
+ "FROM Follow f " +
+ "JOIN f.follower " +
+ "WHERE f.followee.email = :email " +
+ "AND f.follower.userStatus != 'WITHDRAW'")
+ List findFollowersByFolloweeEmail(@Param("email") String email);
+
+ @Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.followee.id, f.followee.nickname) " +
+ "FROM Follow f " +
+ "JOIN f.followee " +
+ "WHERE f.follower.email = :email " +
+ "AND f.followee.userStatus != 'WITHDRAW'")
+ List findFollowingByFollowerEmail(@Param("email") String email);
+}
diff --git a/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java b/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java
new file mode 100644
index 0000000..f5ce6bf
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java
@@ -0,0 +1,153 @@
+package com.example.gamemate.domain.follow.service;
+
+import com.example.gamemate.domain.follow.dto.*;
+import com.example.gamemate.domain.follow.entity.Follow;
+import com.example.gamemate.domain.follow.repository.FollowRepository;
+import com.example.gamemate.domain.notification.service.NotificationService;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.UserStatus;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.eventListener.event.FollowCreatedEvent;
+import com.example.gamemate.global.exception.ApiException;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * 팔로우 기능을 처리하는 서비스 클래스입니다.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class FollowService {
+ private final UserRepository userRepository;
+ private final FollowRepository followRepository;
+ private final NotificationService notificationService;
+ private final ApplicationEventPublisher publisher;
+
+
+ /**
+ * 사용자 간의 팔로우를 생성합니다.
+ * @param dto FollowCreateRequestDto 팔로우할 상대방의 email
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 팔로우 처리 결과를 담은 FollowResponseDto
+ */
+ @Transactional
+ public FollowResponseDto createFollow(FollowCreateRequestDto dto, User loginUser) {
+
+ User followee = userRepository.findByEmail(dto.getEmail())
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (followee.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 탈퇴한 유저일때 예외처리
+
+ if (followRepository.existsByFollowerAndFollowee(loginUser, followee)) {
+ throw new ApiException(ErrorCode.IS_ALREADY_FOLLOWED);
+ } // 이미 팔로우를 했을때 예외처리
+
+ if (Objects.equals(loginUser.getEmail(), dto.getEmail())) {
+ throw new ApiException(ErrorCode.INVALID_INPUT);
+ } // 자기 자신을 팔로우 할때 예외처리
+
+ Follow follow = new Follow(loginUser, followee);
+ followRepository.save(follow);
+ publisher.publishEvent(new FollowCreatedEvent(this, follow));
+
+ return new FollowResponseDto(
+ follow.getId(),
+ follow.getFollower().getId(),
+ follow.getFollowee().getId(),
+ follow.getCreatedAt()
+ );
+ }
+
+ /**
+ * 사용자 간의 팔로우를 취소합니다.
+ * @param id 팔로우 id
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void deleteFollow(Long id, User loginUser) {
+
+ Follow findFollow = followRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.FOLLOW_NOT_FOUND));
+
+ log.info("사용자 email : {}" , loginUser.getEmail());
+
+ if (!Objects.equals(findFollow.getFollower().getId(), loginUser.getId())) {
+ throw new ApiException(ErrorCode.INVALID_INPUT);
+ } // 본인의 팔로우가 아닐때 예외처리
+
+ followRepository.delete(findFollow);
+ }
+
+ /**
+ * 팔로우 상태를 확인합니다. (loginUser 가 followee 를 팔로우 했는지 확인)
+ * @param loginUser 현재 인증된 사용자 정보
+ * @param email 팔로우 상태를 확인할 사용자 email
+ * @return 팔로우 상태의 정보를 담은 FollowBooleanResponseDto
+ */
+ public FollowBooleanResponseDto findFollow(User loginUser, String email) {
+
+ User followee = userRepository.findByEmail(email)
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (followee.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 확인할 상대방이 탈퇴한 회원일때 예외처리
+
+ if (!followRepository.existsByFollowerAndFollowee(loginUser, followee)) {
+ return new FollowBooleanResponseDto(
+ false,
+ loginUser.getId(),
+ followee.getId()
+ );
+ }
+
+ return new FollowBooleanResponseDto(
+ true,
+ loginUser.getId(),
+ followee.getId()
+ );
+ }
+
+ /**
+ * 특정 유저의 팔로워 목록를 조회합니다.
+ * @param email 팔로워 목록을 확인할 상대방 email
+ * @return 팔로워 목록을 담은 List
+ */
+ public List findFollowers(String email) {
+ User followee = userRepository.findByEmail(email)
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (followee.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 확인할 상대방이 탈퇴한 회원일때 예외처리
+
+ return followRepository.findFollowersByFolloweeEmail(email);
+ }
+
+ /**
+ * 특정 유저의 팔로잉 목록를 조회합니다.
+ * @param email 팔로잉 목록을 조회할 상대방 email
+ * @return 팔로잉 목록을 담은 List
+ */
+ public List findFollowing(String email) {
+ User follower = userRepository.findByEmail(email)
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (follower.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 확인할 상대방이 탈퇴한 회원일때 예외처리
+
+ return followRepository.findFollowingByFollowerEmail(email);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameController.java b/src/main/java/com/example/gamemate/domain/game/controller/GameController.java
new file mode 100644
index 0000000..535d333
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/controller/GameController.java
@@ -0,0 +1,151 @@
+package com.example.gamemate.domain.game.controller;
+
+import com.example.gamemate.domain.game.dto.request.GameCreateRequestDto;
+import com.example.gamemate.domain.game.dto.request.GameUpdateRequestDto;
+import com.example.gamemate.domain.game.dto.response.GameCreateResponseDto;
+import com.example.gamemate.domain.game.dto.response.GameFindAllResponseDto;
+import com.example.gamemate.domain.game.dto.response.GameFindByIdResponseDto;
+import com.example.gamemate.domain.game.service.GameService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 게임 관련 API를 처리하는 컨트롤러 클래스입니다.
+ * 게임의 생성, 조회, 수정, 삭제 기능을 제공합니다.
+ */
+@RestController
+@RequestMapping("/games")
+@Slf4j
+@RequiredArgsConstructor
+public class GameController {
+ private final GameService gameService;
+
+ /**
+ * 새로운 게임을 생성합니다.
+ *
+ * @param gameDataString 게임 데이터를 포함한 JSON 문자열
+ * @param file 게임 관련 이미지 파일 (선택적)
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 생성된 게임 정보를 포함한 ResponseEntity
+ * @throws RuntimeException JSON 파싱 오류 시 발생
+ */
+ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity createGame(
+ @Valid @RequestPart(value = "gameData") String gameDataString,
+ @RequestPart(value = "file", required = false) MultipartFile file,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ ObjectMapper mapper = new ObjectMapper();
+ GameCreateRequestDto requestDto;
+ try {
+ requestDto = mapper.readValue(gameDataString, GameCreateRequestDto.class);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Invalid JSON format", e);
+ }
+
+ GameCreateResponseDto responseDto = gameService.createGame(customUserDetails.getUser(), requestDto, file);
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 모든 게임을 페이지네이션하여 조회하거나 검색합니다.
+ *
+ * @param keyword 검색 키워드 (선택적)
+ * @param genre 게임 장르 (선택적)
+ * @param platform 게임 플랫폼 (선택적)
+ * @param page 페이지 번호 (기본값: 0)
+ * @param size 페이지 크기 (기본값: 10)
+ * @return 게임 목록을 포함한 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> findAllGame(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String genre,
+ @RequestParam(required = false) String platform,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+
+ log.info("Search parameters - keyword: {}, genre: {}, platform: {}, page: {}, size: {}",
+ keyword, genre, platform, page, size);
+ Page games;
+ if (keyword != null || genre != null || platform != null) {
+ games = gameService.searchGame(keyword, genre, platform, page, size);
+ } else {
+ games = gameService.findAllGame(page, size);
+ }
+
+ return new ResponseEntity<>(games, HttpStatus.OK);
+
+ }
+
+ /**
+ * 특정 ID의 게임을 조회합니다.
+ *
+ * @param id 조회할 게임의 ID
+ * @return 조회된 게임 정보를 포함한 ResponseEntity
+ */
+ @GetMapping("/{id}")
+ public ResponseEntity findGameById(
+ @PathVariable Long id) {
+
+ GameFindByIdResponseDto gameById = gameService.findGameById(id);
+ return new ResponseEntity<>(gameById, HttpStatus.OK);
+
+ }
+
+ /**
+ * 특정 ID의 게임 정보를 수정합니다.
+ *
+ * @param id 수정할 게임의 ID
+ * @param gameDataString 수정할 게임 데이터를 포함한 JSON 문자열
+ * @param newFile 새로운 게임 이미지 파일 (선택적)
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 수정 결과를 나타내는 ResponseEntity
+ * @throws RuntimeException JSON 파싱 오류 시 발생
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateGame(
+ @PathVariable Long id,
+ @Valid @RequestPart(value = "gameData") String gameDataString,
+ @RequestPart(value = "file", required = false) MultipartFile newFile,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ ObjectMapper mapper = new ObjectMapper();
+ GameUpdateRequestDto requestDto;
+ try {
+ requestDto = mapper.readValue(gameDataString, GameUpdateRequestDto.class);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Invalid JSON format", e);
+ }
+
+ gameService.updateGame(id, requestDto, newFile, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 특정 ID의 게임을 삭제합니다.
+ *
+ * @param id 삭제할 게임의 ID
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 삭제 결과를 나타내는 ResponseEntity
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteGame(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ gameService.deleteGame(id, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java b/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java
new file mode 100644
index 0000000..a321d55
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java
@@ -0,0 +1,104 @@
+package com.example.gamemate.domain.game.controller;
+
+import com.example.gamemate.domain.game.dto.request.GameEnrollRequestCreateRequestDto;
+import com.example.gamemate.domain.game.dto.response.GameEnrollRequestResponseDto;
+import com.example.gamemate.domain.game.dto.request.GameEnrollRequestUpdateRequestDto;
+import com.example.gamemate.domain.game.service.GameEnrollRequestService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 게임 등록 요청 관련 API를 처리하는 컨트롤러 클래스입니다.
+ * 게임 등록 요청의 생성, 조회, 수정, 삭제 기능을 제공합니다.
+ */
+@RestController
+@RequestMapping("/games/requests")
+@RequiredArgsConstructor
+public class GameEnrollRequestController {
+ private final GameEnrollRequestService gameEnrollRequestService;
+
+ /**
+ * 새로운 게임 등록 요청을 생성합니다.
+ *
+ * @param requestDto 게임 등록 요청 데이터
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 생성된 게임 등록 요청 정보를 포함한 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity CreateGameEnrollRequest(
+ @RequestBody GameEnrollRequestCreateRequestDto requestDto,
+ @Valid @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ GameEnrollRequestResponseDto responseDto = gameEnrollRequestService.createGameEnrollRequest(requestDto, customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 모든 게임 등록 요청을 조회합니다.
+ *
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 게임 등록 요청 목록을 포함한 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> findAllGameEnrollRequest(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ Page gameEnrollRequestAll = gameEnrollRequestService.findAllGameEnrollRequest(customUserDetails.getUser());
+ return new ResponseEntity<>(gameEnrollRequestAll, HttpStatus.OK);
+
+ }
+
+ /**
+ * 모든 게임 등록 요청을 조회합니다.
+ *
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 게임 등록 요청 목록을 포함한 ResponseEntity
+ */
+ @GetMapping("/{id}")
+ public ResponseEntity findGameEnrollRequestById(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ GameEnrollRequestResponseDto gameEnrollRequestById = gameEnrollRequestService.findGameEnrollRequestById(id, customUserDetails.getUser());
+ return new ResponseEntity<>(gameEnrollRequestById, HttpStatus.OK);
+ }
+
+ /**
+ * 특정 ID의 게임 등록 요청을 수정하고, 필요시 게임 등록 기능과 연계합니다.
+ *
+ * @param id 수정할 게임 등록 요청의 ID
+ * @param requestDto 수정할 게임 등록 요청 데이터
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 수정 결과를 나타내는 ResponseEntity
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateGameEnroll(
+ @PathVariable Long id,
+ @Valid @RequestBody GameEnrollRequestUpdateRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ gameEnrollRequestService.updateGameEnroll(id, requestDto, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 특정 ID의 게임 등록 요청을 삭제합니다.
+ *
+ * @param id 삭제할 게임 등록 요청의 ID
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 삭제 결과를 나타내는 ResponseEntity
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteGameEnroll(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+ gameEnrollRequestService.deleteGameEnroll(id,customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java b/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java
new file mode 100644
index 0000000..6d3d99d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java
@@ -0,0 +1,59 @@
+package com.example.gamemate.domain.game.controller;
+
+import com.example.gamemate.domain.game.dto.response.GameRecommendHistorysResponseDto;
+import com.example.gamemate.domain.game.dto.request.UserGamePreferenceRequestDto;
+import com.example.gamemate.domain.game.dto.response.UserGamePreferenceResponseDto;
+import com.example.gamemate.domain.game.service.GameRecommendService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 게임 추천 관련 API를 처리하는 컨트롤러 클래스입니다.
+ * 사용자 게임 선호도 생성 및 게임 추천 이력 조회 기능을 제공합니다.
+ */
+@RestController
+@RequestMapping("/games/recommendations")
+@RequiredArgsConstructor
+public class GameRecommendContorller {
+
+ private final GameRecommendService gameRecommendService;
+
+ /**
+ * 사용자의 게임 선호도를 생성합니다.
+ *
+ * @param requestDto 사용자 게임 선호도 정보
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 생성된 사용자 게임 선호도 정보를 포함한 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createUserGamePreference(
+ @RequestBody UserGamePreferenceRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ UserGamePreferenceResponseDto responseDto = gameRecommendService.createUserGamePreference(requestDto, customUserDetails.getUser());
+
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 특정 사용자의 게임 추천 이력을 조회합니다.
+ *
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 게임 추천 이력 목록을 포함한 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> getGameRecommendHistories(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ Page responseDto = gameRecommendService.getGameRecommendHistories(customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java
new file mode 100644
index 0000000..e0df5af
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java
@@ -0,0 +1,50 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import lombok.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@Builder
+public class ChatRequestDto {
+ private List contents;
+ private GenerationConfig generationConfig;
+
+ @Getter
+ @Setter
+ public static class Content {
+ private Parts parts;
+ }
+
+ @Getter @Setter
+ public static class Parts {
+ private String text;
+
+ }
+
+ @Getter @Setter
+ public static class GenerationConfig {
+ private int candidate_count;
+ private int max_output_tokens;
+ private double temperature;
+
+ }
+
+ public ChatRequestDto(String prompt) {
+ this.contents = new ArrayList<>();
+ Content content = new Content();
+ Parts parts = new Parts();
+
+ parts.setText(prompt);
+ content.setParts(parts);
+
+ this.contents.add(content);
+ this.generationConfig = new GenerationConfig();
+ this.generationConfig.setCandidate_count(1);
+ this.generationConfig.setMax_output_tokens(1000);
+ this.generationConfig.setTemperature(0.7);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java
new file mode 100644
index 0000000..c579fc4
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java
@@ -0,0 +1,24 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+
+@Getter
+@NoArgsConstructor
+public class GameCreateRequestDto {
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+
+
+ public GameCreateRequestDto(String title, String genre, String platform, String description) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java
new file mode 100644
index 0000000..cc09aa5
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java
@@ -0,0 +1,21 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import lombok.Getter;
+
+@Getter
+
+public class GameEnrollRequestCreateRequestDto {
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+
+
+ public GameEnrollRequestCreateRequestDto(String title, String genre, String platform, String description) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java
new file mode 100644
index 0000000..f65e42a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java
@@ -0,0 +1,23 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import lombok.Getter;
+
+@Getter
+
+public class GameEnrollRequestUpdateRequestDto {
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+ private Boolean isAccepted;
+
+
+ public GameEnrollRequestUpdateRequestDto(String title, String genre, String platform, String description, Boolean isAccepted) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ this.isAccepted = isAccepted;
+
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java
new file mode 100644
index 0000000..1919372
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java
@@ -0,0 +1,21 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class GameUpdateRequestDto {
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+
+
+ public GameUpdateRequestDto(String title, String genre, String platform, String description) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java
new file mode 100644
index 0000000..6036872
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java
@@ -0,0 +1,19 @@
+package com.example.gamemate.domain.game.dto.request;
+
+import com.example.gamemate.domain.user.entity.User;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class UserGamePreferenceRequestDto {
+
+ private User user;
+ private String preferredGenres;
+ private String playStyle;
+ private String playTime;
+ private String difficulty;
+ private String platform;
+ private String extraRequest;
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java
new file mode 100644
index 0000000..3dae15e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java
@@ -0,0 +1,56 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import lombok.*;
+
+import java.util.List;
+
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ChatResponseDto {
+
+ private List candidates;
+ private PromptFeedback promptFeedback;
+
+ @Getter
+ @Setter
+ public static class Candidate {
+ private Content content;
+ private String finishReason;
+ private int index;
+ private List safetyRatings;
+
+ }
+
+ @Getter
+ @Setter
+ @ToString
+ public static class Content {
+ private List parts;
+ private String role;
+
+ }
+
+ @Getter
+ @Setter
+ @ToString
+ public static class Parts {
+ private String text;
+
+ }
+
+ @Getter
+ @Setter
+ public static class SafetyRating {
+ private String category;
+ private String probability;
+ }
+
+ @Getter
+ @Setter
+ public static class PromptFeedback {
+ private List safetyRatings;
+
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java
new file mode 100644
index 0000000..02214b9
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java
@@ -0,0 +1,34 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.Game;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class GameCreateResponseDto {
+ private Long id;
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime modifiedAt;
+ private final String fileName;
+ private final String imageUrl;
+
+ public GameCreateResponseDto(Game game) {
+
+ this.id = game.getId();
+ this.title = game.getTitle();
+ this.genre = game.getGenre();
+ this.platform = game.getPlatform();
+ this.description = game.getDescription();
+ this.createdAt = game.getCreatedAt();
+ this.modifiedAt = game.getModifiedAt();
+ this.fileName = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFileName();
+ this.imageUrl = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFilePath();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java
new file mode 100644
index 0000000..eae671b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java
@@ -0,0 +1,35 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.GamaEnrollRequest;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class GameEnrollRequestResponseDto {
+ private final String message;
+ private Long id;
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+ private LocalDateTime createdAt;
+ private LocalDateTime modifiedAt;
+ private boolean isAccepted;
+ private Long userId;
+
+ public GameEnrollRequestResponseDto(GamaEnrollRequest gameEnrollRequest) {
+ // game 객체의 필드들을 이용해 DTO의 필드들을 초기화
+ this.message = "게임등록 요청이 완료되었습니다.";
+ this.id = gameEnrollRequest.getId();
+ this.title = gameEnrollRequest.getTitle();
+ this.genre = gameEnrollRequest.getGenre();
+ this.platform = gameEnrollRequest.getPlatform();
+ this.description = gameEnrollRequest.getDescription();
+ this.createdAt = gameEnrollRequest.getCreatedAt();
+ this.modifiedAt = gameEnrollRequest.getModifiedAt();
+ this.isAccepted = gameEnrollRequest.getIsAccepted();
+ this.userId = gameEnrollRequest.getUser().getId();
+
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java
new file mode 100644
index 0000000..15d342d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java
@@ -0,0 +1,46 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.Game;
+import com.example.gamemate.domain.review.entity.Review;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+public class GameFindAllResponseDto {
+ private final Long id;
+ private final String title;
+ private final String genre;
+ private final String platform;
+ private final Long reviewCount;
+ private final Double averageStar;
+ private final String fileName;
+ private final String imageUrl;
+
+ public GameFindAllResponseDto(Game game) {
+ // game 객체의 필드들을 이용해 DTO의 필드들을 초기화
+ this.id = game.getId();
+ this.title = game.getTitle();
+ this.genre = game.getGenre();
+ this.platform = game.getPlatform();
+ this.reviewCount = (long) game.getReviews().size();
+ this.averageStar = calculateAverageStar(game.getReviews());
+ this.fileName = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFileName();
+ this.imageUrl = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFilePath();
+ }
+
+ private Double calculateAverageStar(List reviews) {
+ if (reviews.isEmpty()) {
+ return 0.0;
+ }
+ double average = reviews.stream()
+ .mapToInt(Review::getStar)
+ .average()
+ .orElse(0.0);
+
+ // 소수점 둘째 자리에서 반올림
+ return Math.round(average * 10.0) / 10.0;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java
new file mode 100644
index 0000000..a8ab3a1
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java
@@ -0,0 +1,37 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.Game;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@JsonPropertyOrder({"id", "title", "genre", "platform", "description", "createdAt", "fileName", "imageUrl", "modifiedAt"})
+public class GameFindByIdResponseDto {
+ private final Long id;
+ private final String title;
+ private final String genre;
+ private final String platform;
+ private final String description;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime modifiedAt;
+ private final String fileName;
+ private final String imageUrl;
+// private final List reviews;
+
+ public GameFindByIdResponseDto(Game game) {
+ // game 객체의 필드들을 이용해 DTO의 필드들을 초기화
+ this.id = game.getId();
+ this.title = game.getTitle();
+ this.genre = game.getGenre();
+ this.platform = game.getPlatform();
+ this.description = game.getDescription();
+ this.createdAt = game.getCreatedAt();
+ this.modifiedAt = game.getModifiedAt();
+ this.fileName = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFileName();
+ this.imageUrl = game.getImages().isEmpty() ? null :
+ game.getImages().get(0).getFilePath();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java
new file mode 100644
index 0000000..36e5338
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java
@@ -0,0 +1,26 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.GameRecommendHistory;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class GameRecommendHistorysResponseDto {
+ private Long userId;
+ private String title;
+ private String description;
+ private Double metacriticScore;
+ //private Double matchingScore;
+ private String reasonForRecommendation;
+
+ public GameRecommendHistorysResponseDto(GameRecommendHistory gameRecommendHistory) {
+
+ this.userId = gameRecommendHistory.getUser().getId();
+ this.title = gameRecommendHistory.getTitle();
+ this.description = gameRecommendHistory.getDescription();
+ this.metacriticScore = gameRecommendHistory.getMetacriticScore();
+ //this.matchingScore = gameRecommendHistory.getMatchingScore();
+ this.reasonForRecommendation = gameRecommendHistory.getReasonForRecommendation();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java
new file mode 100644
index 0000000..4230c53
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java
@@ -0,0 +1,17 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class GameRecommendationResponseDto {
+ private String title;
+ private String description;
+ private Double metacriticScore;
+ //private Double matchingScore;
+ private String reasonForRecommendation;
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java
new file mode 100644
index 0000000..ed33b63
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java
@@ -0,0 +1,26 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.Game;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class GameUpdateResponseDto {
+ private Long id;
+ private String title;
+ private String genre;
+ private String platform;
+ private String description;
+ private final LocalDateTime modifiedAt;
+
+ public GameUpdateResponseDto(Game game) {
+ // game 객체의 필드들을 이용해 DTO의 필드들을 초기화
+ this.id = game.getId();
+ this.title = game.getTitle();
+ this.genre = game.getGenre();
+ this.platform = game.getPlatform();
+ this.description = game.getDescription();
+ this.modifiedAt = game.getModifiedAt();
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java
new file mode 100644
index 0000000..06228cb
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java
@@ -0,0 +1,31 @@
+package com.example.gamemate.domain.game.dto.response;
+
+import com.example.gamemate.domain.game.entity.UserGamePreference;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+@Slf4j
+@Getter
+public class UserGamePreferenceResponseDto {
+
+ private Long user;
+ private String preferredGenres;
+ private String playStyle;
+ private String playTime;
+ private String difficulty;
+ private String platform;
+ private List recommendations;
+
+ public UserGamePreferenceResponseDto(UserGamePreference userGamePreference,List recommendations) {
+ this.user = userGamePreference.getUser().getId();
+ this.preferredGenres = userGamePreference.getPreferredGenres();
+ this.playStyle = userGamePreference.getPlayStyle();
+ this.playTime = userGamePreference.getPlayTime();
+ this.difficulty = userGamePreference.getDifficulty();
+ this.platform = userGamePreference.getPlatform();
+ this.recommendations =recommendations;
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java b/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java
new file mode 100644
index 0000000..d353f51
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java
@@ -0,0 +1,57 @@
+package com.example.gamemate.domain.game.entity;
+
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@Table(name = "game_enroll_request")
+@NoArgsConstructor
+public class GamaEnrollRequest extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "title", nullable = false, length = 255, unique = false)
+ private String title;
+
+ @Column(name = "genre", length = 10)
+ private String genre;
+
+ @Column(name = "description", length = 255)
+ private String description;
+
+ @Column(name = "platform", length = 20)
+ private String platform;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Column(name = "is_accepted", columnDefinition = "BOOLEAN DEFAULT false")
+ private Boolean isAccepted = false;
+
+ public GamaEnrollRequest(String title, String genre, String platform, String description, User userId ) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ this.user = userId;
+
+ }
+
+ public void updateGameEnroll(String title, String genre, String platform, String description,Boolean isAccepted) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ this.isAccepted = isAccepted;
+ }
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/entity/Game.java b/src/main/java/com/example/gamemate/domain/game/entity/Game.java
new file mode 100644
index 0000000..a695b0e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/entity/Game.java
@@ -0,0 +1,67 @@
+package com.example.gamemate.domain.game.entity;
+
+import com.example.gamemate.domain.review.entity.Review;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Getter
+@Table(name = "game")
+@NoArgsConstructor
+public class Game extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "title", nullable = false, length = 255 ,unique = false)
+ private String title;
+
+ @Column(name = "genre", nullable = false, length = 10)
+ private String genre;
+
+ @Column(name = "description", nullable = false, length = 255)
+ private String description;
+
+ @Column(name = "platform", nullable = false, length = 20)
+ private String platform;
+
+ @OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List images = new ArrayList<>();
+
+ @OneToMany(mappedBy = "game", fetch = FetchType.LAZY)
+ private List reviews = new ArrayList<>();
+
+ public Game(String title, String genre, String platform, String description) {
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ }
+
+ public void updateGame(String title, String genre, String platform, String description){
+ this.title = title;
+ this.genre = genre;
+ this.platform = platform;
+ this.description = description;
+ }
+
+ public void addImage(GameImage gameImage) {
+ this.images.add(gameImage);
+ }
+
+ public void removeGameImage(GameImage gameImage) {
+ this.images.remove(gameImage);
+ }
+
+ public void clearImages() {
+ this.images.clear();
+ }
+
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java b/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java
new file mode 100644
index 0000000..4a5c0c5
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java
@@ -0,0 +1,41 @@
+package com.example.gamemate.domain.game.entity;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "game_image")
+public class GameImage {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "game_id")
+ private Game game;
+
+ @Column(name = "file_name", nullable = false)
+ private String fileName;
+
+ @Column(name = "file_type", nullable = false)
+ private String fileType;
+
+ @Column(name = "file_path", nullable = false)
+ private String filePath;
+
+ public GameImage(String fileName, String fileType, String filePath, Game game) {
+ this.fileName = fileName;
+ this.fileType = fileType;
+ this.filePath = filePath;
+ this.game = game;
+ if (game != null) {
+ game.getImages().add(this);
+ }
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java b/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java
new file mode 100644
index 0000000..d2985d5
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java
@@ -0,0 +1,51 @@
+package com.example.gamemate.domain.game.entity;
+
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "game_recommend_history")
+public class GameRecommendHistory extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Column(length = 255)
+ private String title;
+
+ @Column(length = 255)
+ private String description;
+
+ //private Double matchingScore;
+
+ @Column(length = 255)
+ private String reasonForRecommendation;
+
+ private Double metacriticScore;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "preferences_id")
+ private UserGamePreference userGamePreference;
+
+ public GameRecommendHistory(User user, String title, String description, String reasonForRecommendation, Double metacriticScore, UserGamePreference userGamePreference) {
+ this.user = user;
+ this.title = title;
+ this.description = description;
+ //this.matchingScore = matchingScore;
+ this.reasonForRecommendation = reasonForRecommendation;
+ this.metacriticScore = metacriticScore;
+ this.userGamePreference = userGamePreference;
+
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java b/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java
new file mode 100644
index 0000000..11526e7
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java
@@ -0,0 +1,51 @@
+package com.example.gamemate.domain.game.entity;
+
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "user_game_preference")
+public class UserGamePreference extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Column(length = 10)
+ private String preferredGenres;
+
+ @Column(length = 255)
+ private String playStyle;
+
+ @Column(length = 10)
+ private String playTime;
+
+ @Column(length = 10)
+ private String difficulty;
+
+ @Column(length = 20)
+ private String platform;
+
+ @Column(length = 255)
+ private String extraRequest;
+
+ public UserGamePreference(User user, String preferredGenres, String playStyle, String playTime, String difficulty, String platform, String extraRequest){
+ this.user =user;
+ this.preferredGenres = preferredGenres;
+ this.playStyle = playStyle;
+ this.playTime = playTime;
+ this.difficulty = difficulty;
+ this.platform = platform;
+ this.extraRequest =extraRequest;
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java
new file mode 100644
index 0000000..0e4ec8d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java
@@ -0,0 +1,11 @@
+package com.example.gamemate.domain.game.repository;
+
+import com.example.gamemate.domain.game.entity.GamaEnrollRequest;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface GameEnrollRequestRepository extends JpaRepository {
+
+ List findByIsAccepted(Boolean isAccepted);
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java
new file mode 100644
index 0000000..ae82e26
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java
@@ -0,0 +1,11 @@
+package com.example.gamemate.domain.game.repository;
+
+import com.example.gamemate.domain.game.entity.GameImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface GameImageRepository extends JpaRepository {
+ // 게임별 이미지 찾기
+ List findGameImagesByGameId(Long gameId);
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java
new file mode 100644
index 0000000..931904c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java
@@ -0,0 +1,10 @@
+package com.example.gamemate.domain.game.repository;
+
+import com.example.gamemate.domain.game.entity.GameRecommendHistory;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface GameRecommendHistoryRepository extends JpaRepository {
+ Page findByUserId(Long userId, Pageable pageable);
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java
new file mode 100644
index 0000000..79ffa82
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java
@@ -0,0 +1,30 @@
+package com.example.gamemate.domain.game.repository;
+
+import com.example.gamemate.domain.game.entity.Game;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Optional;
+
+public interface GameRepository extends JpaRepository {
+
+ // 아이디로 게임 찾기
+ Optional findGameById(Long id);
+
+ // 게임 검색
+ @Query("SELECT g FROM Game g WHERE " +
+ "(:keyword IS NULL OR " +
+ "LOWER(g.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
+ "LOWER(g.description) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
+ "AND (:genre IS NULL OR :genre = '' OR LOWER(g.genre) LIKE LOWER(CONCAT('%', :genre, '%'))) " +
+ "AND (:platform IS NULL OR :platform = '' OR LOWER(g.platform) LIKE LOWER(CONCAT('%', :platform, '%')))")
+ Page searchGames(
+ @Param("keyword") String keyword,
+ @Param("genre") String genre,
+ @Param("platform") String platform,
+ Pageable pageable
+ );
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java
new file mode 100644
index 0000000..d66a245
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java
@@ -0,0 +1,7 @@
+package com.example.gamemate.domain.game.repository;
+
+import com.example.gamemate.domain.game.entity.UserGamePreference;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserGamePreferenceRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java b/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java
new file mode 100644
index 0000000..3e2052c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java
@@ -0,0 +1,140 @@
+package com.example.gamemate.domain.game.service;
+
+import com.example.gamemate.domain.game.dto.request.GameEnrollRequestCreateRequestDto;
+import com.example.gamemate.domain.game.dto.response.GameEnrollRequestResponseDto;
+import com.example.gamemate.domain.game.dto.request.GameEnrollRequestUpdateRequestDto;
+
+import com.example.gamemate.domain.game.entity.GamaEnrollRequest;
+import com.example.gamemate.domain.game.entity.Game;
+import com.example.gamemate.domain.game.repository.GameEnrollRequestRepository;
+import com.example.gamemate.domain.game.repository.GameRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.Role;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class GameEnrollRequestService {
+ private final GameRepository gameRepository;
+ private final GameEnrollRequestRepository gameEnrollRequestRepository;
+
+ /**
+ * 새로운 게임 등록 요청을 생성합니다.
+ * @param requestDto 게임 등록 요청 정보를 담은 DTO
+ * @param userId 요청을 생성하는 사용자
+ * @return 생성된 게임 등록 요청 정보
+ */
+ @Transactional
+ public GameEnrollRequestResponseDto createGameEnrollRequest(GameEnrollRequestCreateRequestDto requestDto, User userId) {
+ GamaEnrollRequest gameEnrollRequest = new GamaEnrollRequest(
+ requestDto.getTitle(),
+ requestDto.getGenre(),
+ requestDto.getPlatform(),
+ requestDto.getDescription(),
+ userId
+ );
+ GamaEnrollRequest saveEnrollRequest = gameEnrollRequestRepository.save(gameEnrollRequest);
+ return new GameEnrollRequestResponseDto(saveEnrollRequest);
+ }
+
+ /**
+ * 모든 게임 등록 요청을 조회합니다. 관리자 권한이 필요합니다.
+ * @param loginUser 현재 로그인한 사용자
+ * @return 게임 등록 요청 목록 (페이지네이션 적용)
+ */
+ public Page findAllGameEnrollRequest(User loginUser) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ Pageable pageable = PageRequest.of(0, 10);
+
+ return gameEnrollRequestRepository.findAll(pageable).map(GameEnrollRequestResponseDto::new);
+ }
+
+ /**
+ * 특정 ID의 게임 등록 요청을 조회합니다. 관리자 권한이 필요합니다.
+ * @param id 조회할 게임 등록 요청의 ID
+ * @param loginUser 현재 로그인한 사용자
+ * @return 조회된 게임 등록 요청 정보
+ */
+ public GameEnrollRequestResponseDto findGameEnrollRequestById(Long id, User loginUser) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+
+ return new GameEnrollRequestResponseDto(gamaEnrollRequest);
+ }
+
+ /**
+ * 게임 등록 요청을 수정하고, 승인 시 게임을 등록합니다. 관리자 권한이 필요합니다.
+ * @param id 수정할 게임 등록 요청의 ID
+ * @param requestDto 수정할 게임 등록 요청 정보를 담은 DTO
+ * @param loginUser 현재 로그인한 사용자
+ */
+ @Transactional
+ public void updateGameEnroll(Long id, GameEnrollRequestUpdateRequestDto requestDto, User loginUser) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository
+ .findById(id).orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+
+ gamaEnrollRequest.updateGameEnroll(
+ requestDto.getTitle(),
+ requestDto.getGenre(),
+ requestDto.getPlatform(),
+ requestDto.getDescription(),
+ requestDto.getIsAccepted()
+ );
+
+ gameEnrollRequestRepository.save(gamaEnrollRequest);
+
+ // IsAccepted = true > 게임등록
+ Boolean accepted = requestDto.getIsAccepted();
+ if (accepted == true) {
+ Game game = new Game(
+ requestDto.getTitle(),
+ requestDto.getGenre(),
+ requestDto.getPlatform(),
+ requestDto.getDescription()
+ );
+ gameRepository.save(game);
+ }
+ }
+
+ /**
+ * 게임 등록 요청을 삭제합니다. 관리자 권한이 필요합니다.
+ * @param id 삭제할 게임 등록 요청의 ID
+ * @param loginUser 현재 로그인한 사용자
+ */
+ @Transactional
+ public void deleteGameEnroll(Long id, User loginUser) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository
+ .findById(id).orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+ gameEnrollRequestRepository.delete(gamaEnrollRequest);
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java b/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java
new file mode 100644
index 0000000..9dd2ac2
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java
@@ -0,0 +1,130 @@
+package com.example.gamemate.domain.game.service;
+
+import com.example.gamemate.domain.game.dto.response.GameRecommendHistorysResponseDto;
+import com.example.gamemate.domain.game.dto.response.GameRecommendationResponseDto;
+import com.example.gamemate.domain.game.dto.request.UserGamePreferenceRequestDto;
+import com.example.gamemate.domain.game.dto.response.UserGamePreferenceResponseDto;
+import com.example.gamemate.domain.game.entity.GameRecommendHistory;
+import com.example.gamemate.domain.game.entity.UserGamePreference;
+import com.example.gamemate.domain.game.repository.GameRecommendHistoryRepository;
+import com.example.gamemate.domain.game.repository.UserGamePreferenceRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GameRecommendService {
+ private final UserGamePreferenceRepository userGamePreferenceRepository;
+ private final UserRepository userRepository;
+ private final GameRecommendHistoryRepository gameRecommendHistoryRepository;
+ private final GeminiService geminiService;
+
+ /**
+ * 사용자의 게임 선호도를 기반으로 게임을 추천하고, 그 결과를 저장합니다.
+ * @param requestDto 사용자의 게임 선호도 정보를 담은 DTO
+ * @param loginUser 현재 로그인한 사용자
+ * @return 사용자의 게임 선호도와 추천된 게임 목록을 포함한 응답 DTO
+ */
+ @Transactional
+ public UserGamePreferenceResponseDto createUserGamePreference(UserGamePreferenceRequestDto requestDto, User loginUser) {
+
+ User user = userRepository.findById(loginUser.getId())
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ UserGamePreference userGamePreference = new UserGamePreference(
+ user,
+ requestDto.getPreferredGenres(),
+ requestDto.getPlayStyle(),
+ requestDto.getPlayTime(),
+ requestDto.getDifficulty(),
+ requestDto.getPlatform(),
+ requestDto.getExtraRequest()
+ );
+
+ UserGamePreference saveData = userGamePreferenceRepository.save(userGamePreference);
+
+ String prompt = String.format(
+ "나에게 맞는 게임 3개 추천해줘 선호하는 장르는 %s이고 플레이 스타일은 %s 정도고 플레이 타임은 %s 정도고 난이도는 %s 그리고 플랫폼은 %s이고 추가적인 요청은 %s 야 " +
+ "응답은 " +
+ "한글(영어)로 된 제목(title), " +
+ "간단한 내용(description)," +
+ "metacriticScore 점수(metacriticScore)," +
+ //"나와의 매칭점수(matchingScore)," +
+ "추천 이유(reasonForRecommendation)를 적어주고 " +
+ "응답은 순수 JSON 배열로 알려줘",
+ userGamePreference.getPreferredGenres(),
+ userGamePreference.getPlayStyle(),
+ userGamePreference.getPlayTime(),
+ userGamePreference.getDifficulty(),
+ userGamePreference.getPlatform(),
+ userGamePreference.getExtraRequest()
+ );
+
+ String recommendation = geminiService.getContents(prompt);
+ log.info(recommendation);
+
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ List gameRecommendations;
+ try {
+
+ String jsonArray = recommendation
+ .replaceAll("(?s)^.*?```json\\s*", "") // 시작 부분의 ```
+ .replaceAll("\\s*```\\s*$", "") // 끝 부분의 ```
+ .trim();
+
+ gameRecommendations = objectMapper.readValue(jsonArray, new TypeReference>() {
+ });
+ } catch (Exception e) {
+ throw new ApiException(ErrorCode.RECOMMENDATION_NOT_FOUND);
+ }
+
+ List gameRecommendHistories = new ArrayList<>();
+ for (GameRecommendationResponseDto responseDto : gameRecommendations) {
+ GameRecommendHistory history = new GameRecommendHistory(
+ loginUser,
+ responseDto.getTitle(),
+ responseDto.getDescription(),
+ //responseDto.getMatchingScore(),
+ responseDto.getReasonForRecommendation(),
+ responseDto.getMetacriticScore(),
+ saveData
+ );
+ gameRecommendHistories.add(history);
+ }
+
+ gameRecommendHistoryRepository.saveAll(gameRecommendHistories);
+
+ return new UserGamePreferenceResponseDto(saveData, gameRecommendations);
+ }
+
+ /**
+ * 로그인한 사용자의 게임 추천 기록을 페이지네이션하여 조회합니다.
+ * @param loginUser 현재 로그인한 사용자
+ * @return 사용자의 게임 추천 기록 목록 (페이지네이션 적용)
+ */
+ public Page getGameRecommendHistories( User loginUser) {
+
+ Pageable pageable = PageRequest.of(0, 15);
+ Page histories = gameRecommendHistoryRepository.findByUserId(loginUser.getId(), pageable);
+
+ return histories.map(GameRecommendHistorysResponseDto::new);
+
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameService.java b/src/main/java/com/example/gamemate/domain/game/service/GameService.java
new file mode 100644
index 0000000..5379fcf
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/service/GameService.java
@@ -0,0 +1,235 @@
+package com.example.gamemate.domain.game.service;
+
+import com.example.gamemate.domain.game.dto.request.GameCreateRequestDto;
+import com.example.gamemate.domain.game.dto.request.GameUpdateRequestDto;
+import com.example.gamemate.domain.game.dto.response.GameCreateResponseDto;
+import com.example.gamemate.domain.game.dto.response.GameFindAllResponseDto;
+import com.example.gamemate.domain.game.dto.response.GameFindByIdResponseDto;
+import com.example.gamemate.domain.game.entity.Game;
+import com.example.gamemate.domain.game.entity.GameImage;
+import com.example.gamemate.domain.game.repository.GameImageRepository;
+import com.example.gamemate.domain.game.repository.GameRepository;
+import com.example.gamemate.domain.review.repository.ReviewRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.Role;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import com.example.gamemate.global.s3.S3Service;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class GameService {
+ private final GameRepository gameRepository;
+ private final S3Service s3Service;
+
+ /**
+ * 새로운 게임을 생성합니다. 관리자 권한이 필요합니다.
+ * @param loginUser 로그인한 사용자 정보
+ * @param gameCreateRequestDto 게임 생성 요청 데이터
+ * @param file 게임 이미지 파일
+ * @return 생성된 게임 정보
+ */
+ @Transactional
+ public GameCreateResponseDto createGame(User loginUser, GameCreateRequestDto gameCreateRequestDto, MultipartFile file) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ Game game = new Game(
+ gameCreateRequestDto.getTitle(),
+ gameCreateRequestDto.getGenre(),
+ gameCreateRequestDto.getPlatform(),
+ gameCreateRequestDto.getDescription()
+ );
+
+ if (file != null && !file.isEmpty()) {
+ try {
+
+ String fileName = file.getOriginalFilename();
+ String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
+ List allowedExtensions = Arrays.asList("jpg", "jpeg");
+
+ if (!allowedExtensions.contains(fileExtension)) {
+ throw new ApiException(ErrorCode.INVALID_FILE_EXTENSION);
+ }
+
+ String fileUrl = s3Service.uploadFile(file);
+ GameImage gameImage = new GameImage(
+ file.getOriginalFilename(),
+ file.getContentType(),
+ fileUrl,
+ game
+ );
+
+ game.addImage(gameImage);
+ } catch (IOException e) {
+ throw new ApiException(ErrorCode.FILE_UPLOAD_ERROR);
+ }
+
+ }
+
+ Game savedGame = gameRepository.save(game);
+ return new GameCreateResponseDto(savedGame);
+ }
+
+ /**
+ * 모든 게임을 페이지네이션하여 조회합니다.
+ * @param page 페이지 번호
+ * @param size 페이지 크기
+ * @return 게임 목록
+ */
+ public Page findAllGame(int page, int size) {
+
+ Pageable pageable = PageRequest.of(page, size);
+ return gameRepository.findAll(pageable).map(GameFindAllResponseDto::new);
+ }
+
+ /**
+ * 특정 ID의 게임을 조회합니다.
+ * @param id 게임 ID
+ * @return 조회된 게임 정보
+ * @throws ApiException 게임을 찾을 수 없는 경우
+ */
+ public GameFindByIdResponseDto findGameById(Long id) {
+
+ Game game = gameRepository.findGameById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+
+ return new GameFindByIdResponseDto(game);
+ }
+
+ /**
+ * 게임 정보를 수정합니다. 관리자 권한이 필요합니다.
+ * @param id 수정할 게임의 ID
+ * @param requestDto 수정할 게임 정보
+ * @param newFile 새로운 게임 이미지 파일
+ * @param loginUser 로그인한 사용자 정보
+ */
+ @Transactional
+ public void updateGame(Long id, GameUpdateRequestDto requestDto, MultipartFile newFile, User loginUser) {
+
+ if (loginUser == null || !loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ Game game = gameRepository.findGameById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+
+ deleteExistingImages(game);
+ uploadNewImage(game, newFile);
+
+ game.updateGame(
+ requestDto.getTitle(),
+ requestDto.getGenre(),
+ requestDto.getPlatform(),
+ requestDto.getDescription()
+ );
+
+ gameRepository.save(game);
+ }
+
+ /**
+ * 기존 게임 이미지를 삭제합니다.
+ * @param game 이미지를 삭제할 게임
+ */
+ private void deleteExistingImages(Game game) {
+ for (GameImage image : game.getImages()) {
+ try {
+ s3Service.deleteFile(image.getFilePath());
+ } catch (Exception e) {
+ log.error("Failed to delete file: {}", image.getFilePath(), e);
+ }
+ }
+ game.getImages().clear();
+ }
+
+ /**
+ * 새로운 게임 이미지를 업로드합니다.
+ * @param game 이미지를 업로드할 게임
+ * @param newFile 새로운 이미지 파일
+ */
+ private void uploadNewImage(Game game, MultipartFile newFile) {
+ if (newFile != null && !newFile.isEmpty()) {
+ try {
+
+ String fileName = newFile.getOriginalFilename();
+ String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
+ List allowedExtensions = Arrays.asList("jpg", "jpeg");
+
+ if (!allowedExtensions.contains(fileExtension)) {
+ throw new ApiException(ErrorCode.INVALID_FILE_EXTENSION);
+ }
+
+ String fileUrl = s3Service.uploadFile(newFile);
+ GameImage gameImage = new GameImage(
+ newFile.getOriginalFilename(),
+ newFile.getContentType(),
+ fileUrl,
+ game
+ );
+ game.addImage(gameImage);
+ } catch (IOException e) {
+ throw new ApiException(ErrorCode.FILE_UPLOAD_ERROR);
+ }
+ }
+ }
+
+ /**
+ * 게임을 삭제합니다. 관리자 권한이 필요합니다.
+ * @param id 삭제할 게임의 ID
+ * @param loginUser 로그인한 사용자 정보
+ */
+ @Transactional
+ public void deleteGame(Long id, User loginUser) {
+
+ if (!loginUser.getRole().equals(Role.ADMIN)) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ Game game = gameRepository.findGameById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND));
+
+ if (!game.getImages().isEmpty()) {
+ for (GameImage image : game.getImages()) {
+ s3Service.deleteFile(image.getFilePath());
+ }
+ }
+
+ gameRepository.delete(game);
+ }
+
+ /**
+ * 키워드, 장르, 플랫폼으로 게임을 검색합니다.
+ * @param keyword 검색 키워드
+ * @param genre 게임 장르
+ * @param platform 게임 플랫폼
+ * @param page 페이지 번호
+ * @param size 페이지 크기
+ * @return 검색된 게임 목록
+ */
+ public Page searchGame(String keyword, String genre, String platform, int page, int size) {
+
+ log.info("Searching games with parameters - keyword: {}, genre: {}, platform: {}",
+ keyword, genre, platform);
+ Pageable pageable = PageRequest.of(page, size);
+ Page games = gameRepository.searchGames(keyword, genre, platform, pageable);
+ return games.map(GameFindAllResponseDto::new);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java b/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java
new file mode 100644
index 0000000..509c99a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java
@@ -0,0 +1,44 @@
+package com.example.gamemate.domain.game.service;
+
+import com.example.gamemate.domain.game.dto.request.ChatRequestDto;
+import com.example.gamemate.domain.game.dto.response.ChatResponseDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+@Service
+@RequiredArgsConstructor
+public class GeminiService {
+ @Qualifier("geminiRestTemplate")
+
+ @Autowired
+ private RestTemplate restTemplate;
+
+ @Value("${gemini.api.url}")
+ private String apiUrl;
+
+ @Value("${gemini.api.key}")
+ private String geminiApiKey;
+
+ /**
+ * Gemini API에 프롬프트를 전송하고 응답을 받아옵니다.
+ * @param prompt Gemini API에 전송할 프롬프트 문자열
+ * @return Gemini API로부터 받은 응답 메시지
+ */
+ public String getContents(String prompt) {
+
+
+ String requestUrl = apiUrl + "?key=" + geminiApiKey;
+
+ ChatRequestDto request = new ChatRequestDto(prompt);
+ ChatResponseDto response = restTemplate.postForObject(requestUrl, request, ChatResponseDto.class);
+
+ String message = response.getCandidates().get(0).getContent().getParts().get(0).getText().toString();
+
+ return message;
+
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java b/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java
new file mode 100644
index 0000000..7e751b0
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java
@@ -0,0 +1,94 @@
+package com.example.gamemate.domain.like.controller;
+
+import com.example.gamemate.domain.like.dto.request.LikeRequestDto;
+import com.example.gamemate.domain.like.dto.response.BoardLikeCountResponseDto;
+import com.example.gamemate.domain.like.dto.response.BoardLikeResponseDto;
+import com.example.gamemate.domain.like.dto.response.ReviewLikeCountResponseDto;
+import com.example.gamemate.domain.like.dto.response.ReviewLikeResponseDto;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import com.example.gamemate.domain.like.service.LikeService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 좋아요 기능을 처리하는 컨트롤러 클래스입니다.
+ * 리뷰와 게시판에 대한 좋아요 기능을 제공합니다.
+ */
+@RestController
+@RequestMapping("/likes")
+@RequiredArgsConstructor
+public class LikeController {
+
+ private final LikeService likeService;
+
+ /**
+ * 리뷰에 대한 좋아요를 처리합니다.
+ *
+ * @param reviewId 좋아요를 누를 리뷰의 ID
+ * @param status 좋아요 상태
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 좋아요 처리 결과를 담은 ResponseEntity
+ */
+ @PostMapping("/reviews/{reviewId}")
+ public ResponseEntity reviewLikeUp(
+ @PathVariable Long reviewId,
+ @RequestBody LikeRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ ReviewLikeResponseDto responseDto = likeService.reviewLikeUp(reviewId, requestDto.getStatus(), customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 게시글에 대한 좋아요를 처리합니다.
+ *
+ * @param boardId 좋아요를 누를 게시글의 ID
+ * @param status 좋아요 상태 (1: 좋아요, 0: 좋아요 취소, -1:싫어요)
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 좋아요 처리 결과를 담은 ResponseEntity
+ */
+ @PostMapping("/boards/{boardId}")
+ public ResponseEntity boardLikeUp(
+ @PathVariable Long boardId,
+ @RequestBody LikeRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ BoardLikeResponseDto responseDto = likeService.boardLikeUp(boardId, requestDto.getStatus(), customUserDetails.getUser());
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 특정 리뷰의 좋아요 수를 조회합니다.
+ *
+ * @param reviewId 조회할 리뷰의 ID
+ * @return 리뷰의 좋아요 수를 담은 ResponseEntity
+ */
+ @GetMapping("/reviews/{reviewId}")
+ public ResponseEntity reviewLikeCount(
+ @PathVariable Long reviewId) {
+
+ Long likeCount = likeService.getReivewLikeCount(reviewId);
+ ReviewLikeCountResponseDto responseDto = new ReviewLikeCountResponseDto(reviewId, likeCount);
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 특정 게시글의 좋아요 수를 조회합니다.
+ *
+ * @param boardId 조회할 게시글의 ID
+ * @return 게시글의 좋아요 수를 담은 ResponseEntity
+ */
+ @GetMapping("/boards/{boardId}")
+ public ResponseEntity boardLikeCount(
+ @PathVariable Long boardId) {
+
+ Long likeCount = likeService.getBoardLikeCount(boardId);
+ BoardLikeCountResponseDto responseDto = new BoardLikeCountResponseDto(boardId, likeCount);
+ return new ResponseEntity<>(responseDto, HttpStatus.OK);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java b/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java
new file mode 100644
index 0000000..d7f34a7
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.like.dto.request;
+
+import com.example.gamemate.domain.like.enums.LikeStatus;
+
+public class LikeRequestDto {
+ private final LikeStatus status;
+
+ public LikeRequestDto(LikeStatus status) {
+ this.status = status;
+ }
+
+ public LikeStatus getStatus() {
+ return status;
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java
new file mode 100644
index 0000000..1079ca9
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java
@@ -0,0 +1,15 @@
+package com.example.gamemate.domain.like.dto.response;
+
+import lombok.Getter;
+
+@Getter
+public class BoardLikeCountResponseDto {
+ private Long boardId;
+ private Long likeCount;
+
+ public BoardLikeCountResponseDto(Long boardId, Long likeCount){
+ this.boardId = boardId;
+ this.likeCount = likeCount;
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java
new file mode 100644
index 0000000..13dea47
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.like.dto.response;
+
+import com.example.gamemate.domain.like.entity.BoardLike;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import lombok.Getter;
+
+@Getter
+public class BoardLikeResponseDto {
+ private Long boardId;
+ private Long userId;
+ private LikeStatus status;
+
+
+ public BoardLikeResponseDto(BoardLike boardLike){
+ this.boardId = boardLike.getBoard().getId();
+ this.status = boardLike.getStatus();
+ this.userId = boardLike.getUser().getId();
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java
new file mode 100644
index 0000000..b2611e7
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java
@@ -0,0 +1,15 @@
+package com.example.gamemate.domain.like.dto.response;
+
+import lombok.Getter;
+
+@Getter
+public class ReviewLikeCountResponseDto {
+ private Long reviewId;
+ private Long likeCount;
+
+ public ReviewLikeCountResponseDto(Long reviewId, Long likeCount){
+ this.reviewId = reviewId;
+ this.likeCount = likeCount;
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java
new file mode 100644
index 0000000..366d449
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.like.dto.response;
+
+import com.example.gamemate.domain.like.entity.ReviewLike;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import lombok.Getter;
+
+@Getter
+public class ReviewLikeResponseDto {
+ private Long reviewId;
+ private Long userId;
+ private LikeStatus status;
+
+
+ public ReviewLikeResponseDto(ReviewLike reviewLike){
+ this.reviewId = reviewLike.getReview().getId();
+ this.status = reviewLike.getStatus();
+ this.userId = reviewLike.getUser().getId();
+ }
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java b/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java
new file mode 100644
index 0000000..58ad698
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java
@@ -0,0 +1,40 @@
+package com.example.gamemate.domain.like.entity;
+
+import com.example.gamemate.domain.board.entity.Board;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import com.example.gamemate.domain.user.entity.User;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class BoardLike {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private LikeStatus status;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "board_id")
+ private Board board;
+
+ public BoardLike(LikeStatus status, User user, Board board) {
+ this.status = status;
+ this.user = user;
+ this.board = board;
+ }
+
+ // 좋아요 상태 변경을 위한 메서드
+ public void changeStatus(LikeStatus status) {
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java b/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java
new file mode 100644
index 0000000..68902fa
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java
@@ -0,0 +1,40 @@
+package com.example.gamemate.domain.like.entity;
+
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import com.example.gamemate.domain.review.entity.Review;
+import com.example.gamemate.domain.user.entity.User;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ReviewLike {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private LikeStatus status;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "review_id")
+ private Review review;
+
+ public ReviewLike(LikeStatus status, User user, Review review) {
+ this.status = status;
+ this.user = user;
+ this.review = review;
+ }
+
+ // 좋아요 상태 변경을 위한 메서드
+ public void changeStatus(LikeStatus status) {
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java b/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java
new file mode 100644
index 0000000..8811fb8
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java
@@ -0,0 +1,34 @@
+package com.example.gamemate.domain.like.enums;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum LikeStatus {
+ LIKE("like"),
+ DISLIKE("disLike"),
+ NEUTRAL("neutral");
+
+ private final String value;
+
+ LikeStatus(String value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public String getValue() {
+ return value;
+ }
+
+ @JsonCreator
+ public static LikeStatus fromValue(String value) {
+ if (value == null) {
+ return null;
+ }
+ for (LikeStatus status : values()) {
+ if (status.value.equalsIgnoreCase(value)) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("Invalid LikeStatus value: " + value);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java b/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java
new file mode 100644
index 0000000..0cdf292
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java
@@ -0,0 +1,19 @@
+package com.example.gamemate.domain.like.repository;
+
+import com.example.gamemate.domain.like.entity.BoardLike;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Optional;
+
+public interface BoardLikeRepository extends JpaRepository {
+
+ @Query("SELECT bl FROM BoardLike bl WHERE bl.board.id = :id AND bl.user.id = :userId")
+ Optional findByBoardIdAndUserId(@Param("id") Long boardId, @Param("userId") Long userId);
+
+ Long countByBoardIdAndStatus(Long boardId, LikeStatus status);
+
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java b/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java
new file mode 100644
index 0000000..c4fbc82
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java
@@ -0,0 +1,16 @@
+package com.example.gamemate.domain.like.repository;
+
+import com.example.gamemate.domain.like.entity.ReviewLike;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+
+import java.util.Optional;
+
+public interface ReviewLikeRepository extends JpaRepository {
+
+ Optional findByReviewIdAndUserId(Long reviewId, Long userId);
+
+ Long countByReviewIdAndStatus(Long reviewId, LikeStatus status);
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/like/service/LikeService.java b/src/main/java/com/example/gamemate/domain/like/service/LikeService.java
new file mode 100644
index 0000000..b158d3f
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/like/service/LikeService.java
@@ -0,0 +1,95 @@
+package com.example.gamemate.domain.like.service;
+
+import com.example.gamemate.domain.board.repository.BoardRepository;
+import com.example.gamemate.domain.like.dto.response.BoardLikeResponseDto;
+import com.example.gamemate.domain.like.dto.response.ReviewLikeResponseDto;
+import com.example.gamemate.domain.like.entity.BoardLike;
+import com.example.gamemate.domain.like.entity.ReviewLike;
+import com.example.gamemate.domain.like.enums.LikeStatus;
+import com.example.gamemate.domain.like.repository.BoardLikeRepository;
+import com.example.gamemate.domain.like.repository.ReviewLikeRepository;
+import com.example.gamemate.domain.notification.enums.NotificationType;
+import com.example.gamemate.domain.notification.service.NotificationService;
+import com.example.gamemate.domain.review.repository.ReviewRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.eventListener.event.BoardLikeCreatedEvent;
+import com.example.gamemate.global.eventListener.event.ReviewLikeCreatedEvent;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class LikeService {
+ private final ReviewLikeRepository reviewLikeRepository;
+ private final UserRepository userRepository;
+ private final ReviewRepository reviewRepository;
+ private final BoardLikeRepository boardLikeRepository;
+ private final BoardRepository boardRepository;
+ private final ApplicationEventPublisher publisher;
+
+ //리뷰 좋아요 생성 취소 수정
+ @Transactional
+ public ReviewLikeResponseDto reviewLikeUp(Long reviewId, LikeStatus status, User loginUser) {
+
+ ReviewLike reviewLike = reviewLikeRepository.findByReviewIdAndUserId(reviewId, loginUser.getId()).
+ orElse(new ReviewLike(
+ status,
+ userRepository.findById(loginUser.getId())
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)),
+ reviewRepository.findById(reviewId)
+ .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND))
+ ));
+
+ if (reviewLike.getId() == null) {
+ reviewLikeRepository.save(reviewLike);
+ publisher.publishEvent(new ReviewLikeCreatedEvent(this, reviewLike));
+ } else {
+ reviewLike.changeStatus(status);
+ }
+
+ return new ReviewLikeResponseDto(reviewLike);
+ }
+
+ //게시물 좋아요 생성 취소 수정
+ @Transactional
+ public BoardLikeResponseDto boardLikeUp(Long boardId, LikeStatus status, User loginUser) {
+
+ BoardLike boardLike = boardLikeRepository.findByBoardIdAndUserId(boardId, loginUser.getId()).
+ orElse(new BoardLike(
+ status,
+ userRepository.findById(loginUser.getId())
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)),
+ boardRepository.findById(boardId)
+ .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND))
+ ));
+
+ if (boardLike.getId() == null) {
+ boardLikeRepository.save(boardLike);
+ publisher.publishEvent(new BoardLikeCreatedEvent(this, boardLike));
+ } else {
+ boardLike.changeStatus(status);
+ }
+
+ return new BoardLikeResponseDto(boardLike);
+ }
+
+ public Long getBoardLikeCount(Long boardId) {
+
+ boardRepository.findById(boardId)
+ .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND));
+ return boardLikeRepository.countByBoardIdAndStatus(boardId, LikeStatus.LIKE);
+ }
+
+ public Long getReivewLikeCount(Long reviewId) {
+
+ reviewRepository.findById(reviewId)
+ .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND));
+
+ return reviewLikeRepository.countByReviewIdAndStatus(reviewId, LikeStatus.LIKE);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java b/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java
new file mode 100644
index 0000000..fda01d6
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java
@@ -0,0 +1,206 @@
+package com.example.gamemate.domain.match.controller;
+
+import com.example.gamemate.domain.match.dto.*;
+import com.example.gamemate.domain.match.service.MatchService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 매칭 기능을 처리하는 컨트롤러 클래스입니다.
+ * 사용자 간의 매칭 기능을 제공합니다.
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/matches")
+public class MatchController {
+ private final MatchService matchService;
+
+ /**
+ * 사용자 간의 매칭 요청을 생성합니다.
+ *
+ * @param dto 매칭을 원하는 상대방 ID, 상대방에게 보낼 메세지를 포함합니다.
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 매칭 요청 처리 결과를 담은 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createMatch(
+ @Valid @RequestBody MatchCreateRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ MatchResponseDto matchResponseDto = matchService.createMatch(dto, customUserDetails.getUser());
+ return new ResponseEntity<>(matchResponseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 받은 매칭 요청의 수락/거절을 처리합니다.
+ *
+ * @param id 수락/거절할 매칭 요청 ID
+ * @param dto status (ACCEPTED 수락 / REJECTED 거절)
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT 성공했지만 반환값이 없음
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateMatch(
+ @PathVariable Long id,
+ @Valid @RequestBody MatchUpdateRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ matchService.updateMatch(id, dto, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+
+ /**
+ * 사용자가 받은 매칭 요청을 조회합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 사용자의 받은 매칭 요청 목록을 담은 ResponseEntity
+ */
+ @GetMapping("/received-matches")
+ public ResponseEntity> findAllReceivedMatch(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ List matchResponseDtoList = matchService.findAllReceivedMatch(customUserDetails.getUser());
+ return new ResponseEntity<>(matchResponseDtoList, HttpStatus.OK);
+ }
+
+ /**
+ * 사용자가 보낸 매칭 요청을 조회합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 사용자가 보낸 매칭 요청 목록을 담은 ResponseEntity
+ */
+ @GetMapping("/sent-matches")
+ public ResponseEntity> findAllSentMatch(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ List matchResponseDtoList = matchService.findAllSentMatch(customUserDetails.getUser());
+ return new ResponseEntity<>(matchResponseDtoList, HttpStatus.OK);
+ }
+
+ /**
+ * 사용자가 보낸 매칭 요청을 취소합니다.
+ *
+ * @param id 취소할 매칭 요청 ID
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT 성공했지만 반환값 없음
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteMatch(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ matchService.deleteMatch(id, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 매칭을 위한 정보를 입력합니다.
+ *
+ * @param dto 매칭을 위해 자신의 정보를 입력합니다.
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 사용자의 정보가 처리된 ResponseEntity
+ */
+ @PostMapping("/my-info")
+ public ResponseEntity createMyInfo(
+ @Valid @RequestBody MatchInfoCreateRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ MatchInfoResponseDto matchInfoResponseDto = matchService.createMyInfo(dto, customUserDetails.getUser());
+ return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 매칭을 위해 입력한 내 정보를 확인합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 내 정보를 담은 ResponseEntity
+ */
+ @GetMapping("/my-info")
+ public ResponseEntity findMyInfo(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ MatchInfoResponseDto matchInfoResponseDto = matchService.findMyInfo(customUserDetails.getUser());
+ return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 매칭 상대방의 입력한 정보를 확인합니다.
+ *
+ * @param id 확인할 매칭 요청 ID
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 매칭 요청 ID의 상대방이 입력한 정보를 담은 ResponseEntity
+ */
+ @GetMapping("/{id}/opponent-info")
+ public ResponseEntity findOpponentInfo(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ MatchInfoResponseDto matchInfoResponseDto = matchService.findOpponentInfo(id, customUserDetails.getUser());
+ return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.OK);
+ }
+
+ /**
+ * 입력한 내 정보를 수정합니다.
+ *
+ * @param dto 수정할 정보를 입력합니다.
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT 성공했지만 반환값 없음
+ */
+ @PutMapping("/my-info")
+ public ResponseEntity updateMyInfo(
+ @Valid @RequestBody MatchInfoUpdateRequestDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ matchService.updateMyInfo(dto, customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 내 정보 삭제, 내 정보 삭제시 더이상 매칭에서 검색되지 않습니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT 성공했지만 반환값 없음
+ */
+ @DeleteMapping("/my-info")
+ public ResponseEntity deleteMyInfo(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ matchService.deleteMyInfo(customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 매칭 추천 받기
+ *
+ * @param dto 원하는 매칭 조건을 설정합니다.
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 원하는 매칭 조건을 토대로 매칭 로직을 통해 가장 점수가 높은 5명을 추천해줍니다.
+ */
+ @PostMapping("/recommendations")
+ public ResponseEntity> findRecommendation(
+ @Valid @RequestBody MatchSearchConditionDto dto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ List recommendationList = matchService.findRecommendation(dto, customUserDetails.getUser());
+ return new ResponseEntity<>(recommendationList, HttpStatus.OK);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java
new file mode 100644
index 0000000..d2ddfa3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.match.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+
+@Getter
+public class MatchCreateRequestDto {
+ @NotNull
+ private Long userId;
+
+ @NotNull
+ @Size(max = 100, message = "메시지는 100자를 초과할 수 없습니다.")
+ private String message;
+
+ public MatchCreateRequestDto(Long userId, String message) {
+ this.userId = userId;
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java
new file mode 100644
index 0000000..ae5a816
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java
@@ -0,0 +1,62 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.enums.*;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+
+import java.util.Set;
+
+@Getter
+public class MatchInfoCreateRequestDto {
+ @NotNull(message = "성별은 필수 입력값입니다.")
+ private Gender gender;
+
+ @NotNull(message = "라인은 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.")
+ private Set lanes;
+
+ @NotNull(message = "목적은 필수 입력값입니다.")
+ @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.")
+ private Set purposes;
+
+ @NotNull(message = "게임 랭크는 필수 입력값입니다.")
+ private GameRank gameRank;
+
+ @NotNull(message = "플레이 시간대는 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.")
+ private Set playTimeRanges;
+
+ @NotNull(message = "스킬 레벨은 필수 입력값입니다.")
+ @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.")
+ @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.")
+ private Integer skillLevel;
+
+ @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.")
+ private Boolean micUsage;
+
+ @Size(max = 200, message = "메시지는 200자를 초과할 수 없습니다.")
+ private String message;
+
+ public MatchInfoCreateRequestDto(
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ GameRank gameRank,
+ Set playTimeRanges,
+ Integer skillLevel,
+ Boolean micUsage,
+ String message
+ ) {
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.gameRank = gameRank;
+ this.playTimeRanges = playTimeRanges;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java
new file mode 100644
index 0000000..0cafd6b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java
@@ -0,0 +1,60 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.entity.MatchUserInfo;
+import com.example.gamemate.domain.match.enums.*;
+import lombok.Getter;
+
+import java.util.Set;
+
+@Getter
+public class MatchInfoResponseDto {
+ private Long matchUserInfoId;
+ private String nickname;
+ private Gender gender;
+ private Set lanes;
+ private Set purposes;
+ private GameRank gameRank;
+ private Set playTimeRanges;
+ private Integer skillLevel;
+ private Boolean micUsage;
+ private String message;
+
+ public MatchInfoResponseDto(
+ Long matchUserInfoId,
+ String nickname,
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ GameRank gameRank,
+ Set playTimeRanges,
+ Integer skillLevel,
+ Boolean micUsage,
+ String message
+ ) {
+ this.matchUserInfoId = matchUserInfoId;
+ this.nickname = nickname;
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.gameRank = gameRank;
+ this.playTimeRanges = playTimeRanges;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.message = message;
+ }
+
+ public static MatchInfoResponseDto toDto(MatchUserInfo matchUserInfo) {
+ return new MatchInfoResponseDto(
+ matchUserInfo.getId(),
+ matchUserInfo.getUser().getNickname(),
+ matchUserInfo.getGender(),
+ matchUserInfo.getLanes(),
+ matchUserInfo.getPurposes(),
+ matchUserInfo.getGameRank(),
+ matchUserInfo.getPlayTimeRanges(),
+ matchUserInfo.getSkillLevel(),
+ matchUserInfo.getMicUsage(),
+ matchUserInfo.getMessage()
+ );
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java
new file mode 100644
index 0000000..51ad254
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java
@@ -0,0 +1,62 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.enums.*;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+
+import java.util.Set;
+
+@Getter
+public class MatchInfoUpdateRequestDto {
+ @NotNull(message = "성별은 필수 입력값입니다.")
+ private Gender gender;
+
+ @NotNull(message = "라인은 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.")
+ private Set lanes;
+
+ @NotNull(message = "목적은 필수 입력값입니다.")
+ @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.")
+ private Set purposes;
+
+ @NotNull(message = "게임 랭크는 필수 입력값입니다.")
+ private GameRank gameRank;
+
+ @NotNull(message = "플레이 시간대는 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.")
+ private Set playTimeRanges;
+
+ @NotNull(message = "스킬 레벨은 필수 입력값입니다.")
+ @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.")
+ @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.")
+ private Integer skillLevel;
+
+ @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.")
+ private Boolean micUsage;
+
+ @Size(max = 200, message = "메시지는 200자를 초과할 수 없습니다.")
+ private String message;
+
+ public MatchInfoUpdateRequestDto(
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ GameRank gameRank,
+ Set playTimeRanges,
+ Integer skillLevel,
+ Boolean micUsage,
+ String message
+ ) {
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.gameRank = gameRank;
+ this.playTimeRanges = playTimeRanges;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java
new file mode 100644
index 0000000..0b4e62e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java
@@ -0,0 +1,32 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.entity.Match;
+import com.example.gamemate.domain.match.enums.MatchStatus;
+import lombok.Getter;
+
+@Getter
+public class MatchResponseDto {
+ private Long id;
+ private MatchStatus status;
+ private String senderNickname;
+ private String receiverNickname;
+ private String message;
+
+ public MatchResponseDto(Long id, MatchStatus status, String senderNickname, String receiverNickname, String message) {
+ this.id = id;
+ this.status = status;
+ this.senderNickname = senderNickname;
+ this.receiverNickname = receiverNickname;
+ this.message = message;
+ }
+
+ public static MatchResponseDto toDto(Match match) {
+ return new MatchResponseDto(
+ match.getId(),
+ match.getStatus(),
+ match.getSender().getNickname(),
+ match.getReceiver().getNickname(),
+ match.getMessage()
+ );
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java
new file mode 100644
index 0000000..a82cf3b
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java
@@ -0,0 +1,61 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.enums.*;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+
+import java.util.Set;
+
+@Getter
+public class MatchSearchConditionDto {
+ @NotNull(message = "성별은 필수 입력값입니다.")
+ private Gender gender;
+
+ @NotNull(message = "라인은 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.")
+ private Set lanes;
+
+ @NotNull(message = "목적은 필수 입력값입니다.")
+ @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.")
+ private Set purposes;
+
+ @NotNull(message = "게임 랭크는 필수 입력값입니다.")
+ private GameRank gameRank;
+
+ @NotNull(message = "플레이 시간대는 필수 입력값입니다.")
+ @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.")
+ private Set playTimeRanges;
+
+ @NotNull(message = "스킬 레벨은 필수 입력값입니다.")
+ @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.")
+ @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.")
+ private Integer skillLevel;
+
+ @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.")
+ private Boolean micUsage;
+
+ private Priority priority;
+
+ public MatchSearchConditionDto(
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ GameRank gameRank,
+ Set playTimeRanges,
+ Integer skillLevel,
+ Boolean micUsage,
+ Priority priority
+ ) {
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.gameRank = gameRank;
+ this.playTimeRanges = playTimeRanges;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.priority = priority;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java
new file mode 100644
index 0000000..b03bd19
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java
@@ -0,0 +1,15 @@
+package com.example.gamemate.domain.match.dto;
+
+import com.example.gamemate.domain.match.enums.MatchStatus;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+
+@Getter
+public class MatchUpdateRequestDto {
+ @NotNull
+ private MatchStatus status;
+
+ public MatchUpdateRequestDto(MatchStatus status) {
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/entity/Match.java b/src/main/java/com/example/gamemate/domain/match/entity/Match.java
new file mode 100644
index 0000000..675629d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/entity/Match.java
@@ -0,0 +1,44 @@
+package com.example.gamemate.domain.match.entity;
+
+import com.example.gamemate.domain.match.enums.MatchStatus;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+
+@Getter
+@Entity
+@Table(name = "matches")
+public class Match extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ private MatchStatus status;
+
+ @Column
+ private String message;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "sender_id", nullable = false)
+ private User sender;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "receiver_id", nullable = false)
+ private User receiver;
+
+ public Match() {
+ }
+
+ public Match(String message, User sender, User receiver) {
+ this.status = MatchStatus.PENDING;
+ this.message = message;
+ this.sender = sender;
+ this.receiver = receiver;
+ }
+
+ public void updateStatus(MatchStatus status) {
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java b/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java
new file mode 100644
index 0000000..7ab5ce1
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java
@@ -0,0 +1,104 @@
+package com.example.gamemate.domain.match.entity;
+
+import com.example.gamemate.domain.match.enums.*;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Getter
+@Entity
+public class MatchUserInfo extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ private Gender gender;
+
+ @ElementCollection
+ @CollectionTable(name = "user_lanes")
+ @Enumerated(EnumType.STRING)
+ private Set lanes = new HashSet<>();
+
+ @ElementCollection
+ @CollectionTable(name = "user_purposes")
+ @Enumerated(EnumType.STRING)
+ private Set purposes = new HashSet<>();
+
+ @ElementCollection
+ @CollectionTable(name = "user_play_times")
+ @Enumerated(EnumType.STRING)
+ private Set playTimeRanges = new HashSet<>();
+
+ @Enumerated(EnumType.STRING)
+ private GameRank gameRank;
+
+ @Column
+ private Integer skillLevel;
+
+ @Column
+ private Boolean micUsage;
+
+ @Column
+ private String message;
+
+ @OneToOne
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Transient // DB에 저장하지 않고 런타임에만 사용
+ private int matchScore;
+
+ public MatchUserInfo() {
+ }
+
+ public MatchUserInfo(
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ Set playTimeRanges,
+ GameRank gameRank,
+ Integer skillLevel,
+ Boolean micUsage,
+ String message,
+ User user
+ ) {
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.playTimeRanges = playTimeRanges;
+ this.gameRank = gameRank;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.message = message;
+ this.user = user;
+ }
+
+ public void updateMatchUserInfo(
+ Gender gender,
+ Set lanes,
+ Set purposes,
+ Set playTimeRanges,
+ GameRank gameRank,
+ Integer skillLevel,
+ Boolean micUsage,
+ String message
+ ) {
+ this.gender = gender;
+ this.lanes = lanes;
+ this.purposes = purposes;
+ this.playTimeRanges = playTimeRanges;
+ this.gameRank = gameRank;
+ this.skillLevel = skillLevel;
+ this.micUsage = micUsage;
+ this.message = message;
+ }
+
+ public void updateMatchScore(int matchScore) {
+ this.matchScore = matchScore;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java b/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java
new file mode 100644
index 0000000..7739742
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java
@@ -0,0 +1,25 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum GameRank {
+ DONT_MIND("dont_mind", "상관없음"),
+ IRON("iron", "아이언"),
+ BRONZE("bronze", "브론즈"),
+ SILVER("silver", "실버"),
+ GOLD("gold", "골드"),
+ PLATINUM("platinum", "플래티넘"),
+ DIAMOND("diamond", "다이아"),
+ MASTER("master", "마스터"),
+ GRANDMASTER("grandmaster", "그랜드마스터"),
+ CHALLENGER("challenger", "챌린저");
+
+ private final String name;
+ private final String koreanName;
+
+ GameRank(String name, String koreanName) {
+ this.name = name;
+ this.koreanName = koreanName;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Gender.java b/src/main/java/com/example/gamemate/domain/match/enums/Gender.java
new file mode 100644
index 0000000..176d03a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/Gender.java
@@ -0,0 +1,15 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum Gender {
+ MALE("male"),
+ FEMALE("female");
+
+ private final String name;
+
+ Gender(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Lane.java b/src/main/java/com/example/gamemate/domain/match/enums/Lane.java
new file mode 100644
index 0000000..9151294
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/Lane.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum Lane {
+ TOP("top", "탑"),
+ JUNGLE("jungle", "정글"),
+ MID("mid","미드"),
+ BOTTOM_AD("bottom_ad", "원딜"),
+ BOTTOM_SUPPORTER("bottom_supporter", "서포터");
+
+ private final String name;
+ private final String koreanName;
+
+ Lane(String name, String koreanName) {
+ this.name = name;
+ this.koreanName = koreanName;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java b/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java
new file mode 100644
index 0000000..2cccc1f
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java
@@ -0,0 +1,13 @@
+package com.example.gamemate.domain.match.enums;
+
+public enum MatchStatus {
+ ACCEPTED("accepted"),
+ PENDING("pending"),
+ REJECTED("rejected");
+
+ private final String name;
+
+ MatchStatus(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java b/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java
new file mode 100644
index 0000000..14cab4e
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java
@@ -0,0 +1,19 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum PlayTimeRange {
+ ZERO_TO_SIX("zero_to_six", "0~6시"),
+ SIX_TO_TWELVE("six_to_twelve", "6~12시"),
+ TWELVE_TO_EIGHTEEN("twelve_to_eighteen", "12~18시"),
+ EIGHTEEN_TO_TWENTY_FOUR("eighteen_to_twenty_four", "18시~24시");
+
+ private final String name;
+ private final String koreanName;
+
+ PlayTimeRange(String name, String koreanName) {
+ this.name = name;
+ this.koreanName = koreanName;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Priority.java b/src/main/java/com/example/gamemate/domain/match/enums/Priority.java
new file mode 100644
index 0000000..35461f1
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/Priority.java
@@ -0,0 +1,20 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum Priority {
+ GAME_RANK("gameRank"),
+ GENDER("gender"),
+ LANES("lanes"),
+ PLAY_TIME_RANGES("playTimeRanges"),
+ PURPOSES("purposes"),
+ MIC_USAGE("micUsage"),
+ SKILL_LEVEL("skillLevel");
+
+ private final String name;
+
+ Priority(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java b/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java
new file mode 100644
index 0000000..6bc7d7c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java
@@ -0,0 +1,24 @@
+package com.example.gamemate.domain.match.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum Purpose {
+ JUST_FOR_FUN("just_for_fun", "즐겜"),
+ TRY_HARD("try_hard", "빡겜"),
+ RANK_GAME("rank_game", "랭겜"),
+ NORMAL_GAME("normal_game", "일반겜"),
+ TEAMWORK("teamwork", "팀워크"),
+ WANT_FRIEND("want_friend", "친구 구함"),
+ MENTORING("mentoring", "멘토/멘티 구함"),
+ DUO_PLAY("duo_play", "듀오할 사람 구함"),
+ BEGINNER_FRIENDLY("beginner_friendly","뉴비 구함");
+
+ private final String name;
+ private final String koreanName;
+
+ Purpose(String name, String koreanName) {
+ this.name = name;
+ this.koreanName = koreanName;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java b/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java
new file mode 100644
index 0000000..cc09ca7
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java
@@ -0,0 +1,18 @@
+package com.example.gamemate.domain.match.repository;
+
+import com.example.gamemate.domain.match.entity.Match;
+import com.example.gamemate.domain.match.enums.MatchStatus;
+import com.example.gamemate.domain.user.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface MatchRepository extends JpaRepository {
+
+ Boolean existsBySenderAndReceiverAndStatus(User sender, User receiver, MatchStatus status);
+ List findAllByReceiverId(Long receiverId);
+ List findAllBySenderId(Long senderId);
+
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java b/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java
new file mode 100644
index 0000000..13aceff
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java
@@ -0,0 +1,36 @@
+package com.example.gamemate.domain.match.repository;
+
+import com.example.gamemate.domain.match.entity.MatchUserInfo;
+import com.example.gamemate.domain.match.enums.Gender;
+import com.example.gamemate.domain.match.enums.PlayTimeRange;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.UserStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Repository
+public interface MatchUserInfoRepository extends JpaRepository {
+ Optional findByUser(User user);
+ Boolean existsByUser(User user);
+
+ @Query("SELECT m FROM MatchUserInfo m " +
+ "WHERE m.gender = :gender " +
+ "AND EXISTS (SELECT pt FROM m.playTimeRanges pt WHERE pt IN :playTimeRanges) " +
+ "AND m.user.id <> :userId " +
+ "AND m.user.modifiedAt >= :sevenDaysAgo " +
+ "AND m.user.userStatus = :userStatus")
+ List findByGenderAndPlayTimeRanges(
+ @Param("gender") Gender gender,
+ @Param("playTimeRanges") Set playTimeRanges,
+ @Param("userId") Long userId,
+ @Param("sevenDaysAgo") LocalDateTime sevenDaysAgo,
+ @Param("userStatus") UserStatus userStatus
+ );
+}
diff --git a/src/main/java/com/example/gamemate/domain/match/service/MatchService.java b/src/main/java/com/example/gamemate/domain/match/service/MatchService.java
new file mode 100644
index 0000000..cee26bf
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/match/service/MatchService.java
@@ -0,0 +1,448 @@
+package com.example.gamemate.domain.match.service;
+
+import com.example.gamemate.domain.match.dto.*;
+import com.example.gamemate.domain.match.entity.Match;
+import com.example.gamemate.domain.match.entity.MatchUserInfo;
+import com.example.gamemate.domain.match.enums.GameRank;
+import com.example.gamemate.domain.match.enums.MatchStatus;
+import com.example.gamemate.domain.match.enums.Priority;
+import com.example.gamemate.domain.match.repository.MatchRepository;
+import com.example.gamemate.domain.match.repository.MatchUserInfoRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.domain.user.enums.UserStatus;
+import com.example.gamemate.domain.user.repository.UserRepository;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.eventListener.event.MatchAcceptedEvent;
+import com.example.gamemate.global.eventListener.event.MatchCreatedEvent;
+import com.example.gamemate.global.eventListener.event.MatchRejectedEvent;
+import com.example.gamemate.global.exception.ApiException;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static com.example.gamemate.domain.match.enums.Priority.*;
+
+/**
+ * 매칭 기능을 처리하는 서비스 클래스입니다.
+ */
+@Service
+@RequiredArgsConstructor
+public class MatchService {
+
+ private final UserRepository userRepository;
+ private final MatchRepository matchRepository;
+ private final MatchUserInfoRepository matchUserInfoRepository;
+ private final ApplicationEventPublisher publisher;
+
+ /**
+ * 사용자 간의 매칭 요청을 생성합니다.
+ * @param dto 매칭을 원하는 상대방 ID, 상대방에게 보낼 메세지를 포함합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 매칭 요청 처리 결과를 담은 MatchResponseDto
+ */
+ @Transactional
+ public MatchResponseDto createMatch(MatchCreateRequestDto dto, User loginUser) {
+
+ User receiver = userRepository.findById(dto.getUserId())
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+
+ if (!matchUserInfoRepository.existsByUser(receiver)) {
+ throw new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND);
+ } // 받는 사람의 매칭 유저 정보가 없을때 예외처리
+
+ if (!matchUserInfoRepository.existsByUser(loginUser)) {
+ throw new ApiException(ErrorCode.MATCH_USER_INFO_NOT_WRITTEN);
+ } // 로그인 한 유저의 매칭 유저 정보가 없을때 예외처리
+
+ if (receiver.getUserStatus() == UserStatus.WITHDRAW) {
+ throw new ApiException(ErrorCode.IS_WITHDRAWN_USER);
+ } // 받는 사람의 유저 상태가 탈퇴 상태일때 예외처리
+
+ if (matchRepository.existsBySenderAndReceiverAndStatus(loginUser, receiver, MatchStatus.PENDING)) {
+ throw new ApiException(ErrorCode.IS_ALREADY_PENDING);
+ } // 이미 보낸 요청이 있을때 예외처리
+
+ Match match = new Match(dto.getMessage(), loginUser, receiver);
+ Match savedMatch = matchRepository.save(match);
+ publisher.publishEvent(new MatchCreatedEvent(this, savedMatch));
+
+ return MatchResponseDto.toDto(match);
+ }
+
+ /**
+ * 받은 매칭 요청의 수락/거절을 처리합니다.
+ * @param id 수락/거절할 매칭 요청 ID
+ * @param dto status (ACCEPTED 수락 / REJECTED 거절)
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void updateMatch(Long id, MatchUpdateRequestDto dto, User loginUser) {
+
+ Match findMatch = matchRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND));
+
+ if (findMatch.getStatus() != MatchStatus.PENDING) {
+ throw new ApiException(ErrorCode.IS_ALREADY_PROCESSED);
+ } // 매칭의 상태가 보류중이 아닐때 예외처리
+
+ if (!Objects.equals(loginUser.getId(), findMatch.getReceiver().getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ } // 로그인한 유저가 매칭의 받는 사람이 아닐때 예외처리
+
+ if (dto.getStatus() == MatchStatus.ACCEPTED) {
+ publisher.publishEvent(new MatchAcceptedEvent(this, findMatch));
+ } // 매칭 보낸 사람에게 매칭이 수락되었다는 알림 전송
+
+ if (dto.getStatus() == MatchStatus.REJECTED) {
+ publisher.publishEvent(new MatchRejectedEvent(this, findMatch));
+ } // 매칭 보낸 사람에게 매칭이 거절되었다는 알림 전송
+
+ findMatch.updateStatus(dto.getStatus());
+ }
+
+ /**
+ * 사용자가 받은 매칭 요청을 조회합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 사용자의 받은 매칭 요청 목록을 담은 List
+ */
+ public List findAllReceivedMatch(User loginUser) {
+
+ List matchList = matchRepository.findAllByReceiverId(loginUser.getId());
+
+ return matchList.stream()
+ .map(MatchResponseDto::toDto)
+ .toList();
+ }
+
+ /**
+ * 사용자가 보낸 매칭 요청을 조회합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 사용자가 보낸 매칭 요청 목록을 담은 List
+ */
+ public List findAllSentMatch(User loginUser) {
+
+ List matchList = matchRepository.findAllBySenderId(loginUser.getId());
+
+ return matchList.stream()
+ .map(MatchResponseDto::toDto)
+ .toList();
+ }
+
+ /**
+ * 사용자가 보낸 매칭 요청을 취소합니다.
+ * @param id 취소할 매칭 요청 ID
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void deleteMatch(Long id, User loginUser) {
+
+ Match findMatch = matchRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND));
+
+ if (!Objects.equals(findMatch.getSender().getId(), loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ } // 로그인한 유저가 매칭의 보낸사람이 아닐때 예외처리
+
+ matchRepository.delete(findMatch);
+ }
+
+ /**
+ * 매칭을 위한 정보를 입력합니다.
+ * @param dto 매칭을 위해 자신의 정보를 입력합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 사용자의 정보가 처리된 MatchInfoResponseDto
+ */
+ @Transactional
+ public MatchInfoResponseDto createMyInfo(MatchInfoCreateRequestDto dto, User loginUser) {
+
+ MatchUserInfo matchUserInfo = new MatchUserInfo(
+ dto.getGender(),
+ dto.getLanes(),
+ dto.getPurposes(),
+ dto.getPlayTimeRanges(),
+ dto.getGameRank(),
+ dto.getSkillLevel(),
+ dto.getMicUsage(),
+ dto.getMessage(),
+ loginUser
+ );
+
+ matchUserInfoRepository.save(matchUserInfo);
+
+ return MatchInfoResponseDto.toDto(matchUserInfo);
+ }
+
+ /**
+ * 매칭을 위해 입력한 내 정보를 확인합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 내 정보를 담은 MatchInfoResponseDto
+ */
+ public MatchInfoResponseDto findMyInfo(User loginUser) {
+ MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND));
+
+ return MatchInfoResponseDto.toDto(matchUserInfo);
+ }
+
+ /**
+ * 매칭 상대방의 입력한 정보를 확인합니다.
+ * @param id 확인할 매칭 요청 ID
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 매칭 요청 ID의 상대방이 입력한 정보를 담은 MatchInfoResponseDto
+ */
+ public MatchInfoResponseDto findOpponentInfo(Long id, User loginUser) {
+ Match findMatch = matchRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND));
+
+ if (!Objects.equals(findMatch.getReceiver().getId(), loginUser.getId())
+ && !Objects.equals(findMatch.getSender().getId(), loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ } // 매칭의 상대방을 검색할때, 로그인한 유저가 검색할 매칭과 연관이 없을때 예외처리
+
+ return (Objects.equals(findMatch.getReceiver().getId(), loginUser.getId()))
+ ? getMatchInfoResponseDto(findMatch.getSender())
+ : getMatchInfoResponseDto(findMatch.getReceiver());
+ }
+
+ /**
+ * 입력한 내 정보를 수정합니다.
+ * @param dto 수정할 정보를 입력합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void updateMyInfo(MatchInfoUpdateRequestDto dto, User loginUser) {
+ MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND));
+
+ matchUserInfo.updateMatchUserInfo(
+ dto.getGender(),
+ dto.getLanes(),
+ dto.getPurposes(),
+ dto.getPlayTimeRanges(),
+ dto.getGameRank(),
+ dto.getSkillLevel(),
+ dto.getMicUsage(),
+ dto.getMessage()
+ );
+ }
+
+ /**
+ * 내 정보 삭제, 내 정보 삭제시 더이상 매칭에서 검색되지 않습니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void deleteMyInfo(User loginUser) {
+ MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser)
+ .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND));
+
+ matchUserInfoRepository.delete(matchUserInfo);
+ }
+
+ /**
+ * 사용자간의 연결을 위한 매칭 로직입니다.
+ * @param dto MatchSearchConditionDto 검색할 상대방의 조건을 입력합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 입력한 조건과 매칭로직을 통해 점수를 매겨 상위 5명의 정보를 보여줍니다.
+ */
+ public List findRecommendation(MatchSearchConditionDto dto, User loginUser) {
+ // 1. 성별과 플레이 시간대 및 최근 로그인날짜가 7일이내, 유저상태가 ACTIVE 인 필터링된 사용자 정보 조회
+ LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7);
+
+ List filteredUsers = matchUserInfoRepository.findByGenderAndPlayTimeRanges(
+ dto.getGender(),
+ dto.getPlayTimeRanges(),
+ loginUser.getId(),
+ sevenDaysAgo,
+ UserStatus.ACTIVE
+ );
+
+ // 2. 매칭 점수 계산 및 저장
+ for (MatchUserInfo matchUserInfo : filteredUsers) {
+ int score = calculateMatchScore(dto, matchUserInfo);
+ matchUserInfo.updateMatchScore(score);
+ }
+
+ // 3. 매칭 점수 내림차순으로 정렬
+ filteredUsers.sort((u1, u2) -> Integer.compare(u2.getMatchScore(), u1.getMatchScore()));
+
+
+ // 4. 동점자 처리 (동점자끼리 랜덤 섞기)
+ List resultList = handleTies(filteredUsers);
+
+ // 5. 상위 5명 추출 및 DTO 변환
+ return resultList.stream()
+ .limit(5)
+ .map(MatchInfoResponseDto::toDto)
+ .collect(Collectors.toList());
+ }
+
+
+ /**
+ * 매칭의 점수 계산 로직입니다.
+ * @param condition 사용자가 입력한 원하는 상대의 조건입니다.
+ * @param userInfo 매칭에 추천될 사람들의 정보입니다.
+ * @return 점수계산 로직을 통해 나온 점수
+ */
+ private int calculateMatchScore(MatchSearchConditionDto condition, MatchUserInfo userInfo) {
+ int score = 0;
+ int normalScorePerMatch = 5; // 매칭되는 항목당 점수
+ int priorityWeight = 2; // 우선순위 가중치
+
+ Priority priority = condition.getPriority();
+
+ // 우선순위 항목 점수 계산 및 가중치 적용
+ if (priority != null) {
+ switch (priority) {
+ case LANES:
+ int matchedLanes = (int) condition.getLanes().stream()
+ .filter(userInfo.getLanes()::contains)
+ .count();
+ score += matchedLanes * normalScorePerMatch * priorityWeight;
+ break;
+ case PURPOSES:
+ int matchedPurposes = (int) condition.getPurposes().stream()
+ .filter(userInfo.getPurposes()::contains)
+ .count();
+ score += matchedPurposes * normalScorePerMatch * priorityWeight;
+ break;
+ case PLAY_TIME_RANGES:
+ int matchedPlayTimeRanges = (int) condition.getPlayTimeRanges().stream()
+ .filter(userInfo.getPlayTimeRanges()::contains)
+ .count();
+ score += matchedPlayTimeRanges * normalScorePerMatch * priorityWeight;
+ break;
+ case GAME_RANK:
+ if (condition.getGameRank().equals(userInfo.getGameRank())) {
+ score += normalScorePerMatch * priorityWeight * 2;
+ } else if (isRankSimilar(condition.getGameRank(), userInfo.getGameRank())) {
+ score += normalScorePerMatch * priorityWeight;
+ }
+ break;
+ case SKILL_LEVEL:
+ int skillLevelDifference = Math.abs(condition.getSkillLevel() - userInfo.getSkillLevel());
+ score += (normalScorePerMatch - skillLevelDifference) * priorityWeight;
+ break;
+ case MIC_USAGE:
+ if (condition.getMicUsage().equals(userInfo.getMicUsage())) {
+ score += normalScorePerMatch * priorityWeight * 2;
+ }
+ break;
+ }
+ }
+
+ // 우선순위가 아닌 조건의 점수 계산 방식
+ if (priority == null || !priority.equals(LANES)) {
+ int matchedLanes = (int) condition.getLanes().stream()
+ .filter(userInfo.getLanes()::contains)
+ .count();
+ score += matchedLanes * normalScorePerMatch;
+ }
+
+ if (priority == null || !priority.equals(PURPOSES)) {
+ int matchedPurposes = (int) condition.getPurposes().stream()
+ .filter(userInfo.getPurposes()::contains)
+ .count();
+ score += matchedPurposes * normalScorePerMatch;
+ }
+
+ if (priority == null || !priority.equals(PLAY_TIME_RANGES)) {
+ int matchedPlayTimeRanges = (int) condition.getPlayTimeRanges().stream()
+ .filter(userInfo.getPlayTimeRanges()::contains)
+ .count();
+ score += matchedPlayTimeRanges * normalScorePerMatch;
+ }
+
+ if (priority == null || !priority.equals(GAME_RANK)) {
+ if (condition.getGameRank().equals(userInfo.getGameRank())) {
+ score += normalScorePerMatch * 2;
+ } else if (isRankSimilar(condition.getGameRank(), userInfo.getGameRank())) {
+ score += normalScorePerMatch;
+ }
+ }
+
+ if (priority == null || !priority.equals(SKILL_LEVEL)) {
+ int skillLevelDifference = Math.abs(condition.getSkillLevel() - userInfo.getSkillLevel());
+ score += (normalScorePerMatch - skillLevelDifference);
+ }
+
+ if (priority == null || !priority.equals(MIC_USAGE)) {
+ if (condition.getMicUsage().equals(userInfo.getMicUsage())) {
+ score += normalScorePerMatch * 2;
+ }
+ }
+
+ return score;
+ }
+
+
+ /**
+ * 매칭 조건 중 랭크 조건에 비슷한 랭크인지 판단하는 메서드입니다.
+ * @param conditionRank 사용자가 입력한 원하는 랭크입니다.
+ * @param userRank 매칭로직에서 검사될 상대방들의 랭크입니다.
+ * @return 유사하다면 true, 아니면 false
+ */
+ private boolean isRankSimilar(GameRank conditionRank, GameRank userRank) {
+ if (conditionRank == GameRank.DONT_MIND) {
+ return true; // "상관없음"은 모든 랭크와 유사하다고 판단
+ }
+
+ int conditionRankIndex = conditionRank.ordinal();
+ int userRankIndex = userRank.ordinal();
+ return Math.abs(conditionRankIndex - userRankIndex) <= 1; // 랭크 차이가 1 이하면 유사하다고 판단
+ }
+
+
+ /**
+ * 매칭 로직을 통해 나온 동점자들을 랜덤하게 섞어서 출력합니다.
+ * @param sortedUsers 매칭 점수 로직을 통해 나온 사용자들입니다.
+ * @return 동점자들을 랜덤하게 섞어서 출력된 정보입니다.
+ */
+ private List handleTies(List sortedUsers) {
+ if (sortedUsers.isEmpty()) {
+ return sortedUsers;
+ }
+ List resultList = new ArrayList<>();
+ List tieGroup = new ArrayList<>(); // 동점자 그룹 임시 저장
+
+ tieGroup.add(sortedUsers.get(0));
+
+ for (int i = 1; i < sortedUsers.size(); i++) {
+ MatchUserInfo currentUser = sortedUsers.get(i);
+ MatchUserInfo previousUser = sortedUsers.get(i - 1);
+
+ if (currentUser.getMatchScore() == previousUser.getMatchScore()) {
+ tieGroup.add(currentUser);
+ } else {
+ Collections.shuffle(tieGroup); // 동점자 그룹 섞기
+ resultList.addAll(tieGroup);
+ tieGroup.clear(); // 다음 그룹을 위해 비우기
+ tieGroup.add(currentUser); //새로운 그룹 시작
+ }
+ }
+
+ Collections.shuffle(tieGroup);
+ resultList.addAll(tieGroup); //마지막 그룹 추가
+
+ return resultList;
+ }
+
+ /**
+ * 매칭의 상대방정보를 dto 로 변환합니다.
+ * @param user dto 로 변환할 상대방 사용자입니다.
+ * @return MatchInfoResponseDto
+ */
+ private MatchInfoResponseDto getMatchInfoResponseDto(User user) {
+ MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(user)
+ .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));
+ return MatchInfoResponseDto.toDto(matchUserInfo);
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java b/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java
new file mode 100644
index 0000000..721367d
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java
@@ -0,0 +1,86 @@
+package com.example.gamemate.domain.notification.controller;
+
+import com.example.gamemate.domain.notification.dto.NotificationResponseDto;
+import com.example.gamemate.domain.notification.service.NotificationService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+
+/**
+ * 알림을 처리하는 컨트롤러 클래스입니다.
+ */
+@RestController
+@RequestMapping("/notifications")
+@RequiredArgsConstructor
+public class NotificationController {
+ private final NotificationService notificationService;
+
+ /**
+ * 로그인한 사용자를 SSE 에 연결합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 로그인한 사용자의 SseEmitter 를 담은 ResponseEntity
+ */
+ @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public ResponseEntity connect(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ SseEmitter sseEmitter = notificationService.subscribe(customUserDetails.getUser());
+ return new ResponseEntity<>(sseEmitter, HttpStatus.OK);
+ }
+
+ /**
+ * 로그인한 사용자의 전체 알림을 조회합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 로그인한 사용자의 전체 알림을 담은 ResponseEntity
+ */
+ @GetMapping
+ public ResponseEntity> findAllNotification(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ List notificationResponseDtoList = notificationService.findAllNotification(customUserDetails.getUser());
+ return new ResponseEntity<>(notificationResponseDtoList, HttpStatus.OK);
+ }
+
+ /**
+ * 단일 알림의 읽음 상태를 처리합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @param id 읽음 상태를 처리할 알림 id
+ * @return 204 NO_CONTENT
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity readNotification(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails,
+ @PathVariable Long id
+ ) {
+
+ notificationService.readNotification(customUserDetails.getUser(), id);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 사용자의 모든 읽지않은 알림을 읽음 처리합니다.
+ *
+ * @param customUserDetails 현재 인증된 사용자 정보
+ * @return 204 NO_CONTENT
+ */
+ @PatchMapping
+ public ResponseEntity readAllNotification(
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ) {
+
+ notificationService.readAllNotification(customUserDetails.getUser());
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java b/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java
new file mode 100644
index 0000000..6e1ef0a
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java
@@ -0,0 +1,26 @@
+package com.example.gamemate.domain.notification.dto;
+
+import com.example.gamemate.domain.notification.entity.Notification;
+import com.example.gamemate.domain.notification.enums.NotificationType;
+import lombok.Getter;
+
+@Getter
+public class NotificationResponseDto {
+ private Long id;
+ private String content;
+ private NotificationType type;
+ private String relatedUrl;
+ private Long receiverId;
+
+ public NotificationResponseDto(Long id, String content, NotificationType type, String relatedUrl, Long receiverId) {
+ this.id = id;
+ this.content = content;
+ this.type = type;
+ this.relatedUrl = relatedUrl;
+ this.receiverId = receiverId;
+ }
+
+ public static NotificationResponseDto toDto(Notification notification) {
+ return new NotificationResponseDto(notification.getId(), notification.getContent(), notification.getType(), notification.getRelatedUrl(), notification.getReceiver().getId());
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java b/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java
new file mode 100644
index 0000000..a090951
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java
@@ -0,0 +1,47 @@
+package com.example.gamemate.domain.notification.entity;
+
+import com.example.gamemate.domain.notification.enums.NotificationType;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseCreatedEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+
+@Entity
+@Getter
+public class Notification extends BaseCreatedEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column
+ private String content;
+
+ @Column
+ private String relatedUrl;
+
+ @Enumerated(EnumType.STRING)
+ @Column
+ private NotificationType type;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "receiver_id")
+ private User receiver;
+
+ @Column
+ private boolean isRead;
+
+ public Notification() {
+ }
+
+ public Notification(String content, String relatedUrl, NotificationType type, User receiver) {
+ this.content = content;
+ this.relatedUrl = relatedUrl;
+ this.type = type;
+ this.receiver = receiver;
+ this.isRead = false;
+ }
+
+ public void updateIsRead(boolean isRead) {
+ this.isRead = isRead;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java b/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java
new file mode 100644
index 0000000..00d64ff
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java
@@ -0,0 +1,21 @@
+package com.example.gamemate.domain.notification.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum NotificationType {
+ NEW_FOLLOWER("follow", "새 팔로워가 생겼습니다."),
+ NEW_COMMENT("comment", "새로운 댓글이 달렸습니다."),
+ NEW_MATCH("new_match", "새로운 매칭 요청이 왔습니다."),
+ MATCH_REJECTED("match_rejected", "보낸 매칭이 거절되었습니다."),
+ MATCH_ACCEPTED("match_accepted", "보낸 매칭이 수락되었습니다."),
+ NEW_LIKE("like", "새 좋아요가 달렸습니다.");
+
+ private final String name;
+ private final String content;
+
+ NotificationType(String name, String content) {
+ this.name = name;
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java b/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java
new file mode 100644
index 0000000..5b5664c
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java
@@ -0,0 +1,48 @@
+package com.example.gamemate.domain.notification.repository;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Repository
+@Slf4j
+public class EmitterRepository {
+ // ConcurrentHashMap을 사용하여 thread-safe하게 관리
+ private final Map emitters = new ConcurrentHashMap<>();
+
+ public SseEmitter save(Long userId, SseEmitter emitter) {
+ emitters.put(userId, emitter);
+ log.info("EmitterRepository - userId: {} 연결 저장", userId);
+
+ // 연결 종료 시 자동 제거
+ emitter.onCompletion(() -> {
+ log.info("EmitterRepository - userId: {} 연결 종료", userId);
+ this.deleteById(userId);
+ });
+ emitter.onTimeout(() -> {
+ log.info("EmitterRepository - userId: {} 연결 시간 초과", userId);
+ this.deleteById(userId);
+ });
+ emitter.onError((e) -> {
+ log.error("EmitterRepository - userId: {} 연결 에러: {}", userId, e.getMessage());
+ this.deleteById(userId);
+ });
+
+ return emitter;
+ }
+
+ public SseEmitter findById(Long userId) {
+ return emitters.get(userId);
+ }
+
+ public void deleteById(Long userId) {
+ emitters.remove(userId);
+ }
+
+ public Map findAll() {
+ return emitters;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java b/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java
new file mode 100644
index 0000000..ef47e90
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java
@@ -0,0 +1,26 @@
+package com.example.gamemate.domain.notification.repository;
+
+import com.example.gamemate.domain.notification.entity.Notification;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NotificationRepository extends JpaRepository {
+
+ List findAllByReceiverId(Long receiverId);
+
+ @Query("UPDATE Notification n " +
+ "SET n.isRead = true " +
+ "WHERE n.receiver.id = :receiverId " +
+ "AND n.isRead = false")
+ @Modifying
+ void updateUnreadNotificationToRead(@Param("receiverId") Long receiverId);
+
+ Optional findTopByReceiverIdAndIsReadOrderByCreatedAtDesc(Long receiverId, boolean isRead);
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java b/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java
new file mode 100644
index 0000000..91fee35
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java
@@ -0,0 +1,177 @@
+package com.example.gamemate.domain.notification.service;
+
+import com.example.gamemate.domain.notification.dto.NotificationResponseDto;
+import com.example.gamemate.domain.notification.entity.Notification;
+import com.example.gamemate.domain.notification.enums.NotificationType;
+import com.example.gamemate.domain.notification.repository.EmitterRepository;
+import com.example.gamemate.domain.notification.repository.NotificationRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.exception.ApiException;
+import jakarta.annotation.PostConstruct;
+import jakarta.transaction.Transactional;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.connection.stream.StreamInfo;
+import org.springframework.data.redis.connection.stream.StreamRecords;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.util.*;
+
+/**
+ * 알림을 처리하는 서비스 클래스입니다.
+ */
+@Service
+@Slf4j
+public class NotificationService {
+
+ private static final String STREAM_KEY = "notification_stream";
+ private static final String GROUP_NAME = "notification-group";
+ private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;
+
+ private final NotificationRepository notificationRepository;
+ private final EmitterRepository emitterRepository;
+ private final RedisStreamService redisStreamService;
+ private final RedisTemplate redisTemplate;
+
+ public NotificationService(
+ NotificationRepository notificationRepository,
+ EmitterRepository emitterRepository,
+ RedisStreamService redisStreamService,
+ @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate) {
+ this.notificationRepository = notificationRepository;
+ this.emitterRepository = emitterRepository;
+ this.redisStreamService = redisStreamService;
+ this.redisTemplate = redisTemplate;
+ }
+
+ /**
+ * 레디스 스트림의 스트림그룹을 생성합니다.
+ */
+ @PostConstruct
+ public void init() {
+ try {
+ // 스트림이 존재하지 않으면 생성
+ if (!Boolean.TRUE.equals(redisTemplate.hasKey(STREAM_KEY))) {
+ redisTemplate.opsForStream()
+ .add(StreamRecords.newRecord()
+ .in(STREAM_KEY)
+ .ofMap(Collections.singletonMap("init", "init")));
+ }
+
+ // 그룹 정보 조회
+ StreamInfo.XInfoGroups groups = redisTemplate.opsForStream().groups(STREAM_KEY);
+ boolean groupExists = groups.stream()
+ .anyMatch(group -> GROUP_NAME.equals(group.groupName()));
+
+ // 그룹이 없으면 생성
+ if (!groupExists) {
+ redisTemplate.opsForStream().createGroup(STREAM_KEY, GROUP_NAME);
+ }
+ } catch (Exception e) {
+ log.error("스트림 초기화 중 오류 발생: {}", e.getMessage());
+ }
+ }
+
+ /**
+ * 알림을 생성합니다.
+ * @param user 알림을 받는 사용자
+ * @param type 알림 타입
+ * @param relatedUrl 알림과 관련된 URL
+ * @return Notification 생성된 알림
+ */
+ @Transactional
+ public Notification createNotification(User user, NotificationType type, String relatedUrl) {
+ Notification notification = new Notification(type.getContent(), relatedUrl, type, user);
+ return notificationRepository.save(notification);
+ }
+
+ /**
+ * 사용자의 모든 알림을 조회합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 로그인 한 사용자의 모든 알림이 담긴 List
+ */
+ public List findAllNotification(User loginUser) {
+ return notificationRepository.findAllByReceiverId(loginUser.getId())
+ .stream()
+ .map(NotificationResponseDto::toDto)
+ .toList();
+ }
+
+ /**
+ * 단일 알림을 읽음 처리합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @param id 읽음 처리할 알림 id
+ */
+ @Transactional
+ public void readNotification(User loginUser, Long id) {
+ Notification notification = notificationRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.NOTIFICATION_NOT_FOUND));
+
+ if (!Objects.equals(notification.getReceiver().getId(), loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ } // 알림의 받는 사람과 로그인 한 유저가 다르면 예외 처리
+
+ notification.updateIsRead(true);
+ }
+
+ /**
+ * 로그인한 사용자의 읽지 않은 모든 알림을 읽음 처리합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ */
+ @Transactional
+ public void readAllNotification(User loginUser) {
+ notificationRepository.updateUnreadNotificationToRead(loginUser.getId());
+ }
+
+ /**
+ * SSE 연결을 구독합니다. 또한 가장 최근 읽지 않은 알림 1개를 전송합니다.
+ * @param loginUser 현재 인증된 사용자 정보
+ * @return 사용자 연결정보가 담긴 SseEmitter
+ */
+ public SseEmitter subscribe(User loginUser) {
+ SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
+
+ try {
+ // 연결 직후 더미 데이터를 보내 503 에러 방지
+ emitter.send(SseEmitter.event()
+ .name("connect")
+ .data("connected!"));
+
+ // DB에서 가장 최근 읽지 않은 알림 1개만 조회
+ notificationRepository.findTopByReceiverIdAndIsReadOrderByCreatedAtDesc(loginUser.getId(), false)
+ .ifPresent(notification -> {
+ try {
+ NotificationResponseDto notificationDto = NotificationResponseDto.toDto(notification);
+ emitter.send(SseEmitter.event()
+ .name(notification.getType().name())
+ .data(notificationDto));
+ log.debug("최근 알림 전송 - ID: {}", notification.getId());
+ } catch (IOException e) {
+ log.error("알림 전송 실패 - ID: {} - 에러: {}", notification.getId(), e.getMessage());
+ }
+ });
+
+ } catch (IOException e) {
+ log.error("SSE 연결 실패 - 유저: {} - 에러: {}", loginUser.getId(), e.getMessage());
+ throw new RuntimeException("SSE 연결 실패", e);
+ }
+
+ return emitterRepository.save(loginUser.getId(), emitter);
+ }
+
+ /**
+ * 사용자에게 알림을 전송합니다.
+ * @param notification 보내질 알림
+ */
+ @Transactional
+ public void sendNotification(Notification notification) {
+ NotificationResponseDto notificationDto = NotificationResponseDto.toDto(notification);
+
+ // Redis Stream 에 알림 추가
+ redisStreamService.addNotificationToStream(notificationDto);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java b/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java
new file mode 100644
index 0000000..d48b630
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java
@@ -0,0 +1,200 @@
+package com.example.gamemate.domain.notification.service;
+
+import com.example.gamemate.domain.notification.dto.NotificationResponseDto;
+import com.example.gamemate.domain.notification.repository.EmitterRepository;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.connection.stream.*;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.*;
+
+@Service
+@Slf4j
+public class RedisStreamService {
+
+ private static final String STREAM_KEY = "notification_stream";
+ private static final String GROUP_NAME = "notification-group";
+
+ private static final String CONSUMER_PREFIX = "consumer";
+ private static final int BATCH_SIZE = 100;
+ private static final Duration POLL_TIMEOUT = Duration.ofMillis(100);
+ private static final int MAX_STREAM_LENGTH = 1000;
+
+ private final RedisTemplate redisTemplate;
+ private final EmitterRepository emitterRepository;
+
+ public RedisStreamService(
+ @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate,
+ EmitterRepository emitterRepository) {
+ this.redisTemplate = redisTemplate;
+ this.emitterRepository = emitterRepository;
+ }
+
+ @PostConstruct
+ public void init() {
+ createStreamGroup();
+ initializeStreamTrimming();
+ }
+
+ public void createStreamGroup() {
+ try {
+ // 스트림이 없으면 생성
+ if (!Boolean.TRUE.equals(redisTemplate.hasKey(STREAM_KEY))) {
+ redisTemplate.opsForStream().createGroup(STREAM_KEY, ReadOffset.from("0-0"), GROUP_NAME);
+ log.info("스트림과 그룹 생성 완료: {}", GROUP_NAME);
+ }
+ // 스트림은 있지만 그룹이 없는 경우
+ else {
+ try {
+ redisTemplate.opsForStream().createGroup(STREAM_KEY, GROUP_NAME);
+ log.info("기존 스트림에 그룹 생성 완료: {}", GROUP_NAME);
+ } catch (Exception e) {
+ log.info("그룹이 이미 존재합니다: {}", e.getMessage());
+ }
+ }
+ } catch (Exception e) {
+ log.error("스트림 그룹 생성 중 오류 발생: {}", e.getMessage());
+ }
+ }
+
+ private void initializeStreamTrimming() {
+ // 스트림 크기 제한 설정
+ try {
+ redisTemplate.opsForStream().trim(STREAM_KEY, MAX_STREAM_LENGTH);
+ } catch (Exception e) {
+ log.error("스트림 크기 초기화 중 오류 발생: {}", e.getMessage());
+ }
+ }
+
+ public void addNotificationToStream(NotificationResponseDto notification) {
+ try {
+ Map notificationMap = new HashMap<>();
+ notificationMap.put("id", notification.getId().toString());
+ notificationMap.put("content", notification.getContent());
+ notificationMap.put("type", notification.getType().name());
+ notificationMap.put("relatedUrl", notification.getRelatedUrl());
+ notificationMap.put("receiverId", notification.getReceiverId().toString());
+ notificationMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+
+ RecordId recordId = redisTemplate.opsForStream()
+ .add(StreamRecords.newRecord()
+ .in(STREAM_KEY)
+ .ofMap(notificationMap));
+
+ log.info("알림이 스트림에 추가됨: {}", recordId);
+
+ // 스트림 크기 관리
+ manageStream();
+ } catch (Exception e) {
+ log.error("알림 스트림 저장 실패: {}", e.getMessage());
+ throw new RuntimeException("알림 저장 실패", e);
+ }
+ }
+
+ private void manageStream() {
+ try {
+ // 스트림 길이 제한
+ long length = redisTemplate.opsForStream().size(STREAM_KEY);
+ if (length > MAX_STREAM_LENGTH) {
+ redisTemplate.opsForStream().trim(STREAM_KEY, MAX_STREAM_LENGTH);
+ log.info("스트림 크기 조정 완료. 현재 크기: {}", length);
+ }
+ } catch (Exception e) {
+ log.error("스트림 관리 중 오류 발생: {}", e.getMessage());
+ }
+ }
+
+ @Scheduled(fixedRate = 1000)
+ public void processUnconsumedNotifications() {
+ String consumerName = CONSUMER_PREFIX + "-" + UUID.randomUUID().toString();
+
+ try {
+ // 처리되지 않은 메시지 읽기
+ List> records =
+ redisTemplate.opsForStream().read(
+ Consumer.from(GROUP_NAME, consumerName),
+ StreamReadOptions.empty().count(BATCH_SIZE).block(POLL_TIMEOUT),
+ StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()));
+
+ for (MapRecord record : records) {
+ try {
+ processNotification(record);
+ // 성공적으로 처리된 메시지 승인
+ redisTemplate.opsForStream()
+ .acknowledge(GROUP_NAME, record);
+ } catch (Exception e) {
+ log.error("알림 처리 실패: {}", e.getMessage());
+ // 실패한 메시지 재처리 큐에 추가
+ handleFailedNotification(record);
+ }
+ }
+ } catch (Exception e) {
+ log.error("알림 처리 중 오류 발생: {}", e.getMessage());
+ }
+ }
+
+ private void processNotification(MapRecord record) throws IOException {
+ Map value = record.getValue();
+ Long receiverId = Long.parseLong(value.get("receiverId").toString());
+
+ // 연결된 SSE Emitter 찾기
+ SseEmitter emitter = emitterRepository.findById(receiverId);
+ if (emitter != null) {
+ try {
+ // SSE로 알림 전송
+ emitter.send(SseEmitter.event()
+ .name(value.get("type").toString())
+ .data(value));
+ } catch (Exception e) {
+ log.error("SSE 알림 전송 실패: {}", e.getMessage());
+ emitterRepository.deleteById(receiverId);
+ throw e;
+ }
+ }
+ }
+
+ private void handleFailedNotification(MapRecord record) {
+ // 실패한 메시지를 재처리 큐에 추가하는 로직
+ try {
+ String failedStreamKey = STREAM_KEY + "-failed";
+ redisTemplate.opsForStream()
+ .add(StreamRecords.newRecord()
+ .in(failedStreamKey)
+ .ofMap(new HashMap<>(record.getValue())));
+ } catch (Exception e) {
+ log.error("실패한 알림 처리 중 오류: {}", e.getMessage());
+ }
+ }
+
+ @Scheduled(fixedRate = 5000)
+ public void retryFailedNotifications() {
+ String failedStreamKey = STREAM_KEY + "-failed";
+
+ try {
+ List> failedRecords =
+ redisTemplate.opsForStream()
+ .read(StreamOffset.fromStart(failedStreamKey));
+
+ for (MapRecord record : failedRecords) {
+ try {
+ // 실패한 메시지 재처리
+ processNotification(record);
+ // 성공적으로 처리된 메시지 제거
+ redisTemplate.opsForStream()
+ .delete(failedStreamKey, record.getId());
+ } catch (Exception e) {
+ log.error("실패한 알림 재처리 실패: {}", e.getMessage());
+ }
+ }
+ } catch (Exception e) {
+ log.error("실패한 알림 재처리 중 오류 발생: {}", e.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java b/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java
new file mode 100644
index 0000000..476e3e8
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java
@@ -0,0 +1,73 @@
+package com.example.gamemate.domain.reply.controller;
+
+import com.example.gamemate.domain.reply.dto.ReplyRequestDto;
+import com.example.gamemate.domain.reply.dto.ReplyResponseDto;
+import com.example.gamemate.domain.reply.service.ReplyService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/boards/{boardId}/comments/{commentId}/replies")
+public class ReplyController {
+
+ private final ReplyService replyService;
+
+ /**
+ * 대댓글 생성 API 입니다.
+ *
+ * @param commentId 댓글 식별자
+ * @param requestDto 대댓글 생성 Dto
+ * @param customUserDetails 인증된 사용자
+ * @return 대댓글 생성 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createReply(
+ @PathVariable Long commentId,
+ @Valid @RequestBody ReplyRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ ReplyResponseDto dto = replyService.createReply(customUserDetails.getUser(), commentId, requestDto);
+ return new ResponseEntity<>(dto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 대댓글 수정 API 입니다.
+ *
+ * @param id 대댓글 식별자
+ * @param requestDto 업데이트할 대댓글 Dto
+ * @param customUserDetails 인증된 사용자
+ * @return Void
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity updateReply(
+ @PathVariable Long id,
+ @Valid @RequestBody ReplyRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ replyService.updateReply(customUserDetails.getUser(), id, requestDto);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+
+ /**
+ * 대댓글 삭제 API 입니다.
+ *
+ * @param id 대댓글 식별자
+ * @param customUserDetails 인증된 사용자
+ * @return Void
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteReply(
+ @PathVariable Long id,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails
+ ){
+ replyService.deleteReply(customUserDetails.getUser(), id);
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ }
+}
+
diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java
new file mode 100644
index 0000000..2806e74
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java
@@ -0,0 +1,25 @@
+package com.example.gamemate.domain.reply.dto;
+
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+@Getter
+public class ReplyFindResponseDto {
+ private final Long replyId;
+ private final String parentReplyName;
+ private final String content;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+
+
+ public ReplyFindResponseDto(Long replyId, String parentReplyName, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.replyId = replyId;
+ this.parentReplyName = parentReplyName;
+ this.content = content;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java
new file mode 100644
index 0000000..6c038a3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java
@@ -0,0 +1,21 @@
+package com.example.gamemate.domain.reply.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+
+@Getter
+public class ReplyRequestDto {
+ @NotBlank(message="댓글 내용을 입력하세요.")
+ private String content;
+
+ private Long parentReplyId;
+
+ public ReplyRequestDto(String content, Long parentReplyId) {
+ this.content = content;
+ this.parentReplyId = parentReplyId;
+ }
+
+ public ReplyRequestDto() {
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java
new file mode 100644
index 0000000..809a1c3
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java
@@ -0,0 +1,32 @@
+package com.example.gamemate.domain.reply.dto;
+
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class ReplyResponseDto {
+ private final Long id;
+ private final Long commentId;
+ private Long parentReplyId;
+ private final String content;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+
+ public ReplyResponseDto(Long id, Long commentId, Long parentReplyId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.id = id;
+ this.commentId = commentId;
+ this.parentReplyId = parentReplyId;
+ this.content = content;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ public ReplyResponseDto(Long id, Long commentId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.id = id;
+ this.commentId = commentId;
+ this.content = content;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java b/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java
new file mode 100644
index 0000000..b970fd9
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java
@@ -0,0 +1,52 @@
+package com.example.gamemate.domain.reply.entity;
+
+import com.example.gamemate.domain.comment.entity.Comment;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.common.BaseEntity;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@Table(name = "reply")
+public class Reply extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String content;
+
+ @ManyToOne
+ @JoinColumn(name = "comment_id")
+ private Comment comment;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "parent_reply_id")
+ private Reply parentReply;
+
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ public Reply(String content, Comment comment, User user) {
+ this.content = content;
+ this.comment = comment;
+ this.user = user;
+ }
+
+ public Reply(String content, Comment comment, User user, Reply parentReply) {
+ this.content = content;
+ this.comment = comment;
+ this.user = user;
+ this.parentReply = parentReply;
+ }
+
+ public void updateReply(String content){
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java b/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java
new file mode 100644
index 0000000..69bf6fe
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java
@@ -0,0 +1,13 @@
+package com.example.gamemate.domain.reply.repository;
+
+import com.example.gamemate.domain.comment.entity.Comment;
+import com.example.gamemate.domain.reply.entity.Reply;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface ReplyRepository extends JpaRepository {
+ List findByComment(Comment comment);
+
+ List findByParentReply(Reply reply);
+}
diff --git a/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java b/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java
new file mode 100644
index 0000000..fb04178
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java
@@ -0,0 +1,117 @@
+package com.example.gamemate.domain.reply.service;
+
+import com.example.gamemate.domain.comment.entity.Comment;
+import com.example.gamemate.domain.comment.repository.CommentRepository;
+import com.example.gamemate.domain.reply.dto.ReplyRequestDto;
+import com.example.gamemate.domain.reply.dto.ReplyResponseDto;
+import com.example.gamemate.domain.reply.entity.Reply;
+import com.example.gamemate.domain.reply.repository.ReplyRepository;
+import com.example.gamemate.domain.user.entity.User;
+import com.example.gamemate.global.constant.ErrorCode;
+import com.example.gamemate.global.eventListener.event.MatchCreatedEvent;
+import com.example.gamemate.global.eventListener.event.ReplyCreatedEvent;
+import com.example.gamemate.global.exception.ApiException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReplyService {
+
+ private final ReplyRepository replyRepository;
+ private final CommentRepository commentRepository;
+ private final ApplicationEventPublisher publisher;
+
+ /**
+ * 대댓글 생성 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param commentId 댓글 식별자
+ * @param requestDto 댓글 생성 Dto
+ * @return 대댓글 생성 정보 Dto
+ */
+ @Transactional
+ public ReplyResponseDto createReply(User loginUser, Long commentId, ReplyRequestDto requestDto) {
+ //댓글 조회
+ Comment findComment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+
+ Reply newReply;
+ // 부모 대댓글 null 일 경우
+ if(requestDto.getParentReplyId()==null){
+ newReply = new Reply(requestDto.getContent(), findComment, loginUser);
+ Reply createReply = replyRepository.save(newReply);
+ publisher.publishEvent(new ReplyCreatedEvent(this, createReply));
+
+ return new ReplyResponseDto(
+ createReply.getId(),
+ createReply.getComment().getId(),
+ createReply.getContent(),
+ createReply.getCreatedAt(),
+ createReply.getModifiedAt()
+ );
+ }else{
+ //대댓글 조회
+ Reply findParentReply = replyRepository.findById(requestDto.getParentReplyId())
+ .orElseThrow(()-> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+ newReply = new Reply(requestDto.getContent(), findComment, loginUser, findParentReply);
+ Reply createReply = replyRepository.save(newReply);
+ publisher.publishEvent(new ReplyCreatedEvent(this, createReply));
+
+ return new ReplyResponseDto(
+ createReply.getId(),
+ createReply.getComment().getId(),
+ createReply.getParentReply().getId(),
+ createReply.getContent(),
+ createReply.getCreatedAt(),
+ createReply.getModifiedAt()
+ );
+ }
+ }
+
+ /**
+ * 대댓글 업데이트 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 대댓글 식별자
+ * @param requestDto 대댓글 업데이트 Dto
+ */
+ @Transactional
+ public void updateReply(User loginUser, Long id, ReplyRequestDto requestDto) {
+ // 대댓글 조회
+ Reply findReply = replyRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+
+ // 대댓글 작성자와 로그인 유저 확인
+ if(!findReply.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ findReply.updateReply(requestDto.getContent());
+ replyRepository.save(findReply);
+ }
+
+ /**
+ * 대댓글 메서드입니다.
+ *
+ * @param loginUser 로그인한 유저
+ * @param id 대댓글 식별자
+ */
+ @Transactional
+ public void deleteReply(User loginUser, Long id) {
+ // 대댓글 조회
+ Reply findReply = replyRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
+
+ // 대댓글 작성자와 로그인 유저 확인
+ if(!findReply.getUser().getId().equals(loginUser.getId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+
+ replyRepository.delete(findReply);
+ }
+}
diff --git a/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java b/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java
new file mode 100644
index 0000000..b7f8709
--- /dev/null
+++ b/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java
@@ -0,0 +1,102 @@
+package com.example.gamemate.domain.review.controller;
+
+import com.example.gamemate.domain.review.dto.request.ReviewCreateRequestDto;
+import com.example.gamemate.domain.review.dto.request.ReviewUpdateRequestDto;
+import com.example.gamemate.domain.review.dto.response.ReviewCreateResponseDto;
+import com.example.gamemate.domain.review.dto.response.ReviewFindByAllResponseDto;
+import com.example.gamemate.domain.review.service.ReviewService;
+import com.example.gamemate.global.config.auth.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 게임 리뷰 관련 API를 처리하는 컨트롤러 클래스입니다.
+ * 이 컨트롤러는 리뷰의 생성, 수정, 삭제 및 조회 기능을 제공합니다.
+ */
+@RestController
+@RequestMapping("/games/{gameId}/reviews")
+@Slf4j
+@RequiredArgsConstructor
+public class ReviewController {
+
+ private final ReviewService reviewService;
+
+ /**
+ * 새로운 게임 리뷰를 생성합니다.
+ *
+ * @param gameId 리뷰를 작성할 게임의 ID
+ * @param requestDto 리뷰 생성 요청 데이터
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 생성된 리뷰 정보를 포함한 ResponseEntity
+ */
+ @PostMapping
+ public ResponseEntity createReview(
+ @PathVariable Long gameId,
+ @Valid @RequestBody ReviewCreateRequestDto requestDto,
+ @AuthenticationPrincipal CustomUserDetails customUserDetails) {
+
+ ReviewCreateResponseDto responseDto = reviewService.createReview(customUserDetails.getUser(), gameId, requestDto);
+ return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
+ }
+
+ /**
+ * 기존 게임 리뷰를 수정합니다.
+ *
+ * @param gameId 리뷰가 속한 게임의 ID
+ * @param id 수정할 리뷰의 ID
+ * @param requestDto 리뷰 수정 요청 데이터
+ * @param customUserDetails 인증된 사용자 정보
+ * @return 수정 결과를 나타내는 ResponseEntity
+ */
+ @PatchMapping("/{id}")
+ public ResponseEntity