diff --git "a/.github/ISSUE_TEMPLATE/bug-report-\353\262\204\352\267\270-\354\210\230\354\240\225-\355\205\234\355\224\214\353\246\277-.md" "b/.github/ISSUE_TEMPLATE/bug-report-\353\262\204\352\267\270-\354\210\230\354\240\225-\355\205\234\355\224\214\353\246\277-.md"
new file mode 100644
index 000000000..b9a662105
--- /dev/null
+++ "b/.github/ISSUE_TEMPLATE/bug-report-\353\262\204\352\267\270-\354\210\230\354\240\225-\355\205\234\355\224\214\353\246\277-.md"
@@ -0,0 +1,22 @@
+---
+name: Bug report(버그 수정 템플릿)
+about: 버그 제보 관련 템플릿
+title: "[BUG]"
+labels: "\U0001F41B 버그"
+assignees: ''
+
+---
+
+### 버그 사항
+해당 버그를 **자세하게** 적어주세요 😊
+
+
+### 버그 simulation
+버그를 발견하게 된 상황을 단계별로 적어주세요 😊
+
+
+### 원하던 상황
+원했던 상황을 자세하게 적어주세요 😊
+
+
+### **Screenshots**
diff --git "a/.github/ISSUE_TEMPLATE/feature-request-\352\270\260\353\212\245-\352\260\234\353\260\234-\355\205\234\355\224\214\353\246\277-.md" "b/.github/ISSUE_TEMPLATE/feature-request-\352\270\260\353\212\245-\352\260\234\353\260\234-\355\205\234\355\224\214\353\246\277-.md"
new file mode 100644
index 000000000..b5a4b2a48
--- /dev/null
+++ "b/.github/ISSUE_TEMPLATE/feature-request-\352\270\260\353\212\245-\352\260\234\353\260\234-\355\205\234\355\224\214\353\246\277-.md"
@@ -0,0 +1,27 @@
+---
+name: Feature request(기능 개발 템플릿)
+about: 기능 이슈 관련 템플릿
+title: ''
+labels: ''
+assignees: ''
+---
+
+## ✨ 추가 기능
+
+
+
+## 📆 일정 추정
+
+
+
+## 📃 세부 사항
+
+
+
+## ✅ 투두리스트
+
+- [ ] item
+- [ ] item
+
+## 🔗 참고 자료
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 000000000..caeee0e96
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,19 @@
+## 🔥 연관 이슈
+
+close: #
+
+## 📝 작업 요약
+
+수행할 작업을 1~2줄 사이로 요약해주세요.
+
+## ⏰ 소요 시간
+
+기능 구현에 소요된 시간을 적어주세요. (추정했던 시간과 다르다면 이유도 함께)
+
+## 🔎 작업 상세 설명
+
+주요 기능 및 로직에 관해 설명해주세요.
+
+## 🌟 논의 사항
+
+크루들과 이야기 해보고 싶은 부분을 적어주세요.
diff --git a/.github/workflows/backend-pr-comment.yml b/.github/workflows/backend-pr-comment.yml
new file mode 100644
index 000000000..f1a50e52d
--- /dev/null
+++ b/.github/workflows/backend-pr-comment.yml
@@ -0,0 +1,56 @@
+name: Pull request comment (BE)
+on:
+ issue_comment:
+ types: [created, edited, deleted]
+
+defaults:
+ run:
+ working-directory: backend
+
+jobs:
+ pull_request_comment:
+ # This job only runs for pull request comments
+ if: ${{ github.event.issue.pull_request }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send Slack notification When Review Completed
+ if: contains(github.event.comment.body, 'be-리뷰완') # check the comment if it contains the keywords
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_BE_CHANNEL }} # Slack 채널 ID
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "리뷰 완료했습니다👍\n<${{ github.event.comment.html_url }}|리뷰어의 코멘트 확인하러 가기>"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} # Slack 토큰
+
+ - name: Send Slack notification When Re-review Requested
+ if: contains(github.event.comment.body, 'be-리뷰요청')
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_BE_CHANNEL }}
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "리뷰 반영 최종 완료!✅ 확인 부탁드립니다😃\n<${{ github.event.comment.html_url }}|피드백 반영 확인하러 가기>"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }}
diff --git a/.github/workflows/backend-pr-deadline-slack-bot.yml b/.github/workflows/backend-pr-deadline-slack-bot.yml
new file mode 100644
index 000000000..a6f25a39d
--- /dev/null
+++ b/.github/workflows/backend-pr-deadline-slack-bot.yml
@@ -0,0 +1,58 @@
+name: Notify Pull Request Deadline (BE)
+
+on:
+ pull_request:
+ types:
+ - opened
+ branches: ['dev']
+ paths:
+ - 'backend/**'
+
+jobs:
+ pull_request_open:
+ runs-on: ubuntu-latest
+ name: New pr to repo
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Remove "https://" from PR URL
+ id: remove_https
+ run: |
+ PR_URL="${{ github.event.pull_request.html_url }}"
+ PR_URL="http://${PR_URL#https://}"
+ echo "::set-output name=pr_url::$PR_URL"
+
+ - name: Set environment variable
+ run: echo "PR_CREATED_AT_UTC=${{ github.event.pull_request.created_at }}" >> $GITHUB_ENV
+ - name: Convert UTC to KST
+ run: |
+ UTC_TIME=$PR_CREATED_AT_UTC
+ KST_TIME=$(date -u -d "$UTC_TIME 9 hour" "+%Y-%m-%dT%H:%M:%SZ")
+ echo "PR_CREATED_AT_KST=$KST_TIME" >> $GITHUB_ENV
+
+ - name: Calculate deadline
+ id: deadline
+ run: node .github/workflows/scripts/calculatePRDeadline.js
+ env:
+ PR_CREATED_AT_KST: ${{ env.PR_CREATED_AT_KST }}
+
+ - name: Send Slack notification When BE PR
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_BE_CHANNEL }} # Slack 채널 ID
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "<${{ steps.remove_https.outputs.pr_url }}|${{ github.event.pull_request.title }}>\n코드리뷰 마감시간: ${{ steps.deadline.outputs.DEADLINE }}"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} # Slack 토큰
diff --git a/.github/workflows/backend-pr-test.yml b/.github/workflows/backend-pr-test.yml
new file mode 100644
index 000000000..a9921f454
--- /dev/null
+++ b/.github/workflows/backend-pr-test.yml
@@ -0,0 +1,71 @@
+name: Backend Test When Pull Request
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ paths:
+ - 'backend/**'
+ - '.github/**'
+
+defaults:
+ run:
+ working-directory: backend
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ permissions:
+ checks: write
+ pull-requests: write
+
+ steps:
+ - name: 레포지토리 체크아웃
+ uses: actions/checkout@v3
+
+ - name: JDK 17 환경 설정
+ uses: actions/setup-java@v3
+ with:
+ java-version: 17
+ distribution: temurin
+
+ - name: Gradle 캐시
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Gradle 실행 권한 부여
+ run: chmod +x gradlew
+
+ - name: Gradle 테스트 실행
+ run: ./gradlew --info test
+
+ - name: 테스트 결과 PR 코멘트 등록
+ uses: EnricoMi/publish-unit-test-result-action@v2
+ if: always()
+ with:
+ files: '**/build/test-results/test/TEST-*.xml'
+
+ - name: 테스트 실패 시 해당 코드 라인에 Check 등록
+ uses: mikepenz/action-junit-report@v3
+ if: always()
+ with:
+ report_paths: '**/build/test-results/test/TEST-*.xml'
+
+ - name: 테스트 실패 시 Slack 알림
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: 백엔드 테스트 실패 알림
+ fields: repo, message, commit, author, action, eventName, ref, workflow, job, took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: failure()
diff --git a/.github/workflows/frontend-lighthouse-ci.yml b/.github/workflows/frontend-lighthouse-ci.yml
new file mode 100644
index 000000000..e510a9063
--- /dev/null
+++ b/.github/workflows/frontend-lighthouse-ci.yml
@@ -0,0 +1,112 @@
+name: Run Lighthouse CI When PR
+
+on:
+ pull_request:
+ branches: ['dev']
+ paths:
+ - 'frontend/**'
+
+defaults:
+ run:
+ working-directory: frontend
+
+jobs:
+ lhci:
+ permissions: write-all
+ name: Lighthouse CI
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Use Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install packages
+ run: npm install
+
+ - name: Set environment variable
+ run: |
+ touch .env
+ echo VOTOGETHER_BASE_URL=${{ secrets.VOTOGETHER_BASE_URL }} >> .env
+ echo VOTOGETHER_MOCKING_URL=${{ secrets.VOTOGETHER_MOCKING_URL }} >> .env
+ echo VOTOGETHER_REST_API_KEY=${{ secrets.VOTOGETHER_REST_API_KEY }} >> .env
+ echo VOTOGETHER_SERVER_REDIRECT_URL=${{ secrets.VOTOGETHER_SERVER_REDIRECT_URL }} >> .env
+ echo VOTOGETHER_CHANNEL_TALK_KEY=${{ secrets.VOTOGETHER_CHANNEL_TALK_KEY }} >> .env
+ echo VOTOGETHER_GOOGLE_TAG_ID=${{ secrets.VOTOGETHER_GOOGLE_TAG_ID }} >> .env
+ cat .env
+
+ - name: Build
+ run: npm run build
+ env:
+ VOTOGETHER_BASE_URL: ${{ env.VOTOGETHER_BASE_URL }}
+ VOTOGETHER_MOCKING_URL: ${{ env.VOTOGETHER_MOCKING_URL }}
+ VOTOGETHER_REST_API_KEY: ${{ env.VOTOGETHER_REST_API_KEY }}
+ VOTOGETHER_SERVER_REDIRECT_URL: ${{ env.VOTOGETHER_SERVER_REDIRECT_URL}}
+ VOTOGETHER_CHANNEL_TALK_KEY: ${{ env.VOTOGETHER_CHANNEL_TALK_KEY }}
+ VOTOGETHER_GOOGLE_TAG_ID: ${{ env.VOTOGETHER_GOOGLE_TAG_ID }}
+
+ - name: Run Lighthouse CI
+ env:
+ LHCI_GITHUB_APP_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ npm install -g @lhci/cli
+ lhci autorun || echo "Fail to Run Lighthouse CI!"
+
+ - name: Format lighthouse score
+ id: format_lighthouse_score
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GITHUB_TOKEN}}
+ script: |
+ const fs = require('fs');
+
+ const results = JSON.parse(fs.readFileSync('./frontend/lhci_reports/manifest.json'));
+ let comments = '';
+
+ results.forEach((result) => {
+ const { summary, jsonPath } = result;
+
+ const details = JSON.parse(fs.readFileSync(jsonPath));
+ const { audits } = details;
+
+ const formatResult = (res) => Math.round(res * 100);
+
+ Object.keys(summary).forEach(
+ (key) => (summary[key] = formatResult(summary[key]))
+ );
+
+ const score = (res) => (res >= 90 ? '🟢' : res >= 50 ? '🟠' : '🔴');
+
+ const comment = [
+ `⚡️ Lighthouse report!`,
+ `| Category | Score |`,
+ `| --- | --- |`,
+ `| ${score(summary.performance)} Performance | ${summary.performance} |`,
+ `| ${score(summary.accessibility)} Accessibilty | ${summary.accessibility} |`,
+ `| ${score(summary.seo)} SEO | ${summary.seo} |`,
+ `| ${score(summary.pwa)} PWA | ${summary.pwa} |`,
+ ].join('\n');
+
+ const detail = [
+ `| Category | Score |`,
+ `| --- | --- |`,
+ `| ${score(audits['first-contentful-paint'].score * 100)} First Contentful Paint | ${audits['first-contentful-paint'].displayValue} |`,
+ `| ${score(audits['largest-contentful-paint'].score * 100)} Largest Contentful Paint | ${audits['largest-contentful-paint'].displayValue} |`,
+ `| ${score(audits['total-blocking-time'].score * 100)} Total Blocking Time | ${audits['total-blocking-time'].displayValue} |`,
+ `| ${score(audits['cumulative-layout-shift'].score * 100)} Cumulative Layout Shift | ${audits['cumulative-layout-shift'].displayValue} |`,
+ `| ${score(audits['speed-index'].score * 100)} Speed Index | ${audits['speed-index'].displayValue} |`,
+ ].join('\n');
+
+ comments += comment + '\n\n' + detail + '\n';
+ });
+
+ core.setOutput('comments', comments)
+
+ - name: comment PR
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ message: ${{ steps.format_lighthouse_score.outputs.comments}}
diff --git a/.github/workflows/frontend-pr-comment.yml b/.github/workflows/frontend-pr-comment.yml
new file mode 100644
index 000000000..ccc0928bc
--- /dev/null
+++ b/.github/workflows/frontend-pr-comment.yml
@@ -0,0 +1,56 @@
+name: Pull request comment (FE)
+on:
+ issue_comment:
+ types: [created, edited, deleted]
+
+defaults:
+ run:
+ working-directory: frontend
+
+jobs:
+ pull_request_comment:
+ # This job only runs for pull request comments
+ if: ${{ github.event.issue.pull_request }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send Slack notification When Review Completed
+ if: contains(github.event.comment.body, 'fe-리뷰완') # check the comment if it contains the keywords
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_FE_CHANNEL }} # Slack 채널 ID
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "리뷰 완료했습니다👍\n<${{ github.event.comment.html_url }}|리뷰어의 코멘트 확인하러 가기>"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} # Slack 토큰
+
+ - name: Send Slack notification When Re-review Requested
+ if: contains(github.event.comment.body, 'fe-리뷰요청') # check the comment if it contains the keywords
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_FE_CHANNEL }} # Slack 채널 ID
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "리뷰 반영 최종 완료!✅ 확인 부탁드립니다😃\n<${{ github.event.comment.html_url }}|피드백 반영 확인하러 가기>"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} # Slack 토큰
diff --git a/.github/workflows/frontend-pr-deadline-slack-bot.yml b/.github/workflows/frontend-pr-deadline-slack-bot.yml
new file mode 100644
index 000000000..9dfd6e087
--- /dev/null
+++ b/.github/workflows/frontend-pr-deadline-slack-bot.yml
@@ -0,0 +1,58 @@
+name: Notify Pull Request Deadline (FE)
+
+on:
+ pull_request:
+ types:
+ - opened
+ branches: ['dev']
+ paths:
+ - 'frontend/**'
+
+jobs:
+ pull_request_open:
+ runs-on: ubuntu-latest
+ name: New pr to repo
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Remove "https://" from PR URL
+ id: remove_https
+ run: |
+ PR_URL="${{ github.event.pull_request.html_url }}"
+ PR_URL="http://${PR_URL#https://}"
+ echo "::set-output name=pr_url::$PR_URL"
+
+ - name: Set environment variable
+ run: echo "PR_CREATED_AT_UTC=${{ github.event.pull_request.created_at }}" >> $GITHUB_ENV
+ - name: Convert UTC to KST
+ run: |
+ UTC_TIME=$PR_CREATED_AT_UTC
+ KST_TIME=$(date -u -d "$UTC_TIME 9 hour" "+%Y-%m-%dT%H:%M:%SZ")
+ echo "PR_CREATED_AT_KST=$KST_TIME" >> $GITHUB_ENV
+
+ - name: Calculate deadline
+ id: deadline
+ run: node .github/workflows/scripts/calculatePRDeadline.js
+ env:
+ PR_CREATED_AT_KST: ${{ env.PR_CREATED_AT_KST }}
+
+ - name: Send Slack notification When FE PR
+ uses: slackapi/slack-github-action@v1.24.0
+ with:
+ channel-id: ${{ secrets.SLACK_FE_CHANNEL }} # Slack 채널 ID
+ payload: |
+ {
+ "text": "",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "<${{ steps.remove_https.outputs.pr_url }}|${{ github.event.pull_request.title }}>\n코드리뷰 마감시간: ${{ steps.deadline.outputs.DEADLINE }}"
+ }
+ }
+ ]
+ }
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} # Slack 토큰
diff --git a/.github/workflows/frontend-storybook-deploy.yml b/.github/workflows/frontend-storybook-deploy.yml
new file mode 100644
index 000000000..959063e4a
--- /dev/null
+++ b/.github/workflows/frontend-storybook-deploy.yml
@@ -0,0 +1,60 @@
+name: Deploy
+
+on:
+ pull_request:
+ branches: ['dev']
+ types: ['closed']
+ paths:
+ - 'frontend/**'
+ - '.github/**'
+
+defaults:
+ run:
+ working-directory: frontend
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+ steps:
+ - name: Use repository source
+ uses: actions/checkout@v3
+
+ - name: Use node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: Cache node_modules
+ id: cache
+ uses: actions/cache@v3
+ with:
+ path: '**/node_modules'
+ key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-node-
+
+ - name: Install dependencies
+ run: npm ci
+ if: steps.cache.outputs.cache-hit != 'true'
+
+ - name: Set PUBLIC_URL
+ run: |
+ PUBLIC_URL=$(echo $GITHUB_REPOSITORY | sed -r 's/^.+\/(.+)$/\/\1\//')
+ echo PUBLIC_URL=$PUBLIC_URL > .env
+
+ - name: 스토리북을 빌드한다.
+ run: |
+ npm run build-storybook
+
+ - name: storybook-deploy 브런치에 배포할 파일을 업데이트 한다.
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_branch: storybook-deploy
+ publish_dir: ./frontend/storybook-static
diff --git a/.github/workflows/scripts/calculatePRDeadline.js b/.github/workflows/scripts/calculatePRDeadline.js
new file mode 100644
index 000000000..9dc14c362
--- /dev/null
+++ b/.github/workflows/scripts/calculatePRDeadline.js
@@ -0,0 +1,54 @@
+function calculatePRDeadline(prCreatedAtKST) {
+ const prCreatedAt = new Date(String(prCreatedAtKST));
+
+ const prCreatedMinute = prCreatedAt.getUTCMinutes();
+ const prCreatedHour = prCreatedAt.getUTCHours();
+ const prCreatedDate = prCreatedAt.getUTCDate();
+ const prCreatedDay = prCreatedAt.getUTCDay();
+ const prCreatedMonth = prCreatedAt.getUTCMonth() + 1; // getUTCMonth()는 0부터 시작하므로 1을 더함
+
+ const isFridayAfterTenPM = prCreatedDay === 5 && prCreatedHour >= 22; // 금요일 오후 10시 이후 (금요일: 5, 오후 12시: 12)
+ const isWeekend = prCreatedDay === 6 || prCreatedDay === 0; // 주말인 경우
+
+ // 주어진 근무시간(월요일 오전 10시~금요일 오후 10시) 내에 올린 pr인지 판별
+ const isNotWorkingTime = isFridayAfterTenPM || isWeekend;
+
+ let nextDay = new Date(prCreatedAt);
+ nextDay.setUTCDate(prCreatedDate + 1); // 다음 날의 날짜를 설정
+
+ const nextDayDate = nextDay.getUTCDate();
+ const nextDayMonth = nextDay.getUTCMonth() + 1;
+
+ let nextWeekMonday = new Date(prCreatedAt);
+ const daysUntilMonday = 8 - prCreatedDay;
+ nextWeekMonday.setUTCDate(
+ prCreatedDay === 0 ? prCreatedDate + 1 : prCreatedDate + daysUntilMonday
+ );
+ const nextWeekMondayDate = nextWeekMonday.getUTCDate();
+ const nextWeekMondayMonth = nextWeekMonday.getUTCMonth() + 1;
+
+ const isFriday = prCreatedDay === 5;
+
+ if (isNotWorkingTime) {
+ return `${nextWeekMondayMonth}월 ${nextWeekMondayDate}일 20시 00분`;
+ }
+
+ if (prCreatedHour < 10 && prCreatedHour > 0)
+ return `${prCreatedMonth}월 ${prCreatedDate}일 20시 00분`;
+ else if (prCreatedHour === 22 || prCreatedHour === 23)
+ return `${nextDayMonth}월 ${nextDayDate}일 20시 00분`;
+ else if (prCreatedHour >= 12)
+ return `${isFriday ? nextWeekMondayMonth : nextDayMonth}월 ${
+ isFriday ? nextWeekMondayDate : nextDayDate
+ }일 ${prCreatedHour - 2}시 ${prCreatedMinute}분`;
+ else
+ return `${prCreatedMonth}월 ${prCreatedDate}일 ${
+ prCreatedHour + 10
+ }시 ${prCreatedMinute}분`;
+}
+
+console.log(
+ `::set-output name=DEADLINE::${calculatePRDeadline(
+ process.env.PR_CREATED_AT_KST
+ )}`
+);
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..da76ca84f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Mac
+.DS_Store
+
+# IntelliJ
+.idea
+
+# vscode
+.vscode
diff --git a/README.md b/README.md
index fa5755065..e239f9406 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,48 @@
# 2023-VoTogether
+
+### 다른 사람들의 의견이 궁금할 때가 있지 않나요?
+
+“운동 초보인데 어떤 운동으로 시작하면 좋을까요?“, “오늘의 점심 메뉴는 어떤 것이 좋을까요?” 등과 같이, 하루에도 수많은 선택의 상황에 있지 않으신가요? 다양한 의견을 듣고 싶지만, 친구나 가족만으로는 충분하지 않은 경우가 있습니다. 내 주변 사람들로부터 답을 들을 수 없는 경우도 있을 거예요.
+
+보투게더 팀 역시 혼자 사소한 고민을 하며 많은 시간과 에너지를 쏟아야 했고, 스트레스를 겪은 적이 있습니다. 그 때문에 사람들이 만족스럽게 고민을 해결할 방법에 대해 생각하게 되었습니다.
+그 결과 다양한 고민에 대해 익명으로 사람들의 취향과 의견을 투표 방식으로 비교해 보는, ‘VoTogether(보투게더)’라는 서비스가 탄생하게 되었습니다.
+
+
+
+### 보투게더에서 당신의 이야기를 털어 놓아보세요
+
+‘VoTogether(보투게더)’는 투표를 통해 고민을 해결하고 재미를 찾는 서비스입니다.
+출퇴근 시 심심할 때 들어와 남들과 수다 떨 수 있는 공간입니다. 사람들이 낚시를 가서 최대 몇 센티미터의 물고기를 잡아봤는지, 3대 중량이 어느 정도인지, 라면 몇 개까지 먹는지 궁금하지 않으신가요. 음식의 기호, 취미생활과 같이 시시하지만 솔직한 이야기를 듣고 나눌 수 있습니다.
+하지만 늘 가벼운 이야기만 나누지는 않습니다. 고민이 있는 사람은 자신의 고민을 글로 써 올리고, 다른 사람들은 글의 선택지에 투표하며 고민 해결을 돕습니다.
+
+예를 들어 20대 후반, 집과 직장만 오가는 일상에 맥주 한잔하고 싶지만 그럴 만한 친구가 없는 사람이 있습니다. 나만 이런 심심한 인생을 사는 건지 궁금하지만 물을 곳이 없습니다. 이같이 인생 상담, 직업 상담, 연애 상담처럼 남에게 털어놓기 어려울 때 의견을 편하게 구할 방법을 제시합니다.
+
+혹은 구체적인 결과가 궁금할 수도 있습니다. 창업을 준비하는 30대 친구가 있습니다. 20대 초반을 대상으로 하는 주점 창업을 준비하고 있는데, 요즘 친구들은 뭘 좋아하는지 모르겠습니다. 마라? 하이볼? 아니면 포장마차? 그럴 때 보투게더를 통해 사람들의 투표를 받아 나이별, 성별로 상세한 투표 통계 결과를 확인할 수 있습니다. 이 결과로 의사결정의 가이드를 받을 수 있고, 더불어 댓글로 더 좋은 의견과 선택지를 얻을 수도 있습니다.
+
+이처럼 보투게더는 투표라는 방식으로 사람들을 연결 짓고, 일상과 함께하면서 사람들이 각종 재미와 공감, 고민 해결의 실마리를 얻어가길 추구합니다.
+
+
+
+### 다 함께, 즐겁게, 심플하게! 보투게더를 이용해 보세요
+
+고민이 있으신가요? 글을 써보세요!
+
+익명으로 글을 작성하며 나를 드러내지 않아도 쉽게 고민을 나눌 수 있습니다. 일정 시간이 지나면 자동으로 글은 마감되어 그 결과를 확인할 수 있습니다. 만약 구체적인 결과가 궁금하다면 글쓴이에게만 제공되는 통계 결과를 확인해 보세요!
+
+
+
+심심하신가요? 투표를 해보세요!
+
+마감 시간이 지나기 전에 글을 읽고 원하는 선택지에 투표할 수 있습니다. 투표를 한 후 다른 사람들의 선택을 확인하고, 댓글을 통해 자유로운 의견도 낼 수 있습니다. 다른 사람들이 무엇을 좋아하는지, 무엇을 고민하는지 알아가는 재미가 있을 거예요.
+
+
+
+궁금하신가요? 관심사를 탐색해 보세요!
+
+특정 소재에 대해 궁금증이 있다면 탐색할 수 있습니다. 관련된 카테고리에 들어가거나 키워드를 검색할 수 있습니다. 진행 중인 게시물에 들어가 나의 의견을 낼 수도 있고, 이미 마감된 게시물에 들어가 다른 사람들이 어떤 선택을 했는지 구경할 수도 있습니다. 여러 개 쌓인 게시물을 탐색하다 보면 고민 해결의 실마리를 찾을지도 몰라요.
+
+
+
+보투게더는 사람들의 다양한 주제로 질문하고 답변하면서, 사람들의 반응을 확인할 수 있다는 점에서 특별합니다. 이런 자유로운 분위기 속에서, 모든 사람이 즐겁게 서비스를 이용할 수 있도록 보투게더는 적절하지 않은 콘텐츠에 대해서는 신고를 통해 비공개 처리하고 있습니다.
+
+나의 이야기가 우리의 이야기가 되는 공간, 보투게더에서 우리 함께해요! 👍
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 000000000..b0a520940
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,40 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### local environment test files ###
+src/main/generated
diff --git a/backend/build.gradle b/backend/build.gradle
new file mode 100644
index 000000000..b0a03fbb0
--- /dev/null
+++ b/backend/build.gradle
@@ -0,0 +1,88 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.1'
+ id 'io.spring.dependency-management' version '1.1.0'
+}
+
+group = 'com'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '17'
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-log4j2'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
+
+ modules {
+ module("org.springframework.boot:spring-boot-starter-logging") {
+ replacedBy("org.springframework.boot:spring-boot-starter-log4j2")
+ }
+ }
+
+ //Start Querydsl
+ implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
+
+ //To response to java.lang.NoClassDefFoundError
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+ //End Querydsl
+
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+ testCompileOnly 'org.projectlombok:lombok'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+
+ testImplementation 'io.rest-assured:rest-assured'
+ testImplementation 'io.rest-assured:spring-mock-mvc'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'io.rest-assured:rest-assured'
+ testImplementation 'io.rest-assured:spring-mock-mvc'
+ testImplementation "org.testcontainers:testcontainers:1.19.0"
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+//Start Querydsl
+//Where To Create Querydsl Q Class
+def generated = 'src/main/generated'
+
+//Set Querydsl Q class Generated Target
+tasks.withType(JavaCompile).configureEach {
+ options.getGeneratedSourceOutputDirectory().set(file(generated))
+}
+
+//Add Q Class Target In Java Source Set
+sourceSets {
+ main.java.srcDirs += [generated]
+}
+
+//When Gradle Clean, Delete Q Class Directory
+clean {
+ delete file(generated)
+}
+//End Querydsl
diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..c1962a79e
Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..37aef8d3f
--- /dev/null
+++ b/backend/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/backend/gradlew b/backend/gradlew
new file mode 100755
index 000000000..aeb74cbb4
--- /dev/null
+++ b/backend/gradlew
@@ -0,0 +1,245 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/backend/gradlew.bat b/backend/gradlew.bat
new file mode 100644
index 000000000..6689b85be
--- /dev/null
+++ b/backend/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/backend/settings.gradle b/backend/settings.gradle
new file mode 100644
index 000000000..6c2e75f41
--- /dev/null
+++ b/backend/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'votogether'
diff --git a/backend/src/main/java/com/votogether/VotogetherApplication.java b/backend/src/main/java/com/votogether/VotogetherApplication.java
new file mode 100644
index 000000000..e2aa0e85d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/VotogetherApplication.java
@@ -0,0 +1,15 @@
+package com.votogether;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+
+@ConfigurationPropertiesScan
+@SpringBootApplication
+public class VotogetherApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(VotogetherApplication.class, args);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java b/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java
new file mode 100644
index 000000000..563ec6d79
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/controller/AuthController.java
@@ -0,0 +1,95 @@
+package com.votogether.domain.auth.controller;
+
+import com.votogether.domain.auth.dto.request.AccessTokenRequest;
+import com.votogether.domain.auth.dto.response.LoginResponse;
+import com.votogether.domain.auth.dto.response.ReissuedAccessTokenResponse;
+import com.votogether.domain.auth.exception.AuthExceptionType;
+import com.votogether.domain.auth.service.AuthService;
+import com.votogether.domain.auth.service.dto.LoginTokenDto;
+import com.votogether.domain.auth.service.dto.ReissuedTokenDto;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import java.util.Arrays;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RequestMapping("/auth")
+@RestController
+public class AuthController implements AuthControllerDocs {
+
+ private final AuthService authService;
+
+ @GetMapping("/kakao/callback")
+ public ResponseEntity loginByKakao(
+ @RequestParam final String code,
+ final HttpServletResponse httpServletResponse
+ ) {
+ final LoginTokenDto loginTokenDto = authService.register(code);
+
+ addRefreshTokenToCookie(httpServletResponse, loginTokenDto.refreshToken());
+ final LoginResponse response =
+ new LoginResponse(loginTokenDto.accessToken(), loginTokenDto.hasEssentialInfo());
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping("/silent-login")
+ public ResponseEntity reissueAccessToken(
+ @RequestBody @Valid final AccessTokenRequest request,
+ final HttpServletRequest httpServletRequest,
+ final HttpServletResponse httpServletResponse
+ ) {
+ final String refreshToken = getRefreshTokenFromCookie(httpServletRequest);
+ final ReissuedTokenDto reissuedTokenDto = authService.reissueAuthToken(request, refreshToken);
+
+ addRefreshTokenToCookie(httpServletResponse, reissuedTokenDto.refreshToken());
+ final ReissuedAccessTokenResponse response = new ReissuedAccessTokenResponse(reissuedTokenDto.accessToken());
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping("/logout")
+ public ResponseEntity logout(
+ final HttpServletRequest httpServletRequest,
+ final HttpServletResponse httpServletResponse
+ ) {
+ final String refreshToken = getRefreshTokenFromCookie(httpServletRequest);
+ authService.deleteRefreshToken(refreshToken);
+
+ expireCookie(httpServletResponse, refreshToken);
+ return ResponseEntity.noContent().build();
+ }
+
+ private void addRefreshTokenToCookie(final HttpServletResponse httpServletResponse, final String refreshToken) {
+ final Cookie cookie = new Cookie("refreshToken", refreshToken);
+ cookie.setHttpOnly(true);
+ cookie.setSecure(true);
+ cookie.setPath("/auth");
+ httpServletResponse.addCookie(cookie);
+ }
+
+ private String getRefreshTokenFromCookie(final HttpServletRequest httpServletRequest) {
+ return Arrays.stream(httpServletRequest.getCookies())
+ .filter(cookie -> cookie.getName().equals("refreshToken"))
+ .findAny()
+ .map(Cookie::getValue)
+ .orElseThrow(() -> new BadRequestException(AuthExceptionType.NONEXISTENT_REFRESH_TOKEN));
+ }
+
+ private void expireCookie(final HttpServletResponse httpServletResponse, final String refreshToken) {
+ final Cookie cookie = new Cookie("refreshToken", refreshToken);
+ cookie.setMaxAge(0);
+ httpServletResponse.addCookie(cookie);
+ }
+
+}
+
diff --git a/backend/src/main/java/com/votogether/domain/auth/controller/AuthControllerDocs.java b/backend/src/main/java/com/votogether/domain/auth/controller/AuthControllerDocs.java
new file mode 100644
index 000000000..95599b3bd
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/controller/AuthControllerDocs.java
@@ -0,0 +1,61 @@
+package com.votogether.domain.auth.controller;
+
+import com.votogether.domain.auth.dto.request.AccessTokenRequest;
+import com.votogether.domain.auth.dto.response.LoginResponse;
+import com.votogether.domain.auth.dto.response.ReissuedAccessTokenResponse;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestBody;
+
+@Tag(name = "인증", description = "인증 API")
+public interface AuthControllerDocs {
+
+ @Operation(summary = "카카오 로그인 하기", description = "카카오 계정으로 로그인 한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "카카오 로그인 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "올바르지 않은 요청",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "401",
+ description = "올바르지 않은 인증코드",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity loginByKakao(
+ @Parameter(description = "카카오 인가코드", example = "abcdegf") final String code,
+ final HttpServletResponse httpServletResponse
+ );
+
+ @Operation(summary = "액세스 토큰 재발급 하기", description = "액세스 토큰을 재발급 받는다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "액세스 토큰 재발급 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "올바르지 않은 갱신 토큰",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "요청으로부터 찾을 수 없는 갱신 토큰",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity reissueAccessToken(
+ @RequestBody final AccessTokenRequest request,
+ final HttpServletRequest httpServletRequest,
+ final HttpServletResponse httpServletResponse
+ );
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/dto/request/AccessTokenRequest.java b/backend/src/main/java/com/votogether/domain/auth/dto/request/AccessTokenRequest.java
new file mode 100644
index 000000000..e43d279df
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/dto/request/AccessTokenRequest.java
@@ -0,0 +1,12 @@
+package com.votogether.domain.auth.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "인증 토큰 재발급 요청")
+public record AccessTokenRequest(
+ @Schema(description = "인증 토큰", example = "abc.def.ghi")
+ @NotBlank(message = "인증 토큰은 빈 값이거나 null이 될 수 없습니다.")
+ String accessToken
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/dto/response/KakaoMemberResponse.java b/backend/src/main/java/com/votogether/domain/auth/dto/response/KakaoMemberResponse.java
new file mode 100644
index 000000000..4586f313b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/dto/response/KakaoMemberResponse.java
@@ -0,0 +1,25 @@
+package com.votogether.domain.auth.dto.response;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "카카오 서버로부터 받은 유저 정보")
+@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record KakaoMemberResponse(
+ @Schema(description = "카카오 소셜 ID", example = "1")
+ Long id,
+
+ @Schema(description = "카카오 유저 정보")
+ KakaoAccount kakaoAccount
+) {
+
+ @Schema(description = "카카오 유저 정보")
+ @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+ public record KakaoAccount(
+ @Schema(description = "이메일", example = "votogether@email.com")
+ String email
+ ) {
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/dto/response/LoginResponse.java b/backend/src/main/java/com/votogether/domain/auth/dto/response/LoginResponse.java
new file mode 100644
index 000000000..c3735520d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/dto/response/LoginResponse.java
@@ -0,0 +1,13 @@
+package com.votogether.domain.auth.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "로그인 응답")
+public record LoginResponse(
+ @Schema(description = "인증 토큰", example = "abc.def.ghi")
+ String accessToken,
+
+ @Schema(description = "성별, 나이대 정보를 가지고 있는지 여부", example = "true")
+ boolean hasEssentialInfo
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/dto/response/OAuthAccessTokenResponse.java b/backend/src/main/java/com/votogether/domain/auth/dto/response/OAuthAccessTokenResponse.java
new file mode 100644
index 000000000..9ee9761d8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/dto/response/OAuthAccessTokenResponse.java
@@ -0,0 +1,25 @@
+package com.votogether.domain.auth.dto.response;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "카카오 서버로부터 발급받는 토큰 정보")
+@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record OAuthAccessTokenResponse(
+ @Schema(description = "토큰 타입", example = "bearer")
+ String tokenType,
+
+ @Schema(description = "인증 토큰", example = "abc.def.ghi")
+ String accessToken,
+
+ @Schema(description = "인증 토큰 만료 시간", example = "10")
+ Integer expiresIn,
+
+ @Schema(description = "갱신 토큰", example = "abc.def.ghi")
+ String refreshToken,
+
+ @Schema(description = "갱신 토큰 만료 시간", example = "10")
+ Integer refreshTokenExpiresIn
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/dto/response/ReissuedAccessTokenResponse.java b/backend/src/main/java/com/votogether/domain/auth/dto/response/ReissuedAccessTokenResponse.java
new file mode 100644
index 000000000..5068a6ddf
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/dto/response/ReissuedAccessTokenResponse.java
@@ -0,0 +1,10 @@
+package com.votogether.domain.auth.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "인증 토큰 재발급 응답")
+public record ReissuedAccessTokenResponse(
+ @Schema(description = "인증 토큰", example = "abc.def.ghi")
+ String accessToken
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/exception/AuthExceptionType.java b/backend/src/main/java/com/votogether/domain/auth/exception/AuthExceptionType.java
new file mode 100644
index 000000000..37e1d584d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/exception/AuthExceptionType.java
@@ -0,0 +1,20 @@
+package com.votogether.domain.auth.exception;
+
+import com.votogether.global.exception.ExceptionType;
+import lombok.Getter;
+
+@Getter
+public enum AuthExceptionType implements ExceptionType {
+
+ NONEXISTENT_REFRESH_TOKEN(300, "갱신 토큰이 존재하지 않습니다."),
+ UNMATCHED_INFORMATION_BETWEEN_TOKEN(301, "토큰 간의 정보가 일치하지 않습니다.");
+
+ private final int code;
+ private final String message;
+
+ AuthExceptionType(final int code, final String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java b/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java
new file mode 100644
index 000000000..58cc022e9
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/service/AuthService.java
@@ -0,0 +1,88 @@
+package com.votogether.domain.auth.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.votogether.domain.auth.dto.request.AccessTokenRequest;
+import com.votogether.domain.auth.dto.response.KakaoMemberResponse;
+import com.votogether.domain.auth.exception.AuthExceptionType;
+import com.votogether.domain.auth.service.dto.LoginTokenDto;
+import com.votogether.domain.auth.service.dto.ReissuedTokenDto;
+import com.votogether.domain.auth.service.dto.TokenPayloadDto;
+import com.votogether.domain.auth.service.oauth.KakaoOAuthClient;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.service.MemberService;
+import com.votogether.global.exception.BadRequestException;
+import com.votogether.global.jwt.TokenPayload;
+import com.votogether.global.jwt.TokenProcessor;
+import com.votogether.global.jwt.exception.JsonException;
+import java.util.Objects;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class AuthService {
+
+ private final KakaoOAuthClient kakaoOAuthClient;
+ private final MemberService memberService;
+ private final TokenProcessor tokenProcessor;
+ private final RedisTemplate redisTemplate;
+
+ @Transactional
+ public LoginTokenDto register(final String code) {
+ final String kakaoAccessToken = kakaoOAuthClient.getAccessToken(code);
+ final KakaoMemberResponse response = kakaoOAuthClient.getMemberInfo(kakaoAccessToken);
+
+ final Member member = Member.from(response);
+ final Member registeredMember = memberService.register(member);
+ final String accessToken = tokenProcessor.generateAccessToken(registeredMember.getId());
+ final String refreshToken = tokenProcessor.generateRefreshToken(registeredMember.getId());
+ redisTemplate.opsForValue().set(refreshToken, registeredMember.getId());
+ return new LoginTokenDto(accessToken, refreshToken, registeredMember.hasEssentialInfo());
+ }
+
+ @Transactional
+ public ReissuedTokenDto reissueAuthToken(
+ final AccessTokenRequest request,
+ final String refreshTokenByRequest
+ ) {
+ tokenProcessor.validateToken(refreshTokenByRequest);
+
+ final TokenPayloadDto tokenPayloadDto = parseTokens(request.accessToken(), refreshTokenByRequest);
+ final TokenPayload accessTokenPayload = tokenPayloadDto.accessTokenPayload();
+ final TokenPayload refreshTokenPayload = tokenPayloadDto.refreshTokenPayload();
+
+ redisTemplate.delete(refreshTokenByRequest);
+ validateTokenInfo(accessTokenPayload, refreshTokenPayload);
+
+ final String newAccessToken = tokenProcessor.generateAccessToken(accessTokenPayload.memberId());
+ final String newRefreshToken = tokenProcessor.generateRefreshToken(accessTokenPayload.memberId());
+ redisTemplate.opsForValue().set(newRefreshToken, accessTokenPayload.memberId());
+ return new ReissuedTokenDto(newAccessToken, newRefreshToken);
+ }
+
+ private void validateTokenInfo(final TokenPayload accessTokenPayload, final TokenPayload refreshTokenPayload) {
+ if (!Objects.equals(accessTokenPayload.memberId(), refreshTokenPayload.memberId())) {
+ throw new BadRequestException(AuthExceptionType.UNMATCHED_INFORMATION_BETWEEN_TOKEN);
+ }
+ }
+
+ private TokenPayloadDto parseTokens(final String accessToken, final String refreshToken) {
+ final TokenPayload accessTokenPayload;
+ final TokenPayload refreshTokenPayload;
+ try {
+ accessTokenPayload = tokenProcessor.parseToken(accessToken);
+ refreshTokenPayload = tokenProcessor.parseToken(refreshToken);
+ } catch (final JsonProcessingException e) {
+ throw new BadRequestException(JsonException.UNEXPECTED_EXCEPTION);
+ }
+ return new TokenPayloadDto(accessTokenPayload, refreshTokenPayload);
+ }
+
+ @Transactional
+ public void deleteRefreshToken(final String refreshTokenByRequest) {
+ redisTemplate.delete(refreshTokenByRequest);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/service/dto/LoginTokenDto.java b/backend/src/main/java/com/votogether/domain/auth/service/dto/LoginTokenDto.java
new file mode 100644
index 000000000..55c0ee7e7
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/service/dto/LoginTokenDto.java
@@ -0,0 +1,8 @@
+package com.votogether.domain.auth.service.dto;
+
+public record LoginTokenDto(
+ String accessToken,
+ String refreshToken,
+ boolean hasEssentialInfo
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/service/dto/ReissuedTokenDto.java b/backend/src/main/java/com/votogether/domain/auth/service/dto/ReissuedTokenDto.java
new file mode 100644
index 000000000..786483628
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/service/dto/ReissuedTokenDto.java
@@ -0,0 +1,7 @@
+package com.votogether.domain.auth.service.dto;
+
+public record ReissuedTokenDto(
+ String accessToken,
+ String refreshToken
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/service/dto/TokenPayloadDto.java b/backend/src/main/java/com/votogether/domain/auth/service/dto/TokenPayloadDto.java
new file mode 100644
index 000000000..6e956282d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/service/dto/TokenPayloadDto.java
@@ -0,0 +1,9 @@
+package com.votogether.domain.auth.service.dto;
+
+import com.votogether.global.jwt.TokenPayload;
+
+public record TokenPayloadDto(
+ TokenPayload accessTokenPayload,
+ TokenPayload refreshTokenPayload
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/auth/service/oauth/KakaoOAuthClient.java b/backend/src/main/java/com/votogether/domain/auth/service/oauth/KakaoOAuthClient.java
new file mode 100644
index 000000000..d1ff2b8eb
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/auth/service/oauth/KakaoOAuthClient.java
@@ -0,0 +1,58 @@
+package com.votogether.domain.auth.service.oauth;
+
+import com.votogether.domain.auth.dto.response.KakaoMemberResponse;
+import com.votogether.domain.auth.dto.response.OAuthAccessTokenResponse;
+import lombok.Getter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+@Getter
+@ConfigurationProperties(prefix = "oauth.kakao")
+@Component
+public class KakaoOAuthClient {
+
+ private static final RestTemplate restTemplate = new RestTemplate();
+
+ private final MultiValueMap info = new LinkedMultiValueMap<>();
+
+ public String getAccessToken(final String code) {
+ info.remove("code");
+ info.add("code", code);
+
+ final HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ final HttpEntity> httpEntity = new HttpEntity<>(info, headers);
+
+ final OAuthAccessTokenResponse response = restTemplate.postForEntity(
+ "https://kauth.kakao.com/oauth/token",
+ httpEntity,
+ OAuthAccessTokenResponse.class
+ ).getBody();
+ return response.accessToken();
+ }
+
+ public KakaoMemberResponse getMemberInfo(final String accessToken) {
+ final HttpHeaders headers = new HttpHeaders();
+ headers.setBearerAuth(accessToken);
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ final HttpEntity request = new HttpEntity<>(headers);
+
+ final KakaoMemberResponse response = restTemplate.exchange(
+ "https://kapi.kakao.com/v2/user/me",
+ HttpMethod.GET,
+ request,
+ KakaoMemberResponse.class
+ ).getBody();
+ return response;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java
new file mode 100644
index 000000000..462d1e50e
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java
@@ -0,0 +1,49 @@
+package com.votogether.domain.category.contorller;
+
+import com.votogether.domain.category.dto.response.CategoryResponse;
+import com.votogether.domain.category.service.CategoryService;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.global.jwt.Auth;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RequestMapping("/categories")
+@RestController
+public class CategoryController implements CategoryControllerDocs {
+
+ private final CategoryService categoryService;
+
+ @GetMapping("/guest")
+ public ResponseEntity> getAllCategories() {
+ final List categories = categoryService.getAllCategories();
+ return ResponseEntity.status(HttpStatus.OK).body(categories);
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllCategories(@Auth final Member member) {
+ final List categories = categoryService.getAllCategories(member);
+ return ResponseEntity.status(HttpStatus.OK).body(categories);
+ }
+
+ @PostMapping("/{categoryId}/like")
+ public ResponseEntity addFavoriteCategory(@PathVariable final Long categoryId, @Auth final Member member) {
+ categoryService.addFavoriteCategory(member, categoryId);
+ return ResponseEntity.status(HttpStatus.CREATED).build();
+ }
+
+ @DeleteMapping("/{categoryId}/like")
+ public ResponseEntity removeFavoriteCategory(@PathVariable final Long categoryId, @Auth final Member member) {
+ categoryService.removeFavoriteCategory(member, categoryId);
+ return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/category/contorller/CategoryControllerDocs.java b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryControllerDocs.java
new file mode 100644
index 000000000..1db511911
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryControllerDocs.java
@@ -0,0 +1,66 @@
+package com.votogether.domain.category.contorller;
+
+import com.votogether.domain.category.dto.response.CategoryResponse;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "카테고리", description = "카테고리 API")
+public interface CategoryControllerDocs {
+
+ @Operation(summary = "[비회원] 전체 카테고리 목록 조회", description = "[비회원] 전체 카테고리 목록을 조회한다.")
+ @ApiResponse(responseCode = "200", description = "전체 카테고리 목록 조회 성공")
+ ResponseEntity> getAllCategories();
+
+ @Operation(summary = "[회원] 전체 카테고리 목록 조회", description = "[회원] 전체 카테고리 목록을 조회한다.")
+ @ApiResponse(responseCode = "200", description = "전체 카테고리 목록 조회 성공")
+ ResponseEntity> getAllCategories(final Member member);
+
+ @Operation(summary = "선호 카테고리 추가", description = "선호 카테고리를 추가한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "201", description = "선호 카테고리 추가 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "중복된 선호 카테고리",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 카테고리",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ })
+ ResponseEntity addFavoriteCategory(
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId,
+ final Member member
+ );
+
+ @Operation(summary = "선호 카테고리 삭제", description = "선호하는 카테고리 삭제한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "204", description = "선호 카테고리 삭제 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "선호하지 않는 카테고리",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 카테고리",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity removeFavoriteCategory(
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId,
+ final Member member
+ );
+
+}
+
diff --git a/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java b/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java
new file mode 100644
index 000000000..a685c499b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java
@@ -0,0 +1,27 @@
+package com.votogether.domain.category.dto.response;
+
+import com.votogether.domain.category.entity.Category;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+@Schema(description = "카테고리 응답")
+public record CategoryResponse(
+ @Schema(description = "카테고리 ID", example = "1")
+ Long id,
+
+ @Schema(description = "카테고리 이름", example = "개발")
+ String name,
+
+ @Schema(description = "선호 여부", example = "true")
+ boolean isFavorite
+) {
+
+ public CategoryResponse(final Category category, final boolean isFavorite) {
+ this(category.getId(), category.getName(), isFavorite);
+ }
+
+ public CategoryResponse(final Category category, final List favoriteCategories) {
+ this(category.getId(), category.getName(), favoriteCategories.contains(category));
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/category/entity/Category.java b/backend/src/main/java/com/votogether/domain/category/entity/Category.java
new file mode 100644
index 000000000..2ac63a760
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/entity/Category.java
@@ -0,0 +1,33 @@
+package com.votogether.domain.category.entity;
+
+import com.votogether.domain.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode(of = {"id"})
+@Getter
+public class Category extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(length = 50, unique = true, nullable = false)
+ private String name;
+
+ @Builder
+ private Category(final String name) {
+ this.name = name;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/category/repository/CategoryRepository.java b/backend/src/main/java/com/votogether/domain/category/repository/CategoryRepository.java
new file mode 100644
index 000000000..63648d471
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/repository/CategoryRepository.java
@@ -0,0 +1,7 @@
+package com.votogether.domain.category.repository;
+
+import com.votogether.domain.category.entity.Category;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface CategoryRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java b/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java
new file mode 100644
index 000000000..ed0b5a84d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java
@@ -0,0 +1,75 @@
+package com.votogether.domain.category.service;
+
+import com.votogether.domain.category.dto.response.CategoryResponse;
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.category.repository.CategoryRepository;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.entity.MemberCategory;
+import com.votogether.domain.member.repository.MemberCategoryRepository;
+import java.util.Comparator;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class CategoryService {
+
+ private final CategoryRepository categoryRepository;
+ private final MemberCategoryRepository memberCategoryRepository;
+
+ @Transactional(readOnly = true)
+ public List getAllCategories() {
+ final List categories = categoryRepository.findAll();
+
+ return categories.stream()
+ .sorted(Comparator.comparing(Category::getName))
+ .map(category -> new CategoryResponse(category, false))
+ .toList();
+ }
+
+ @Transactional
+ public void addFavoriteCategory(final Member member, final Long categoryId) {
+ final Category category = categoryRepository.findById(categoryId)
+ .orElseThrow(() -> new IllegalArgumentException("해당 카테고리가 존재하지 않습니다."));
+
+ memberCategoryRepository.findByMemberAndCategory(member, category)
+ .ifPresent(ignore -> {
+ throw new IllegalStateException("이미 선호 카테고리에 등록되어 있습니다.");
+ });
+
+ final MemberCategory memberCategory = MemberCategory.builder()
+ .member(member)
+ .category(category)
+ .build();
+
+ memberCategoryRepository.save(memberCategory);
+ }
+
+ @Transactional
+ public void removeFavoriteCategory(final Member member, final Long categoryId) {
+ final Category category = categoryRepository.findById(categoryId)
+ .orElseThrow(() -> new IllegalArgumentException("해당 카테고리가 존재하지 않습니다."));
+ final MemberCategory memberCategory = memberCategoryRepository.findByMemberAndCategory(member, category)
+ .orElseThrow(() -> new IllegalArgumentException("해당 카테고리는 선호 카테고리가 아닙니다."));
+
+ memberCategoryRepository.delete(memberCategory);
+ }
+
+ @Transactional(readOnly = true)
+ public List getAllCategories(final Member member) {
+ final List categories = categoryRepository.findAll();
+ final List memberCategories = memberCategoryRepository.findAllByMember(member);
+
+ final List favoriteCategories = memberCategories.stream()
+ .map(MemberCategory::getCategory)
+ .toList();
+
+ return categories.stream()
+ .sorted(Comparator.comparing(Category::getName))
+ .map(category -> new CategoryResponse(category, favoriteCategories))
+ .toList();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/common/BaseEntity.java b/backend/src/main/java/com/votogether/domain/common/BaseEntity.java
new file mode 100644
index 000000000..ffb4f2a42
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/common/BaseEntity.java
@@ -0,0 +1,25 @@
+package com.votogether.domain.common;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import java.time.LocalDateTime;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntity {
+
+ @CreatedDate
+ @Column(columnDefinition = "datetime(6)", updatable = false, nullable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(columnDefinition = "datetime(6)", nullable = false)
+ private LocalDateTime updatedAt;
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckController.java b/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckController.java
new file mode 100644
index 000000000..a155b4b12
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckController.java
@@ -0,0 +1,23 @@
+package com.votogether.domain.healthcheck.controller;
+
+import com.votogether.domain.healthcheck.service.HealthCheckService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RequestMapping("/health-check")
+@RestController
+public class HealthCheckController implements HealthCheckControllerDocs {
+
+ private final HealthCheckService healthCheckService;
+
+ @GetMapping
+ public ResponseEntity check() {
+ final String response = healthCheckService.check();
+ return ResponseEntity.ok(response);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckControllerDocs.java b/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckControllerDocs.java
new file mode 100644
index 000000000..d39ecdedf
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/healthcheck/controller/HealthCheckControllerDocs.java
@@ -0,0 +1,15 @@
+package com.votogether.domain.healthcheck.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "헬스 체크", description = "헬스 체크 API")
+public interface HealthCheckControllerDocs {
+
+ @Operation(summary = "헬스 체크", description = "헬스 체크를 한다.")
+ @ApiResponse(responseCode = "200", description = "헬스 체크 성공")
+ ResponseEntity check();
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/healthcheck/service/HealthCheckService.java b/backend/src/main/java/com/votogether/domain/healthcheck/service/HealthCheckService.java
new file mode 100644
index 000000000..0323f2875
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/healthcheck/service/HealthCheckService.java
@@ -0,0 +1,12 @@
+package com.votogether.domain.healthcheck.service;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class HealthCheckService {
+
+ public String check() {
+ return "health-check";
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/controller/MemberController.java b/backend/src/main/java/com/votogether/domain/member/controller/MemberController.java
new file mode 100644
index 000000000..86c84b39f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/controller/MemberController.java
@@ -0,0 +1,55 @@
+package com.votogether.domain.member.controller;
+
+import com.votogether.domain.member.dto.request.MemberDetailRequest;
+import com.votogether.domain.member.dto.request.MemberNicknameUpdateRequest;
+import com.votogether.domain.member.dto.response.MemberInfoResponse;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.service.MemberService;
+import com.votogether.global.jwt.Auth;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RequestMapping("/members")
+@RestController
+public class MemberController implements MemberControllerDocs {
+
+ private final MemberService memberService;
+
+ @GetMapping("/me")
+ public ResponseEntity findMemberInfo(@Auth final Member member) {
+ return ResponseEntity.ok(memberService.findMemberInfo(member));
+ }
+
+ @PatchMapping("/me/nickname")
+ public ResponseEntity changeNickname(
+ @Valid @RequestBody final MemberNicknameUpdateRequest request,
+ @Auth final Member member
+ ) {
+ memberService.changeNickname(member, request.nickname());
+ return ResponseEntity.ok().build();
+ }
+
+ @PatchMapping("/me/detail")
+ public ResponseEntity updateDetails(
+ @Valid @RequestBody final MemberDetailRequest request,
+ @Auth final Member member
+ ) {
+ memberService.updateDetails(request, member);
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/me/delete")
+ public ResponseEntity deleteMember(@Auth final Member member) {
+ memberService.deleteMember(member);
+ return ResponseEntity.noContent().build();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/controller/MemberControllerDocs.java b/backend/src/main/java/com/votogether/domain/member/controller/MemberControllerDocs.java
new file mode 100644
index 000000000..082b424d0
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/controller/MemberControllerDocs.java
@@ -0,0 +1,55 @@
+package com.votogether.domain.member.controller;
+
+import com.votogether.domain.member.dto.request.MemberDetailRequest;
+import com.votogether.domain.member.dto.request.MemberNicknameUpdateRequest;
+import com.votogether.domain.member.dto.response.MemberInfoResponse;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "회원", description = "회원 API")
+public interface MemberControllerDocs {
+
+ @Operation(summary = "회원 정보 조회", description = "회원 정보를 조회한다.")
+ @ApiResponse(responseCode = "200", description = "회원 정보 조회 성공")
+ ResponseEntity findMemberInfo(final Member member);
+
+ @Operation(summary = "회원 닉네임 변경", description = "회원 닉네임을 변경한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "회원 닉네임 변경 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 닉네임 형식",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity changeNickname(
+ final MemberNicknameUpdateRequest request,
+ final Member member
+ );
+
+ @Operation(summary = "회원 상세 정보 변경", description = "회원의 상세 정보를 변경한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "회원 상세 정보 변경 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 상세 정보",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity updateDetails(
+ final MemberDetailRequest request,
+ final Member member
+ );
+
+ @Operation(summary = "회원 탈퇴", description = "회원 탈퇴한다.")
+ @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공")
+ ResponseEntity deleteMember(final Member member);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/dto/request/MemberDetailRequest.java b/backend/src/main/java/com/votogether/domain/member/dto/request/MemberDetailRequest.java
new file mode 100644
index 000000000..a59ede0fd
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/dto/request/MemberDetailRequest.java
@@ -0,0 +1,20 @@
+package com.votogether.domain.member.dto.request;
+
+import com.votogether.domain.member.entity.vo.Gender;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "회원 상세 정보 수정 요청")
+public record MemberDetailRequest(
+ @Schema(description = "성별", example = "MALE")
+ Gender gender,
+
+ @Schema(description = "출생년도", example = "2000")
+ @NotNull(message = "출생년도는 빈 값일 수 없습니다.")
+ @Min(value = 1800, message = "출생년도는 1800년 이상부터 가능합니다.")
+ @Max(value = 2100, message = "출생년도는 2100년 이하만 가능합니다.")
+ Integer birthYear
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/dto/request/MemberNicknameUpdateRequest.java b/backend/src/main/java/com/votogether/domain/member/dto/request/MemberNicknameUpdateRequest.java
new file mode 100644
index 000000000..649623b34
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/dto/request/MemberNicknameUpdateRequest.java
@@ -0,0 +1,12 @@
+package com.votogether.domain.member.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "닉네임 변경 요청")
+public record MemberNicknameUpdateRequest(
+ @Schema(description = "변경할 닉네임", example = "jeomxon")
+ @NotBlank(message = "닉네임은 빈값 혹은 공백이 포함될 수 없습니다.")
+ String nickname
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/dto/response/MemberInfoResponse.java b/backend/src/main/java/com/votogether/domain/member/dto/response/MemberInfoResponse.java
new file mode 100644
index 000000000..8c6247e70
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/dto/response/MemberInfoResponse.java
@@ -0,0 +1,23 @@
+package com.votogether.domain.member.dto.response;
+
+import com.votogether.domain.member.entity.vo.Gender;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "회원 정보 응답")
+public record MemberInfoResponse(
+ @Schema(description = "닉네임", example = "유저")
+ String nickname,
+
+ @Schema(description = "성별", example = "남성")
+ Gender gender,
+
+ @Schema(description = "출생년도", example = "2002")
+ Integer birthYear,
+
+ @Schema(description = "작성한 게시글 수", example = "5")
+ int postCount,
+
+ @Schema(description = "투표한 수", example = "10")
+ int voteCount
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/Member.java b/backend/src/main/java/com/votogether/domain/member/entity/Member.java
new file mode 100644
index 000000000..e9360903b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/Member.java
@@ -0,0 +1,119 @@
+package com.votogether.domain.member.entity;
+
+import com.votogether.domain.auth.dto.response.KakaoMemberResponse;
+import com.votogether.domain.common.BaseEntity;
+import com.votogether.domain.member.entity.vo.Gender;
+import com.votogether.domain.member.entity.vo.Nickname;
+import com.votogether.domain.member.entity.vo.SocialType;
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.apache.commons.lang3.RandomStringUtils;
+
+@Table(indexes = {@Index(columnList = "socialId, socialType")})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode(of = {"id"})
+@ToString
+@Getter
+@Entity
+public class Member extends BaseEntity {
+
+ private static final String INITIAL_NICKNAME_PREFIX = "익명의손님";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Embedded
+ private Nickname nickname;
+
+ @Enumerated(value = EnumType.STRING)
+ @Column(length = 20)
+ private Gender gender;
+
+ @Column
+ private Integer birthYear;
+
+ @Enumerated(value = EnumType.STRING)
+ @Column(length = 20, nullable = false)
+ private SocialType socialType;
+
+ @Column(nullable = false)
+ private String socialId;
+
+ @Builder
+ private Member(
+ final String nickname,
+ final Gender gender,
+ final Integer birthYear,
+ final SocialType socialType,
+ final String socialId
+ ) {
+ this.nickname = new Nickname(nickname);
+ this.gender = gender;
+ this.birthYear = birthYear;
+ this.socialType = socialType;
+ this.socialId = socialId;
+ }
+
+ public static Member from(final KakaoMemberResponse response) {
+ return Member.builder()
+ .nickname(INITIAL_NICKNAME_PREFIX + RandomStringUtils.random(10, true, true))
+ .socialType(SocialType.KAKAO)
+ .socialId(String.valueOf(response.id()))
+ .build();
+ }
+
+ public void changeNicknameByCycle(final String nickname, final Long days) {
+ if (nickname.startsWith(INITIAL_NICKNAME_PREFIX)) {
+ throw new BadRequestException(MemberExceptionType.NOT_ALLOWED_INITIAL_NICKNAME_PREFIX);
+ }
+ if (isNotPassedChangingCycle(days) && isNotInitialNickname()) {
+ throw new BadRequestException(MemberExceptionType.NOT_PASSED_NICKNAME_CHANGING_CYCLE);
+ }
+ this.nickname = new Nickname(nickname);
+ }
+
+ private boolean isNotPassedChangingCycle(final Long days) {
+ return this.getUpdatedAt().isAfter(LocalDateTime.now().minusDays(days));
+ }
+
+ private boolean isNotInitialNickname() {
+ return this.nickname.nonStartsWith(INITIAL_NICKNAME_PREFIX);
+ }
+
+ public void changeNicknameByReport() {
+ final String reportedNickname = "Pause1" + RandomStringUtils.random(9, true, true);
+ this.nickname = new Nickname(reportedNickname);
+ }
+
+ public void updateDetails(final Gender gender, final Integer birthYear) {
+ this.gender = gender;
+ this.birthYear = birthYear;
+ }
+
+ public boolean hasEssentialInfo() {
+ return (this.gender != null && this.birthYear != null);
+ }
+
+ public String getNickname() {
+ return this.nickname.getValue();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/MemberCategory.java b/backend/src/main/java/com/votogether/domain/member/entity/MemberCategory.java
new file mode 100644
index 000000000..3cc1af94e
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/MemberCategory.java
@@ -0,0 +1,43 @@
+package com.votogether.domain.member.entity;
+
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.common.BaseEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "category_id"})})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class MemberCategory extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "category_id", nullable = false)
+ private Category category;
+
+ @Builder
+ private MemberCategory(final Member member, final Category category) {
+ this.member = member;
+ this.category = category;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java
new file mode 100644
index 000000000..7e5e80846
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java
@@ -0,0 +1,45 @@
+package com.votogether.domain.member.entity.vo;
+
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.global.exception.BadRequestException;
+import java.util.Arrays;
+import lombok.Getter;
+
+@Getter
+public enum AgeRange {
+
+ UNDER_TEENS("10대 미만", 1, 9),
+ TEENS("10대", 10, 19),
+ TWENTIES("20대", 20, 29),
+ THIRTIES("30대", 30, 39),
+ FORTIES("40대", 40, 49),
+ FIFTIES("50대", 50, 59),
+ OVER_SIXTIES("60대 이상", 60, 999),
+ ;
+
+ private final String name;
+ private final int startAge;
+ private final int endAge;
+
+ AgeRange(
+ final String name,
+ final int startAge,
+ final int endAge
+ ) {
+ this.name = name;
+ this.startAge = startAge;
+ this.endAge = endAge;
+ }
+
+ public static AgeRange from(final int age) {
+ return Arrays.stream(AgeRange.values())
+ .filter(ageRange -> ageRange.isBelong(age))
+ .findAny()
+ .orElseThrow(() -> new BadRequestException(MemberExceptionType.INVALID_AGE));
+ }
+
+ private boolean isBelong(final int age) {
+ return this.startAge <= age && this.endAge >= age;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/Gender.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/Gender.java
new file mode 100644
index 000000000..77e12a1a2
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/Gender.java
@@ -0,0 +1,9 @@
+package com.votogether.domain.member.entity.vo;
+
+public enum Gender {
+
+ MALE,
+ FEMALE,
+ ;
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java
new file mode 100644
index 000000000..e4f397264
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java
@@ -0,0 +1,41 @@
+package com.votogether.domain.member.entity.vo;
+
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import java.util.regex.Pattern;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+public class Nickname {
+
+ private static final int MINIMUM_NICKNAME_LENGTH = 2;
+ private static final int MAXIMUM_NICKNAME_LENGTH = 15;
+
+ @Column(name = "nickname", length = 20, unique = true, nullable = false)
+ private String value;
+
+ public Nickname(final String nickname) {
+ validateNickname(nickname);
+ this.value = nickname;
+ }
+
+ private void validateNickname(final String nickname) {
+ if (nickname.length() < MINIMUM_NICKNAME_LENGTH || nickname.length() > MAXIMUM_NICKNAME_LENGTH) {
+ throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_LENGTH);
+ }
+ if (!Pattern.matches("^[가-힣a-zA-Z0-9]+$", nickname)) {
+ throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_LETTER);
+ }
+ }
+
+ public boolean nonStartsWith(final String prefix) {
+ return !this.value.startsWith(prefix);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/SocialType.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/SocialType.java
new file mode 100644
index 000000000..35a42ab51
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/SocialType.java
@@ -0,0 +1,8 @@
+package com.votogether.domain.member.entity.vo;
+
+public enum SocialType {
+
+ KAKAO,
+ ;
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/exception/MemberExceptionType.java b/backend/src/main/java/com/votogether/domain/member/exception/MemberExceptionType.java
new file mode 100644
index 000000000..67546d872
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/exception/MemberExceptionType.java
@@ -0,0 +1,28 @@
+package com.votogether.domain.member.exception;
+
+import com.votogether.global.exception.ExceptionType;
+import lombok.Getter;
+
+@Getter
+public enum MemberExceptionType implements ExceptionType {
+
+ INVALID_NICKNAME_LENGTH(800, "닉네임의 길이가 올바르지 않습니다."),
+ INVALID_NICKNAME_LETTER(801, "닉네임에 들어갈 수 없는 문자가 포함되어 있습니다."),
+ ALREADY_EXISTENT_NICKNAME(802, "이미 중복된 닉네임이 존재합니다."),
+ NONEXISTENT_MEMBER(803, "해당 회원이 존재하지 않습니다."),
+ INVALID_AGE(804, "존재할 수 없는 연령입니다."),
+ ALREADY_ASSIGNED_GENDER(805, "이미 성별이 할당되어 있습니다."),
+ ALREADY_ASSIGNED_BIRTH_YEAR(806, "이미 출생년도가 할당되어 있습니다."),
+ NOT_PASSED_NICKNAME_CHANGING_CYCLE(807, "최소 닉네임 변경주기가 지나지 않았습니다."),
+ NOT_ALLOWED_INITIAL_NICKNAME_PREFIX(808, "초기 닉네임에 포함된 접두어로 닉네임을 변경할 수 없습니다."),
+ ;
+
+ private final int code;
+ private final String message;
+
+ MemberExceptionType(final int code, final String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java b/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java
new file mode 100644
index 000000000..f2d11e12a
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java
@@ -0,0 +1,16 @@
+package com.votogether.domain.member.repository;
+
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.entity.MemberCategory;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MemberCategoryRepository extends JpaRepository {
+
+ Optional findByMemberAndCategory(final Member member, final Category category);
+
+ List findAllByMember(final Member member);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/votogether/domain/member/repository/MemberRepository.java
new file mode 100644
index 000000000..f4df26cd6
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/repository/MemberRepository.java
@@ -0,0 +1,15 @@
+package com.votogether.domain.member.repository;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.entity.vo.Nickname;
+import com.votogether.domain.member.entity.vo.SocialType;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MemberRepository extends JpaRepository {
+
+ Optional findBySocialIdAndSocialType(final String socialId, final SocialType socialType);
+
+ boolean existsByNickname(final Nickname nickname);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/member/service/MemberService.java b/backend/src/main/java/com/votogether/domain/member/service/MemberService.java
new file mode 100644
index 000000000..55705f9db
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/member/service/MemberService.java
@@ -0,0 +1,191 @@
+package com.votogether.domain.member.service;
+
+import com.votogether.domain.member.dto.request.MemberDetailRequest;
+import com.votogether.domain.member.dto.response.MemberInfoResponse;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.entity.MemberCategory;
+import com.votogether.domain.member.entity.vo.Nickname;
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.domain.member.repository.MemberCategoryRepository;
+import com.votogether.domain.member.repository.MemberRepository;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.comment.Comment;
+import com.votogether.domain.post.repository.CommentRepository;
+import com.votogether.domain.post.repository.PostRepository;
+import com.votogether.domain.report.entity.Report;
+import com.votogether.domain.report.entity.vo.ReportType;
+import com.votogether.domain.report.repository.ReportRepository;
+import com.votogether.domain.vote.entity.Vote;
+import com.votogether.domain.vote.repository.VoteRepository;
+import com.votogether.global.exception.BadRequestException;
+import com.votogether.global.exception.NotFoundException;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class MemberService {
+
+ private static final Long NICKNAME_CHANGING_CYCLE = 14L;
+
+ private final MemberRepository memberRepository;
+ private final MemberCategoryRepository memberCategoryRepository;
+ private final PostRepository postRepository;
+ private final VoteRepository voteRepository;
+ private final ReportRepository reportRepository;
+ private final CommentRepository commentRepository;
+
+ @Transactional
+ public Member register(final Member member) {
+ final Optional maybeMember = memberRepository.findBySocialIdAndSocialType(
+ member.getSocialId(),
+ member.getSocialType()
+ );
+ return maybeMember.orElseGet(() -> memberRepository.save(member));
+ }
+
+ @Transactional(readOnly = true)
+ public Member findById(final Long memberId) {
+ return memberRepository.findById(memberId)
+ .orElseThrow(() -> new NotFoundException(MemberExceptionType.NONEXISTENT_MEMBER));
+ }
+
+ @Transactional(readOnly = true)
+ public MemberInfoResponse findMemberInfo(final Member member) {
+ final int numberOfPosts = postRepository.countByWriter(member);
+ final int numberOfVotes = voteRepository.countByMember(member);
+
+ return new MemberInfoResponse(
+ member.getNickname(),
+ member.getGender(),
+ member.getBirthYear(),
+ numberOfPosts,
+ numberOfVotes
+ );
+ }
+
+ @Transactional
+ public void changeNickname(final Member member, final String nickname) {
+ validateExistentNickname(nickname);
+ member.changeNicknameByCycle(nickname, NICKNAME_CHANGING_CYCLE);
+ }
+
+ private void validateExistentNickname(final String nickname) {
+ final boolean isExist = memberRepository.existsByNickname(new Nickname(nickname));
+ if (isExist) {
+ throw new BadRequestException(MemberExceptionType.ALREADY_EXISTENT_NICKNAME);
+ }
+ }
+
+ @Transactional
+ public void updateDetails(final MemberDetailRequest request, final Member member) {
+ validateExistentDetails(member);
+ member.updateDetails(request.gender(), request.birthYear());
+ }
+
+ private void validateExistentDetails(final Member member) {
+ if (member.getGender() != null) {
+ throw new BadRequestException(MemberExceptionType.ALREADY_ASSIGNED_GENDER);
+ }
+ if (member.getBirthYear() != null) {
+ throw new BadRequestException(MemberExceptionType.ALREADY_ASSIGNED_BIRTH_YEAR);
+ }
+ }
+
+ @Transactional
+ public void deleteMember(final Member member) {
+ final List posts = deletePosts(member);
+ final List comments = deleteComments(member);
+ deleteVotes(member);
+ deleteMemberCategories(member);
+ deleteReports(member, posts, comments);
+
+ memberRepository.delete(member);
+ }
+
+ private List deletePosts(final Member member) {
+ final List posts = postRepository.findAllByWriter(member);
+ final List postIds = posts.stream()
+ .map(Post::getId)
+ .toList();
+ postRepository.deleteAllById(postIds);
+ return posts;
+ }
+
+ private List deleteComments(final Member member) {
+ final List comments = commentRepository.findAllByMember(member);
+ final List commentIds = comments.stream()
+ .map(Comment::getId)
+ .toList();
+ commentRepository.deleteAllById(commentIds);
+ return comments;
+ }
+
+ private void deleteVotes(final Member member) {
+ final List voteIds = voteRepository.findAllByMember(member)
+ .stream()
+ .map(Vote::getId)
+ .toList();
+ voteRepository.deleteAllById(voteIds);
+ }
+
+ private void deleteMemberCategories(final Member member) {
+ final List memberCategoryIds = memberCategoryRepository.findAllByMember(member)
+ .stream()
+ .map(MemberCategory::getId)
+ .toList();
+ memberCategoryRepository.deleteAllById(memberCategoryIds);
+ }
+
+ private void deleteReports(
+ final Member member,
+ final List posts,
+ final List comments
+ ) {
+ deleteReportByPost(posts);
+ deleteReportByComment(comments);
+ deleteReportByNickname(member);
+ deleteReportByMember(member);
+ }
+
+ private void deleteReportByPost(final List posts) {
+ for (final Post post : posts) {
+ final List reportIds = reportRepository.findAllByReportTypeAndTargetId(ReportType.POST, post.getId())
+ .stream()
+ .map(Report::getId)
+ .toList();
+ reportRepository.deleteAllById(reportIds);
+ }
+ }
+
+ private void deleteReportByComment(final List comments) {
+ for (final Comment comment : comments) {
+ final List reportIds =
+ reportRepository.findAllByReportTypeAndTargetId(ReportType.COMMENT, comment.getId())
+ .stream()
+ .map(Report::getId)
+ .toList();
+ reportRepository.deleteAllById(reportIds);
+ }
+ }
+
+ private void deleteReportByNickname(final Member member) {
+ final List reportIdsByTargetId =
+ reportRepository.findAllByReportTypeAndTargetId(ReportType.NICKNAME, member.getId())
+ .stream()
+ .map(Report::getId)
+ .toList();
+ reportRepository.deleteAllById(reportIdsByTargetId);
+ }
+
+ private void deleteReportByMember(final Member member) {
+ final List reportIdsByMember = reportRepository.findAllByMember(member)
+ .stream()
+ .map(Report::getId)
+ .toList();
+ reportRepository.deleteAllById(reportIdsByMember);
+ }
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java
new file mode 100644
index 000000000..0d2ef6e6b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java
@@ -0,0 +1,67 @@
+package com.votogether.domain.post.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest;
+import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest;
+import com.votogether.domain.post.dto.response.comment.CommentResponse;
+import com.votogether.domain.post.service.PostCommentService;
+import com.votogether.global.jwt.Auth;
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RequestMapping("/posts")
+@RestController
+public class PostCommentController implements PostCommentControllerDocs {
+
+ private final PostCommentService postCommentService;
+
+ @GetMapping("/{postId}/comments")
+ public ResponseEntity> getComments(@PathVariable final Long postId) {
+ final List response = postCommentService.getComments(postId);
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping("/{postId}/comments")
+ public ResponseEntity createComment(
+ @PathVariable final Long postId,
+ @Valid @RequestBody CommentRegisterRequest commentRegisterRequest,
+ @Auth final Member member
+ ) {
+ postCommentService.createComment(member, postId, commentRegisterRequest);
+ return ResponseEntity.status(HttpStatus.CREATED).build();
+ }
+
+ @PutMapping("/{postId}/comments/{commentId}")
+ public ResponseEntity updateComment(
+ @PathVariable final Long postId,
+ @PathVariable final Long commentId,
+ @RequestBody @Valid final CommentUpdateRequest commentUpdateRequest,
+ @Auth final Member member
+ ) {
+ postCommentService.updateComment(postId, commentId, commentUpdateRequest, member);
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/{postId}/comments/{commentId}")
+ public ResponseEntity deleteComment(
+ @PathVariable final Long postId,
+ @PathVariable final Long commentId,
+ @Auth final Member member
+ ) {
+ postCommentService.deleteComment(postId, commentId, member);
+ return ResponseEntity.noContent().build();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java
new file mode 100644
index 000000000..fecb9b3d8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java
@@ -0,0 +1,90 @@
+package com.votogether.domain.post.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest;
+import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest;
+import com.votogether.domain.post.dto.response.comment.CommentResponse;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "게시글 댓글", description = "게시글 댓글 API")
+public interface PostCommentControllerDocs {
+
+ @Operation(summary = "게시글 댓글 목록 조회", description = "게시글 댓글 목록을 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 댓글 목록 조회 성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 게시글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> getComments(
+ @Parameter(description = "댓글 작성 게시글 ID", example = "1") final Long postId
+ );
+
+ @Operation(summary = "게시글 댓글 작성", description = "게시글 댓글을 작성한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 댓글 작성 성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 게시글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity createComment(
+ @Parameter(description = "댓글 작성 게시글 ID", example = "1") final Long postId,
+ final CommentRegisterRequest commentRegisterRequest,
+ final Member member
+ );
+
+ @Operation(summary = "게시글 댓글 수정", description = "게시글 댓글을 수정한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 댓글 수정 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "1.게시글에 속하지 않은 댓글\t\n2.올바르지 않은 댓글 작성자",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 댓글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity updateComment(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ @Parameter(description = "댓글 ID", example = "1") final Long commentId,
+ final CommentUpdateRequest commentUpdateRequest,
+ final Member member
+ );
+
+ @Operation(summary = "게시글 댓글 삭제", description = "게시글 댓글을 삭제한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "204", description = "게시글 댓글 삭제 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "1.게시글에 속하지 않은 댓글\t\n2.올바르지 않은 댓글 작성자",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 댓글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity deleteComment(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ @Parameter(description = "댓글 ID", example = "1") final Long commentId,
+ final Member member
+ );
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostController.java
new file mode 100644
index 000000000..076fc86c0
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/controller/PostController.java
@@ -0,0 +1,227 @@
+package com.votogether.domain.post.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.request.post.PostCreateRequest;
+import com.votogether.domain.post.dto.request.post.PostUpdateRequest;
+import com.votogether.domain.post.dto.response.post.PostDetailResponse;
+import com.votogether.domain.post.dto.response.post.PostRankingResponse;
+import com.votogether.domain.post.dto.response.post.PostResponse;
+import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse;
+import com.votogether.domain.post.entity.vo.PostClosingType;
+import com.votogether.domain.post.entity.vo.PostSortType;
+import com.votogether.domain.post.service.PostService;
+import com.votogether.global.jwt.Auth;
+import jakarta.validation.Valid;
+import java.net.URI;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+@RequiredArgsConstructor
+@RequestMapping("/posts")
+@RestController
+public class PostController implements PostControllerDocs {
+
+ private final PostService postService;
+
+ @GetMapping("/search")
+ public ResponseEntity> searchPostsWithKeyword(
+ @RequestParam final String keyword,
+ @RequestParam final int page,
+ @RequestParam final PostClosingType postClosingType,
+ @RequestParam final PostSortType postSortType,
+ @RequestParam(required = false, name = "category") final Long categoryId,
+ @Auth final Member member
+ ) {
+ final List responses =
+ postService.searchPostsWithKeyword(keyword, page, postClosingType, postSortType, categoryId, member);
+ return ResponseEntity.ok(responses);
+ }
+
+ @GetMapping("/search/guest")
+ public ResponseEntity> searchPostsWithKeywordForGuest(
+ @RequestParam final String keyword,
+ @RequestParam final int page,
+ @RequestParam final PostClosingType postClosingType,
+ @RequestParam final PostSortType postSortType,
+ @RequestParam(required = false, name = "category") final Long categoryId
+ ) {
+ final List responses =
+ postService.searchPostsWithKeywordForGuest(keyword, page, postClosingType, postSortType, categoryId);
+ return ResponseEntity.ok(responses);
+ }
+
+ @GetMapping("/me")
+ public ResponseEntity> getPostsByMe(
+ @RequestParam final int page,
+ @RequestParam final PostClosingType postClosingType,
+ @RequestParam final PostSortType postSortType,
+ @RequestParam(required = false, name = "category") final Long categoryId,
+ @Auth final Member member
+ ) {
+ final List responses =
+ postService.getPostsByWriter(page, postClosingType, postSortType, categoryId, member);
+ return ResponseEntity.ok(responses);
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllPost(
+ @RequestParam final int page,
+ @RequestParam final PostClosingType postClosingType,
+ @RequestParam final PostSortType postSortType,
+ @RequestParam(name = "category", required = false) final Long categoryId,
+ @Auth final Member member
+ ) {
+ final List responses = postService.getAllPostBySortTypeAndClosingTypeAndCategoryId(
+ page,
+ postClosingType,
+ postSortType,
+ categoryId,
+ member
+ );
+ return ResponseEntity.ok(responses);
+ }
+
+ @GetMapping("/guest")
+ public ResponseEntity> getPostsGuest(
+ @RequestParam final int page,
+ @RequestParam final PostClosingType postClosingType,
+ @RequestParam final PostSortType postSortType,
+ @RequestParam(required = false, name = "category") final Long categoryId
+ ) {
+ final List response = postService.getPostsGuest(page, postClosingType, postSortType, categoryId);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("{postId}")
+ public ResponseEntity getPost(
+ @PathVariable final Long postId,
+ @Auth final Member member
+ ) {
+ final PostDetailResponse response = postService.getPostById(postId, member);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("{postId}/guest")
+ public ResponseEntity getPostByGuest(
+ @PathVariable final Long postId
+ ) {
+ final PostDetailResponse response = postService.getPostById(postId, null);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/{postId}/options")
+ public ResponseEntity getVoteStatistics(
+ @PathVariable final Long postId,
+ @Auth final Member member
+ ) {
+ final VoteOptionStatisticsResponse response = postService.getVoteStatistics(postId, member);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/{postId}/options/{optionId}")
+ public ResponseEntity getVoteOptionStatistics(
+ @PathVariable final Long postId,
+ @PathVariable final Long optionId,
+ @Auth final Member member
+ ) {
+ final VoteOptionStatisticsResponse response = postService.getVoteOptionStatistics(postId, optionId, member);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/votes/me")
+ public ResponseEntity> getPostsVotedByMe(
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ @Auth final Member member
+ ) {
+ final List posts = postService.getPostsVotedByMember(page, postClosingType, postSortType, member);
+ return ResponseEntity.status(HttpStatus.OK).body(posts);
+ }
+
+ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity save(
+ @RequestPart @Valid final PostCreateRequest request,
+ @RequestPart final List contentImages,
+ @RequestPart final List optionImages,
+ @Auth final Member member
+ ) {
+ System.out.println("PostController.save");
+
+ System.out.println("contentImages = " + contentImages);
+ if (contentImages != null && !contentImages.isEmpty()) {
+ System.out.println("contentImages = " + contentImages.get(0).getOriginalFilename());
+ }
+
+ System.out.println("optionImages = " + optionImages);
+ if (optionImages != null && !optionImages.isEmpty()) {
+ System.out.println("optionImages1 = " + optionImages.get(0).getOriginalFilename());
+ System.out.println("optionImages2 = " + optionImages.get(1).getOriginalFilename());
+ }
+ final Long postId = postService.save(request, member, contentImages, optionImages);
+ return ResponseEntity.created(URI.create("/posts/" + postId)).build();
+ }
+
+ @GetMapping("ranking/popular/guest")
+ public ResponseEntity> getRanking() {
+ final List responses = postService.getRanking();
+ return ResponseEntity.ok(responses);
+ }
+
+ @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity update(
+ @PathVariable final Long postId,
+ @RequestPart @Valid final PostUpdateRequest request,
+ @RequestPart final List contentImages,
+ @RequestPart final List optionImages,
+ @Auth final Member member
+ ) {
+ System.out.println("PostController.update");
+
+ System.out.println("contentImages = " + contentImages);
+ if (contentImages != null && !contentImages.isEmpty()) {
+ System.out.println("contentImages = " + contentImages.get(0).getOriginalFilename());
+ }
+
+ System.out.println("optionImages = " + optionImages);
+ if (optionImages != null && !optionImages.isEmpty()) {
+ System.out.println("optionImages1 = " + optionImages.get(0).getOriginalFilename());
+ System.out.println("optionImages2 = " + optionImages.get(1).getOriginalFilename());
+ }
+
+ postService.update(postId, request, member, contentImages, optionImages);
+ return ResponseEntity.ok().build();
+ }
+
+ @PatchMapping("/{postId}/close")
+ public ResponseEntity closePostEarly(
+ @PathVariable final Long postId,
+ @Auth final Member loginMember
+ ) {
+ postService.closePostEarlyById(postId, loginMember);
+ return ResponseEntity.ok().build();
+ }
+
+ @DeleteMapping("/{postId}")
+ public ResponseEntity delete(@PathVariable final Long postId) {
+ postService.delete(postId);
+ return ResponseEntity.noContent().build();
+ }
+
+}
+
+
diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java
new file mode 100644
index 000000000..e87b40d0f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java
@@ -0,0 +1,254 @@
+package com.votogether.domain.post.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.request.post.PostCreateRequest;
+import com.votogether.domain.post.dto.request.post.PostUpdateRequest;
+import com.votogether.domain.post.dto.response.post.PostDetailResponse;
+import com.votogether.domain.post.dto.response.post.PostRankingResponse;
+import com.votogether.domain.post.dto.response.post.PostResponse;
+import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse;
+import com.votogether.domain.post.entity.vo.PostClosingType;
+import com.votogether.domain.post.entity.vo.PostSortType;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.multipart.MultipartFile;
+
+@Tag(name = "게시글", description = "게시글 API")
+public interface PostControllerDocs {
+
+ @Operation(summary = "[회원] 전체 게시글 목록 조회", description = "[회원] 전체 게시글 목록을 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "전체 게시글 목록 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> getAllPost(
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId,
+ final Member member
+ );
+
+ @Operation(summary = "[비회원] 전체 게시글 목록 조회", description = "[비회원] 전체 게시글 목록을 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "전체 게시글 목록 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> getPostsGuest(
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId
+ );
+
+ @Operation(summary = "[회원] 게시글 상세 조회", description = "[회원] 게시글을 상세 조회 한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 상세 조회 성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 게시글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity getPost(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ final Member member
+ );
+
+ @Operation(summary = "[비회원] 게시글 상세 조회", description = "[비회원] 게시글을 상세 조회 한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 상세 조회 성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 게시글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity getPostByGuest(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId
+ );
+
+ @Operation(summary = "게시글 투표 통계 조회", description = "게시글 투표에 대한 전체 통계를 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 투표 통계 조회 성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 게시글",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity getVoteStatistics(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ final Member member
+ );
+
+ @Operation(summary = "게시글 투표 선택지 통계 조회", description = "게시글 특정 투표 선택지에 대한 통계를 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 투표 선택지 통계 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "게시글에 속하지 않는 투표 옵션",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 게시글 투표 옵션",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity getVoteOptionStatistics(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ @Parameter(description = "게시글 선택지 ID", example = "2") final Long optionId,
+ final Member member
+ );
+
+ @Operation(summary = "투표한 게시글 목록 조회", description = "투표한 게시글 목록을 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "투표한 게시글 목록 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> getPostsVotedByMe(
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ final Member member
+ );
+
+
+ @Operation(summary = "[회원] 게시글 검색", description = "[회원] 키워드를 통해 게시글을 검색한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 검색 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> searchPostsWithKeyword(
+ @Parameter(description = "검색 키워드", example = "취업") final String keyword,
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId,
+ final Member member
+ );
+
+ @Operation(summary = "[비회원] 게시글 검색", description = "[비회원] 키워드를 통해 게시글을 검색한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 검색 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> searchPostsWithKeywordForGuest(
+ @Parameter(description = "검색 키워드", example = "취업") final String keyword,
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId
+ );
+
+ @Operation(summary = "작성한 게시글 목록 조회", description = "작성한 게시글 목록을 조회한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "작성한 게시글 목록 조회 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity> getPostsByMe(
+ @Parameter(description = "현재 페이지 위치", example = "0") final int page,
+ @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType,
+ @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType,
+ @Parameter(description = "카테고리 ID", example = "1") final Long categoryId,
+ final Member member
+ );
+
+ @Operation(summary = "인기 게시글 랭킹 조회", description = "인기 게시글 랭킹을 조회한다.")
+ @ApiResponse(responseCode = "200", description = "인기 게시글 랭킹 조회 성공")
+ ResponseEntity> getRanking();
+
+ @Operation(summary = "게시글 작성", description = "게시글을 작성한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "201", description = "게시글 작성 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력입니다.",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity save(
+ final PostCreateRequest request,
+ final List contentImages,
+ final List optionImages,
+ final Member member
+ );
+
+ @Operation(summary = "게시글 수정", description = "게시글을 수정한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시글 수정 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity update(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ final PostUpdateRequest request,
+ final List contentImages,
+ final List optionImages,
+ final Member member
+ );
+
+ @Operation(summary = "게시글 조기 마감", description = "게시글을 조기 마감한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시물 조기 마감 성공."),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity closePostEarly(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId,
+ final Member loginMember
+ );
+
+ @Operation(summary = "게시글 삭제", description = "게시글을 삭제한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "게시물 삭제 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 입력",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity delete(
+ @Parameter(description = "게시글 ID", example = "1") final Long postId
+ );
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java
new file mode 100644
index 000000000..ef6f641f4
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java
@@ -0,0 +1,12 @@
+package com.votogether.domain.post.dto.request.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "댓글 작성 요청")
+public record CommentRegisterRequest(
+ @Schema(description = "댓글 내용", example = "hello")
+ @NotBlank(message = "댓글 내용은 존재해야 합니다.")
+ String content
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentUpdateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentUpdateRequest.java
new file mode 100644
index 000000000..2c8724e57
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentUpdateRequest.java
@@ -0,0 +1,12 @@
+package com.votogether.domain.post.dto.request.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "댓글 수정 요청")
+public record CommentUpdateRequest(
+ @Schema(description = "댓글 수정 내용", example = "content")
+ @NotBlank(message = "댓글 내용은 존재해야 합니다.")
+ String content
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java
new file mode 100644
index 000000000..4bfc21708
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java
@@ -0,0 +1,44 @@
+package com.votogether.domain.post.dto.request.post;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Builder;
+import org.hibernate.validator.constraints.Length;
+
+@Schema(description = "게시글 작성 요청")
+@Builder
+public record PostCreateRequest(
+ @Schema(description = "카테고리의 여러 아이디", example = "[1, 3]")
+ @Size(min = 1, message = "게시글에 해당하는 카테고리는 최소 1개 이상이어야 합니다.")
+ List categoryIds,
+
+ @Schema(description = "게시글 제목", example = "title")
+ @NotBlank(message = "제목을 입력해주세요.")
+ @Length(max = 100, message = "제목은 최대 100자까지 입력 가능합니다.")
+ String title,
+
+ @Schema(description = "게시글 내용", example = "content")
+ @NotBlank(message = "내용을 입력해주세요.")
+ @Length(max = 1000, message = "내용은 최대 1000자까지 입력 가능합니다.")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com")
+ String imageUrl,
+
+ @Schema(description = "게시글의 여러 선택지")
+ @Valid
+ @NotNull(message = "선택지는 최소 2개 이상 등록해야 합니다.")
+ @Size(min = 2, max = 5, message = "선택지는 최소 2개, 최대 5개까지 등록 가능합니다.")
+ List postOptions,
+
+ @Schema(description = "마감 기한", example = "2023-08-01 15:30")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime deadline
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java
new file mode 100644
index 000000000..f5c703126
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java
@@ -0,0 +1,19 @@
+package com.votogether.domain.post.dto.request.post;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Builder;
+import org.hibernate.validator.constraints.Length;
+
+@Schema(description = "게시글 선택지 요청")
+@Builder
+public record PostOptionCreateRequest(
+ @Schema(description = "선택지 내용", example = "content")
+ @NotBlank(message = "해당 선택지의 내용을 입력해주세요.")
+ @Length(max = 50, message = "선택지의 내용은 최대 50자까지 입력 가능합니다.")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com")
+ String imageUrl
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java
new file mode 100644
index 000000000..a321d2993
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java
@@ -0,0 +1,19 @@
+package com.votogether.domain.post.dto.request.post;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Builder;
+import org.hibernate.validator.constraints.Length;
+
+@Schema(description = "게시글 선택지 수정 요청")
+@Builder
+public record PostOptionUpdateRequest(
+ @Schema(description = "선택지 내용", example = "content")
+ @NotBlank(message = "해당 선택지의 내용을 입력해주세요.")
+ @Length(max = 50, message = "선택지의 내용은 최대 50자까지 입력 가능합니다.")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com")
+ String imageUrl
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java
new file mode 100644
index 000000000..1dfed74c8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java
@@ -0,0 +1,44 @@
+package com.votogether.domain.post.dto.request.post;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Builder;
+import org.hibernate.validator.constraints.Length;
+
+@Schema(description = "게시글 수정 요청")
+@Builder
+public record PostUpdateRequest(
+ @Schema(description = "카테고리의 여러 아이디", example = "[0, 2]")
+ @Size(min = 1, message = "게시글에 해당하는 카테고리는 최소 1개 이상이어야 합니다.")
+ List categoryIds,
+
+ @Schema(description = "게시글 제목", example = "title")
+ @NotBlank(message = "제목을 입력해주세요.")
+ @Length(max = 100, message = "제목은 최대 100자까지 입력 가능합니다.")
+ String title,
+
+ @Schema(description = "게시글 내용", example = "content")
+ @NotBlank(message = "내용을 입력해주세요.")
+ @Length(max = 1000, message = "내용은 최대 1000자까지 입력 가능합니다.")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com")
+ String imageUrl,
+
+ @Schema(description = "게시글의 여러 선택지")
+ @Valid
+ @NotNull(message = "선택지는 최소 2개 이상 등록해야 합니다.")
+ @Size(min = 2, max = 5, message = "선택지는 최소 2개, 최대 5개까지 등록 가능합니다.")
+ List postOptions,
+
+ @Schema(description = "마감 기한", example = "2023-08-01 15:30")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime deadline
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java
new file mode 100644
index 000000000..a1e14588f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java
@@ -0,0 +1,54 @@
+package com.votogether.domain.post.dto.response.comment;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.comment.Comment;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "댓글 응답")
+public record CommentResponse(
+ @Schema(description = "댓글 ID", example = "1")
+ Long id,
+
+ @Schema(description = "댓글 작성자 회원")
+ CommentMember member,
+
+ @Schema(description = "댓글 내용", example = "재밌어요!")
+ String content,
+
+ @Schema(description = "댓글 작성시각", example = "2023-08-01 10:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime createdAt,
+
+ @Schema(description = "댓글 수정시각", example = "2023-08-01 13:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime updatedAt
+) {
+
+ public static CommentResponse from(final Comment comment) {
+ return new CommentResponse(
+ comment.getId(),
+ CommentMember.from(comment.getMember()),
+ comment.getContent(),
+ comment.getCreatedAt(),
+ comment.getUpdatedAt()
+ );
+ }
+
+ @Schema(description = "댓글 작성자 회원")
+ record CommentMember(
+ @Schema(description = "댓글 작성자 회원 ID", example = "1")
+ Long id,
+
+ @Schema(description = "댓글 작성자 회원 닉네임", example = "votogether")
+ String nickname
+ ) {
+
+ public static CommentMember from(final Member member) {
+ return new CommentMember(member.getId(), member.getNickname());
+ }
+
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java
new file mode 100644
index 000000000..90635c851
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java
@@ -0,0 +1,19 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.votogether.domain.category.entity.Category;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "카테고리 응답")
+public record CategoryResponse(
+ @Schema(description = "카테고리 ID", example = "1")
+ Long id,
+
+ @Schema(description = "카테고리 이름", example = "개발")
+ String name
+) {
+
+ public static CategoryResponse of(Category category) {
+ return new CategoryResponse(category.getId(), category.getName());
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java
new file mode 100644
index 000000000..5ace2220e
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java
@@ -0,0 +1,97 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.response.vote.VoteDetailResponse;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.PostBody;
+import com.votogether.domain.post.entity.PostCategories;
+import com.votogether.domain.post.entity.PostCategory;
+import com.votogether.domain.post.entity.PostContentImage;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "게시글 상세 정보 응답")
+public record PostDetailResponse(
+ @Schema(description = "게시글 ID", example = "1")
+ Long postId,
+
+ @Schema(description = "작성자")
+ WriterResponse writer,
+
+ @Schema(description = "게시글 제목", example = "이거 한번 투표해주세요")
+ String title,
+
+ @Schema(description = "게시글 내용", example = "어떤게 더 맛있나요?")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdasd.com")
+ String imageUrl,
+
+ @Schema(description = "카테고리 목록", example = "[1,2]")
+ List categories,
+
+ @Schema(description = "게시글 생성시각", example = "2023-08-01 13:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime createdAt,
+
+ @Schema(description = "게시글 마감기한", example = "2023-08-01 13:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime deadline,
+
+ @Schema(description = "투표 통계 정보")
+ VoteDetailResponse voteInfo
+) {
+
+ public static PostDetailResponse of(final Post post, final Member loginMember) {
+ final Member writer = post.getWriter();
+ final PostBody postBody = post.getPostBody();
+ final List contentImages = postBody.getPostContentImages().getContentImages();
+ final StringBuilder contentImageUrl = new StringBuilder();
+
+ if (!contentImages.isEmpty()) {
+ contentImageUrl.append(contentImages.get(0).getImageUrl());
+ }
+
+ final PostCategories postCategories = post.getPostCategories();
+ return new PostDetailResponse(
+ post.getId(),
+ WriterResponse.of(writer.getId(), writer.getNickname()),
+ postBody.getTitle(),
+ postBody.getContent(),
+ contentImageUrl.toString(),
+ getCategories(postCategories.getPostCategories()),
+ post.getCreatedAt(),
+ post.getDeadline(),
+ VoteDetailResponse.of(
+ post.getSelectedOptionId(loginMember),
+ post.getFinalTotalVoteCount(loginMember),
+ getOptions(post, loginMember)
+ )
+ );
+ }
+
+ private static List getCategories(final List postCategories) {
+ return postCategories.stream()
+ .map(PostCategory::getCategory)
+ .map(CategoryResponse::of)
+ .toList();
+ }
+
+ private static List getOptions(
+ final Post post,
+ final Member loginMember
+ ) {
+ return post.getPostOptions().getPostOptions().stream()
+ .map(postOption ->
+ PostOptionDetailResponse.of(
+ postOption,
+ post.isVisibleVoteResult(loginMember),
+ post.getFinalTotalVoteCount(loginMember)
+ )
+ )
+ .toList();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java
new file mode 100644
index 000000000..8a4725bb8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java
@@ -0,0 +1,38 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.votogether.domain.post.entity.PostOption;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "게시글 선택지 정보 응답")
+public record PostOptionDetailResponse(
+ @Schema(description = "게시글 선택지 ID", example = "1")
+ Long optionId,
+
+ @Schema(description = "게시글 선택지 내용", example = "짜장면")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://sdasdas.com")
+ String imageUrl,
+
+ @Schema(description = "투표 개수", example = "4")
+ Integer voteCount,
+
+ @Schema(description = "투표한 비율", example = "50.0")
+ Double votePercent
+) {
+
+ public static PostOptionDetailResponse of(
+ final PostOption postOption,
+ final Boolean isVisibleVoteResult,
+ final Long totalVoteCount
+ ) {
+ return new PostOptionDetailResponse(
+ postOption.getId(),
+ postOption.getContent(),
+ postOption.getImageUrl(),
+ postOption.getVoteCount(isVisibleVoteResult),
+ postOption.getVotePercent(totalVoteCount)
+ );
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java
new file mode 100644
index 000000000..4212cf49a
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java
@@ -0,0 +1,55 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.PostOption;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "게시글 선택지 정보 응답")
+public record PostOptionResponse(
+ @Schema(description = "게시글 선택지 ID", example = "1")
+ Long optionId,
+
+ @Schema(description = "게시글 선택지 내용", example = "짜장면")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://sdasdas.com")
+ String imageUrl,
+
+ @Schema(description = "투표 개수", example = "4")
+ Integer voteCount,
+
+ @Schema(description = "투표한 비율", example = "50.0")
+ Double votePercent
+) {
+
+ private static final int HIDDEN_COUNT = -1;
+
+ public static PostOptionResponse of(final Post post, final PostOption postOption) {
+ return new PostOptionResponse(
+ postOption.getId(),
+ postOption.getContent(),
+ convertImageUrl(postOption.getImageUrl()),
+ post.isClosed() ? postOption.getVoteCount() : HIDDEN_COUNT,
+ postOption.getVotePercent(post.getTotalVoteCount())
+ );
+ }
+
+ private static String convertImageUrl(final String imageUrl) {
+ return imageUrl == null ? "" : imageUrl;
+ }
+
+ public static PostOptionResponse of(
+ final PostOption postOption,
+ final boolean isVisibleVoteResult,
+ final Long totalVoteCount
+ ) {
+ return new PostOptionResponse(
+ postOption.getId(),
+ postOption.getContent(),
+ convertImageUrl(postOption.getImageUrl()),
+ postOption.getVoteCount(isVisibleVoteResult),
+ postOption.getVotePercent(totalVoteCount)
+ );
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostRankingResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostRankingResponse.java
new file mode 100644
index 000000000..b8b250a18
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostRankingResponse.java
@@ -0,0 +1,15 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "게시글 랭킹 정보 응답")
+public record PostRankingResponse(
+ @Schema(description = "게시글 랭킹", example = "1")
+ int ranking,
+
+ @Schema(description = "게시글 정보")
+ @JsonProperty("post")
+ PostSummaryResponse postSummaryResponse
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java
new file mode 100644
index 000000000..d17d677d9
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java
@@ -0,0 +1,125 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.response.vote.VoteResponse;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.PostBody;
+import com.votogether.domain.post.entity.PostCategory;
+import com.votogether.domain.post.entity.PostContentImage;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "게시글에 관련한 데이터들입니다.")
+public record PostResponse(
+ @Schema(description = "게시글 ID", example = "1")
+ Long postId,
+
+ @Schema(description = "작성자")
+ WriterResponse writer,
+
+ @Schema(description = "게시글 제목", example = "이거 한번 투표해주세요")
+ String title,
+
+ @Schema(description = "게시글 내용", example = "어떤게 더 맛있나요?")
+ String content,
+
+ @Schema(description = "이미지 URL", example = "http://asdasdasd.com")
+ String imageUrl,
+
+ @Schema(description = "카테고리 목록", example = "[1,2]")
+ List categories,
+
+ @Schema(description = "게시글 생성시각", example = "2023-08-01 13:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime createdAt,
+
+ @Schema(description = "게시글 마감기한", example = "2023-08-01 13:56")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+ LocalDateTime deadline,
+
+ @Schema(description = "투표 통계 정보")
+ VoteResponse voteInfo
+) {
+
+ public static PostResponse of(final Post post, final Member loginMember) {
+ final Member writer = post.getWriter();
+ final PostBody postBody = post.getPostBody();
+ final List contentImages = postBody.getPostContentImages().getContentImages();
+ final StringBuilder contentImageUrl = new StringBuilder();
+
+ if (!contentImages.isEmpty()) {
+ contentImageUrl.append(contentImages.get(0).getImageUrl());
+ }
+
+ return new PostResponse(
+ post.getId(),
+ WriterResponse.of(writer.getId(), writer.getNickname()),
+ postBody.getTitle(),
+ postBody.getContent(),
+ convertImageUrl(contentImageUrl.toString()),
+ getCategories(post),
+ post.getCreatedAt(),
+ post.getDeadline(),
+ VoteResponse.of(
+ post.getSelectedOptionId(loginMember),
+ post.getFinalTotalVoteCount(loginMember),
+ getOptions(post, loginMember)
+ )
+ );
+ }
+
+ private static String convertImageUrl(final String imageUrl) {
+ return imageUrl == null ? "" : imageUrl;
+ }
+
+ private static List getCategories(final Post post) {
+ return post.getPostCategories()
+ .getPostCategories()
+ .stream()
+ .map(PostCategory::getCategory)
+ .map(CategoryResponse::of)
+ .toList();
+ }
+
+ private static List getOptions(
+ final Post post,
+ final Member loginMember
+ ) {
+ return post.getPostOptions()
+ .getPostOptions()
+ .stream()
+ .map(postOption ->
+ PostOptionResponse.of(
+ postOption,
+ post.isVisibleVoteResult(loginMember),
+ post.getFinalTotalVoteCount(loginMember)
+ )
+ )
+ .toList();
+ }
+
+ public static PostResponse forGuest(final Post post) {
+ final PostBody postBody = post.getPostBody();
+ final List contentImages = postBody.getPostContentImages().getContentImages();
+ final StringBuilder contentImageUrl = new StringBuilder();
+
+ if (!contentImages.isEmpty()) {
+ contentImageUrl.append(contentImages.get(0).getImageUrl());
+ }
+
+ return new PostResponse(
+ post.getId(),
+ WriterResponse.from(post.getWriter()),
+ post.getPostBody().getTitle(),
+ post.getPostBody().getContent(),
+ convertImageUrl(contentImageUrl.toString()),
+ getCategories(post),
+ post.getCreatedAt(),
+ post.getDeadline(),
+ VoteResponse.forGuest(post)
+ );
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java
new file mode 100644
index 000000000..f90cb208d
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java
@@ -0,0 +1,30 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.votogether.domain.post.entity.Post;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "게시글 간략 정보 응답")
+public record PostSummaryResponse(
+ @Schema(description = "게시글 ID", example = "1")
+ Long id,
+
+ @Schema(description = "작성자", example = "익명의손님1")
+ String writer,
+
+ @Schema(description = "게시글 제목", example = "제목")
+ String title,
+
+ @Schema(description = "투표 수", example = "123")
+ long voteCount
+) {
+
+ public static PostSummaryResponse from(final Post post) {
+ return new PostSummaryResponse(
+ post.getId(),
+ post.getWriter().getNickname(),
+ post.getPostBody().getTitle(),
+ post.getTotalVoteCount()
+ );
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java
new file mode 100644
index 000000000..985e4c872
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java
@@ -0,0 +1,23 @@
+package com.votogether.domain.post.dto.response.post;
+
+import com.votogether.domain.member.entity.Member;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "작성자 응답")
+public record WriterResponse(
+ @Schema(description = "작성자 ID", example = "1")
+ Long id,
+
+ @Schema(description = "작성자 닉네임", example = "익명의닉네임2SDSDNKLNS")
+ String nickname
+) {
+
+ public static WriterResponse from(final Member member) {
+ return new WriterResponse(member.getId(), member.getNickname());
+ }
+
+ public static WriterResponse of(final Long id, final String nickname) {
+ return new WriterResponse(id, nickname);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java
new file mode 100644
index 000000000..62be5c3c3
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java
@@ -0,0 +1,29 @@
+package com.votogether.domain.post.dto.response.vote;
+
+import com.votogether.domain.member.entity.vo.Gender;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.Map;
+
+@Schema(description = "연령대별 투표 통계 응답")
+public record VoteCountForAgeGroupResponse(
+ @Schema(description = "연령대", example = "20대")
+ String ageGroup,
+
+ @Schema(description = "총 투표 수", example = "14")
+ int voteCount,
+
+ @Schema(description = "남자 투표 수", example = "7")
+ int maleCount,
+
+ @Schema(description = "여자 투표 수", example = "7")
+ int femaleCount
+) {
+ public static VoteCountForAgeGroupResponse of(final String ageGroup, final Map genderGroup) {
+ final int maleCount = genderGroup.getOrDefault(Gender.MALE, 0L).intValue();
+ final int femaleCount = genderGroup.getOrDefault(Gender.FEMALE, 0L).intValue();
+ final int voteCount = maleCount + femaleCount;
+
+ return new VoteCountForAgeGroupResponse(ageGroup, voteCount, maleCount, femaleCount);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java
new file mode 100644
index 000000000..0eabf9522
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java
@@ -0,0 +1,27 @@
+package com.votogether.domain.post.dto.response.vote;
+
+import com.votogether.domain.post.dto.response.post.PostOptionDetailResponse;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+@Schema(description = "투표 상세 응답")
+public record VoteDetailResponse(
+ @Schema(description = "선택지 ID", example = "1")
+ Long selectedOptionId,
+
+ @Schema(description = "총 투표 수", example = "2")
+ Long totalVoteCount,
+
+ @Schema(description = "선택지 상세 응답")
+ List options
+) {
+
+ public static VoteDetailResponse of(
+ final long selectedOptionId,
+ final long finalTotalVoteCount,
+ final List options
+ ) {
+ return new VoteDetailResponse(selectedOptionId, finalTotalVoteCount, options);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java
new file mode 100644
index 000000000..1b794e7f7
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java
@@ -0,0 +1,46 @@
+package com.votogether.domain.post.dto.response.vote;
+
+import com.votogether.domain.member.entity.vo.AgeRange;
+import com.votogether.domain.member.entity.vo.Gender;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+@Schema(description = "투표 선택지 통계 응답")
+public record VoteOptionStatisticsResponse(
+ @Schema(description = "총 투표 수", example = "14")
+ int totalVoteCount,
+
+ @Schema(description = "총 남자 투표 수", example = "7")
+ int totalMaleCount,
+
+ @Schema(description = "총 여자 투표 수", example = "7")
+ int totalFemaleCount,
+
+ @Schema(description = "연령대별 투표 수 응답")
+ List ageGroup
+) {
+
+ public static VoteOptionStatisticsResponse from(final Map> voteStatusGroup) {
+ final List ageGroupStatistics = Arrays.stream(AgeRange.values())
+ .map(AgeRange::getName)
+ .map(ageName ->
+ VoteCountForAgeGroupResponse.of(
+ ageName,
+ voteStatusGroup.computeIfAbsent(ageName, ignore -> new HashMap<>())
+ )
+ )
+ .toList();
+
+ final int totalVoteCount = ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::voteCount).sum();
+ final int totalMaleCount = ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::maleCount).sum();
+ final int totalFemaleCount =
+ ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::femaleCount).sum();
+
+ return new VoteOptionStatisticsResponse(totalVoteCount, totalMaleCount, totalFemaleCount, ageGroupStatistics);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java
new file mode 100644
index 000000000..fac8d92de
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java
@@ -0,0 +1,56 @@
+package com.votogether.domain.post.dto.response.vote;
+
+import com.votogether.domain.post.dto.response.post.PostOptionResponse;
+import com.votogether.domain.post.entity.Post;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+
+@Schema(description = "투표 응답")
+public record VoteResponse(
+ @Schema(description = "선택지 ID", example = "1")
+ long selectedOptionId,
+
+ @Schema(description = "총 투표 수", example = "7")
+ long totalVoteCount,
+
+ @Schema(description = "선택지 옵션 응답")
+ List options
+) {
+
+ private static final int NOT_SELECTED = 0;
+ private static final int HIDDEN_COUNT = -1;
+
+ public static VoteResponse of(
+ final long selectedOptionId,
+ final long finalTotalVoteCount,
+ final List options
+ ) {
+ return new VoteResponse(selectedOptionId, finalTotalVoteCount, options);
+ }
+
+ public static VoteResponse forGuest(final Post post) {
+ return new VoteResponse(
+ NOT_SELECTED,
+ post.isClosed() ? post.getTotalVoteCount() : HIDDEN_COUNT,
+ listOfOptionsForGuest(post)
+ );
+ }
+
+ private static List listOfOptionsForGuest(final Post post) {
+ return post.getPostOptions().getPostOptions()
+ .stream()
+ .map(postOption -> PostOptionResponse.of(post, postOption))
+ .toList();
+ }
+
+ @Override
+ public String toString() {
+ return "VoteInfoResponse{" +
+ "selectedOptionId=" + selectedOptionId +
+ ", totalVoteCount=" + totalVoteCount +
+ ", options=" + options +
+ '}';
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/Post.java b/backend/src/main/java/com/votogether/domain/post/entity/Post.java
new file mode 100644
index 000000000..5e293b334
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/Post.java
@@ -0,0 +1,273 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.common.BaseEntity;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.comment.Comment;
+import com.votogether.domain.post.exception.PostExceptionType;
+import com.votogether.domain.report.exception.ReportExceptionType;
+import com.votogether.domain.vote.entity.Vote;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.persistence.Basic;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.IntStream;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.Formula;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class Post extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member writer;
+
+ @Embedded
+ private PostBody postBody;
+
+ @Embedded
+ private PostCategories postCategories;
+
+ @Embedded
+ private PostOptions postOptions;
+
+ @Column(columnDefinition = "datetime(6)", nullable = false)
+ private LocalDateTime deadline;
+
+ @Column(nullable = false)
+ private boolean isHidden;
+
+ @Basic(fetch = FetchType.LAZY)
+ @Formula("(select count(v.id) from Vote v where v.post_option_id in "
+ + "(select po.id from Post_Option po where po.post_id = id)"
+ + ")")
+ private long totalVoteCount;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
+ private List comments = new ArrayList<>();
+
+ @Builder
+ private Post(
+ final Member writer,
+ final PostBody postBody,
+ final LocalDateTime deadline
+ ) {
+ this.writer = writer;
+ this.postBody = postBody;
+ this.deadline = deadline;
+ this.postCategories = new PostCategories();
+ this.postOptions = new PostOptions();
+ this.isHidden = false;
+ }
+
+ public void mapCategories(final List categories) {
+ this.postCategories.mapPostAndCategories(this, categories);
+ }
+
+ public void mapPostOptionsByElements(
+ final List postOptionContents,
+ final List optionImageUrls
+ ) {
+ this.postOptions = PostOptions.of(this, postOptionContents, optionImageUrls);
+ }
+
+ public void validateDeadlineNotExceedByMaximumDeadline(final int maximumDeadline) {
+ LocalDateTime maximumDeadlineFromNow = LocalDateTime.now().plusDays(maximumDeadline);
+ if (this.deadline.isAfter(maximumDeadlineFromNow)) {
+ throw new BadRequestException(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS);
+ }
+ }
+
+ public void validateWriter(final Member member) {
+ if (!Objects.equals(this.writer, member)) {
+ throw new BadRequestException(PostExceptionType.NOT_WRITER);
+ }
+ }
+
+ public long getSelectedOptionId(final Member member) {
+ return this.postOptions.getSelectedOptionId(member);
+ }
+
+ public Vote makeVote(final Member voter, final PostOption postOption) {
+ validateDeadLine();
+ validateVoter(voter);
+ validatePostOption(postOption);
+
+ final Vote vote = Vote.builder()
+ .member(voter)
+ .build();
+
+ postOption.addVote(vote);
+ return vote;
+ }
+
+ public void validateDeadLine() {
+ if (isClosed()) {
+ throw new BadRequestException(PostExceptionType.POST_CLOSED);
+ }
+ }
+
+ public boolean isClosed() {
+ return deadline.isBefore(LocalDateTime.now());
+ }
+
+ private void validateVoter(final Member voter) {
+ if (Objects.equals(this.writer.getId(), voter.getId())) {
+ throw new BadRequestException(PostExceptionType.NOT_VOTER);
+ }
+ }
+
+ private void validatePostOption(final PostOption postOption) {
+ if (!hasPostOption(postOption)) {
+ throw new BadRequestException(PostExceptionType.POST_OPTION_NOT_FOUND);
+ }
+ }
+
+ private boolean hasPostOption(final PostOption postOption) {
+ return postOptions.contains(postOption);
+ }
+
+ public void closeEarly() {
+ this.deadline = LocalDateTime.now();
+ }
+
+ public void addContentImage(final String contentImageUrl) {
+ this.postBody.addContentImage(this, contentImageUrl);
+ }
+
+ public long getFinalTotalVoteCount(final Member loginMember) {
+ if (isVisibleVoteResult(loginMember)) {
+ return this.totalVoteCount;
+ }
+
+ return -1L;
+ }
+
+ public boolean isVisibleVoteResult(final Member member) {
+ return this.postOptions.getSelectedOptionId(member) != 0
+ || this.writer.equals(member)
+ || isClosed();
+ }
+
+ public void blind() {
+ this.isHidden = true;
+ }
+
+ public void validateMine(final Member member) {
+ if (this.writer.equals(member)) {
+ throw new BadRequestException(ReportExceptionType.REPORT_MY_POST);
+ }
+ }
+
+ public void validateHidden() {
+ if (this.isHidden) {
+ throw new BadRequestException(ReportExceptionType.ALREADY_HIDDEN_POST);
+ }
+ }
+
+ public void addComment(final Comment comment) {
+ comments.add(comment);
+ comment.setPost(this);
+ }
+
+ public void validatePossibleToDelete() {
+ if (this.totalVoteCount >= 20) {
+ throw new BadRequestException(PostExceptionType.CANNOT_DELETE_BECAUSE_MORE_THAN_TWENTY_VOTES);
+ }
+ }
+
+ public void update(
+ final PostBody postBody,
+ final String oldContentImageUrl,
+ final List contentImageUrls,
+ final List categories,
+ final List postOptionContents,
+ final List oldPostOptionImageUrls,
+ final List postOptionImageUrls,
+ final LocalDateTime deadline
+ ) {
+ this.postBody.update(postBody, oldContentImageUrl, contentImageUrls);
+ this.postCategories.update(this, categories);
+ addAllPostOptions(postOptionContents, oldPostOptionImageUrls, postOptionImageUrls);
+ this.deadline = deadline;
+ }
+
+ private void addAllPostOptions(
+ final List postOptionContents,
+ final List oldPostOptionImageUrls,
+ final List postOptionImageUrls
+ ) {
+ this.postOptions.addAll(
+ this,
+ postOptionContents,
+ getPostOptionImageUrls(oldPostOptionImageUrls, postOptionImageUrls)
+ );
+ }
+
+ public void postOptionsClear() {
+ this.postOptions.clear();
+ }
+
+ private List getPostOptionImageUrls(
+ final List oldPostOptionImageUrls,
+ final List postOptionImageUrls
+ ) {
+ return IntStream.range(0, postOptionImageUrls.size())
+ .mapToObj(postOptionIndex ->
+ getPostOptionImageUrl(
+ postOptionIndex,
+ oldPostOptionImageUrls,
+ postOptionImageUrls
+ )
+ )
+ .toList();
+ }
+
+ private String getPostOptionImageUrl(
+ final int postOptionIndex,
+ final List oldPostOptionImageUrls,
+ final List postOptionImageUrls
+ ) {
+ final String postOptionImageUrl = postOptionImageUrls.get(postOptionIndex);
+ if (postOptionImageUrl.isEmpty()) {
+ return oldPostOptionImageUrls.get(postOptionIndex);
+ }
+
+ return postOptionImageUrls.get(postOptionIndex);
+ }
+
+ public void validateDeadLineToModify(final LocalDateTime deadlineToModify) {
+ if (getCreatedAt().plusDays(3).isBefore(deadlineToModify)) {
+ throw new BadRequestException(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS);
+ }
+ }
+
+ public void validateExistVote() {
+ if (totalVoteCount > 0) {
+ throw new BadRequestException(PostExceptionType.VOTING_PROGRESS_NOT_EDITABLE);
+ }
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java b/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java
new file mode 100644
index 000000000..9e5e00217
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java
@@ -0,0 +1,58 @@
+package com.votogether.domain.post.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.Embedded;
+import java.util.List;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+public class PostBody {
+
+ @Column(length = 100, nullable = false)
+ private String title;
+
+ @Column(length = 1000, nullable = false)
+ private String content;
+
+ @Embedded
+ private PostContentImages postContentImages;
+
+ @Builder
+ private PostBody(final String title, final String content) {
+ this.title = title;
+ this.content = content;
+ this.postContentImages = new PostContentImages();
+ }
+
+ public void update(
+ final PostBody postBody,
+ final String oldContentImageUrl,
+ final List contentImageUrls
+ ) {
+ this.title = postBody.getTitle();
+ this.content = postBody.getContent();
+ this.postContentImages.update(getContentImageUrl(oldContentImageUrl, contentImageUrls));
+ }
+
+ private String getContentImageUrl(
+ final String oldContentImageUrl,
+ final List contentImageUrls
+ ) {
+ if (contentImageUrls.isEmpty()) {
+ return oldContentImageUrl;
+ }
+
+ return contentImageUrls.get(0);
+ }
+
+ public void addContentImage(final Post post, final String contentImageUrl) {
+ this.postContentImages.addContentImage(post, contentImageUrl);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java b/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java
new file mode 100644
index 000000000..9f43068bb
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java
@@ -0,0 +1,46 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.category.entity.Category;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.OneToMany;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+public class PostCategories {
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
+ private List postCategories = new ArrayList<>();
+
+ public void mapPostAndCategories(final Post post, final List categories) {
+ categories.forEach(category -> postCategories.add(createPostCategory(post, category)));
+ }
+
+ private PostCategory createPostCategory(final Post post, final Category category) {
+ return PostCategory.builder()
+ .post(post)
+ .category(category)
+ .build();
+ }
+
+ public void update(final Post post, final List categories) {
+ postCategories.removeIf(Predicate.not(postCategory -> categories.contains(postCategory.getCategory())));
+
+ categories.stream()
+ .filter(this::isCategoryNotPresent)
+ .forEach(category -> this.postCategories.add(createPostCategory(post, category)));
+ }
+
+ private boolean isCategoryNotPresent(Category category) {
+ return this.postCategories.stream()
+ .noneMatch(postCategory -> postCategory.getCategory().equals(category));
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java b/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java
new file mode 100644
index 000000000..ef562207c
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java
@@ -0,0 +1,43 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.common.BaseEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "category_id"})})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class PostCategory extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "category_id", nullable = false)
+ private Category category;
+
+ @Builder
+ private PostCategory(final Post post, final Category category) {
+ this.post = post;
+ this.category = category;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java
new file mode 100644
index 000000000..d6e5307b2
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java
@@ -0,0 +1,43 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class PostContentImage extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @Column(nullable = false)
+ private String imageUrl;
+
+ @Builder
+ public PostContentImage(final Post post, final String imageUrl) {
+ this.post = post;
+ this.imageUrl = imageUrl;
+ }
+
+ public void updateImageUrl(final String imageUrl) {
+ this.imageUrl = imageUrl;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java
new file mode 100644
index 000000000..40701594f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java
@@ -0,0 +1,35 @@
+package com.votogether.domain.post.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.OneToMany;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+public class PostContentImages {
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
+ private List contentImages = new ArrayList<>();
+
+ public void addContentImage(final Post post, final String contentImageUrl) {
+ this.contentImages.add(getPostContentImage(post, contentImageUrl));
+ }
+
+ private PostContentImage getPostContentImage(final Post post, final String contentImageUrl) {
+ return PostContentImage.builder()
+ .post(post)
+ .imageUrl(contentImageUrl)
+ .build();
+ }
+
+ public void update(final String imageUrl) {
+ this.contentImages.get(0).updateImageUrl(imageUrl);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java b/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java
new file mode 100644
index 000000000..8849c2e3f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java
@@ -0,0 +1,135 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.common.BaseEntity;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.vote.entity.Vote;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "sequence"})})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class PostOption extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @Column(nullable = false)
+ private int sequence;
+
+ @Column(length = 50, nullable = false)
+ private String content;
+
+ @Column
+ private String imageUrl;
+
+ @OneToMany(mappedBy = "postOption", cascade = CascadeType.PERSIST, orphanRemoval = true)
+ private List votes = new ArrayList<>();
+
+ @Builder
+ private PostOption(
+ final Post post,
+ final int sequence,
+ final String content,
+ final String imageUrl
+ ) {
+ this.post = post;
+ this.sequence = sequence;
+ this.content = content;
+ this.imageUrl = imageUrl;
+ }
+
+ public static PostOption of(
+ final String postOptionContent,
+ final Post post,
+ final int postOptionSequence,
+ final String optionImageUrl
+ ) {
+ if (!optionImageUrl.isEmpty()) {
+ return toPostOptionEntity(post, postOptionSequence, postOptionContent, optionImageUrl);
+ }
+
+ return toPostOptionEntity(post, postOptionSequence, postOptionContent, "");
+ }
+
+ private static PostOption toPostOptionEntity(
+ final Post post,
+ final Integer postOptionSequence,
+ final String postOptionContent,
+ final String optionImageUrl
+ ) {
+ return PostOption.builder()
+ .post(post)
+ .sequence(postOptionSequence)
+ .content(postOptionContent)
+ .imageUrl(optionImageUrl)
+ .build();
+ }
+
+ public void addVote(final Vote vote) {
+ this.votes.add(vote);
+ vote.setPostOption(this);
+ }
+
+ public boolean hasMemberVote(final Member member) {
+ return votes.stream()
+ .anyMatch(vote -> vote.isVoteByMember(member));
+ }
+
+ public boolean isBelongsTo(final Post post) {
+ return Objects.equals(this.post.getId(), post.getId());
+ }
+
+ public int getVoteCount(final boolean isPostVoteByMember) {
+ final int votesCount = votes.size();
+ if (isPostVoteByMember) {
+ return votesCount;
+ }
+
+ return -1;
+ }
+
+ public double getVotePercent(final long totalVoteCount) {
+ if (isPostVoteByMember(totalVoteCount)) {
+ return calculateVotePercent(totalVoteCount);
+ }
+
+ return totalVoteCount;
+ }
+
+ private boolean isPostVoteByMember(final long totalVoteCount) {
+ return totalVoteCount > 0;
+ }
+
+ private double calculateVotePercent(final Long totalVoteCount) {
+ return ((double) this.votes.size() / totalVoteCount) * 100;
+ }
+
+ public int getVoteCount() {
+ return this.votes.size();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java b/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java
new file mode 100644
index 000000000..fb19477e6
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java
@@ -0,0 +1,85 @@
+package com.votogether.domain.post.entity;
+
+import com.votogether.domain.member.entity.Member;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.OneToMany;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.IntStream;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+@Getter
+@Embeddable
+public class PostOptions {
+
+ private static final Integer FIRST_OPTION_SEQUENCE = 1;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
+ private List postOptions = new ArrayList<>();
+
+ public static PostOptions of(
+ final Post post,
+ final List postOptionContents,
+ final List optionImageUrls
+ ) {
+ final PostOptions newInstance = new PostOptions();
+ final List postOptions = getPostOptions(post, postOptionContents, optionImageUrls);
+
+ newInstance.postOptions.addAll(postOptions);
+ return newInstance;
+ }
+
+ private static List getPostOptions(
+ final Post post,
+ final List postOptionContents,
+ final List optionImageUrls
+ ) {
+ return IntStream.rangeClosed(FIRST_OPTION_SEQUENCE, postOptionContents.size())
+ .mapToObj(postOptionSequence ->
+ toPostOption(post, postOptionContents, optionImageUrls, postOptionSequence)
+ )
+ .toList();
+ }
+
+ private static PostOption toPostOption(
+ final Post post,
+ final List postOptionContents,
+ final List optionImageUrls,
+ final int postOptionSequence
+ ) {
+ return PostOption.of(
+ postOptionContents.get(postOptionSequence - 1),
+ post,
+ postOptionSequence,
+ optionImageUrls.get(postOptionSequence - 1)
+ );
+ }
+
+ public Boolean contains(final PostOption postOption) {
+ return postOptions.contains(postOption);
+ }
+
+ public Long getSelectedOptionId(final Member member) {
+ return postOptions.stream()
+ .filter(postOption -> postOption.hasMemberVote(member))
+ .findAny()
+ .map(PostOption::getId)
+ .orElse(0L);
+ }
+
+ public void addAll(
+ final Post post,
+ final List postOptionContents,
+ final List postOptionImageUrls
+ ) {
+ this.postOptions.addAll(getPostOptions(post, postOptionContents, postOptionImageUrls));
+ }
+
+ public void clear() {
+ this.postOptions.clear();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java
new file mode 100644
index 000000000..8f9250848
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java
@@ -0,0 +1,97 @@
+package com.votogether.domain.post.entity.comment;
+
+import com.votogether.domain.common.BaseEntity;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.exception.CommentExceptionType;
+import com.votogether.domain.report.exception.ReportExceptionType;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import java.util.Objects;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class Comment extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Setter
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member member;
+
+ @Embedded
+ private Content content;
+
+ @Column(nullable = false)
+ private boolean isHidden;
+
+ @Builder
+ private Comment(
+ final Post post,
+ final Member member,
+ final String content
+ ) {
+ this.post = post;
+ this.member = member;
+ this.content = new Content(content);
+ this.isHidden = false;
+ }
+
+ public void validateWriter(final Member member) {
+ if (!Objects.equals(this.member.getId(), member.getId())) {
+ throw new BadRequestException(CommentExceptionType.NOT_WRITER);
+ }
+ }
+
+ public void validateBelong(final Post post) {
+ if (!Objects.equals(this.post.getId(), post.getId())) {
+ throw new BadRequestException(CommentExceptionType.NOT_BELONG_POST);
+ }
+ }
+
+ public void blind() {
+ this.isHidden = true;
+ }
+
+ public void validateMine(final Member reporter) {
+ if (this.member.equals(reporter)) {
+ throw new BadRequestException(ReportExceptionType.REPORT_MY_COMMENT);
+ }
+ }
+
+ public void validateHidden() {
+ if (this.isHidden) {
+ throw new BadRequestException(ReportExceptionType.ALREADY_HIDDEN_COMMENT);
+ }
+ }
+
+ public void updateContent(final String content) {
+ this.content = new Content(content);
+ }
+
+ public String getContent() {
+ return content.getValue();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java b/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java
new file mode 100644
index 000000000..e6e0fd3b8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java
@@ -0,0 +1,32 @@
+package com.votogether.domain.post.entity.comment;
+
+import com.votogether.domain.post.exception.CommentExceptionType;
+import com.votogether.global.exception.BadRequestException;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+class Content {
+
+ private static final int MAXIMUM_LENGTH = 500;
+
+ @Column(name = "content", nullable = false, length = MAXIMUM_LENGTH)
+ private String value;
+
+ public Content(final String value) {
+ validate(value);
+ this.value = value;
+ }
+
+ private void validate(final String content) {
+ if (content.length() > MAXIMUM_LENGTH) {
+ throw new BadRequestException(CommentExceptionType.INVALID_CONTENT_LENGTH);
+ }
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java
new file mode 100644
index 000000000..d98d43b4a
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java
@@ -0,0 +1,9 @@
+package com.votogether.domain.post.entity.vo;
+
+public enum PostClosingType {
+
+ ALL,
+ PROGRESS,
+ CLOSED
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java
new file mode 100644
index 000000000..97abc0cf5
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java
@@ -0,0 +1,28 @@
+package com.votogether.domain.post.entity.vo;
+
+import lombok.Getter;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.domain.Sort.Direction;
+
+@Getter
+public enum PostSortType {
+
+ LATEST(
+ Sort.by(Direction.DESC, "createdAt"),
+ Sort.by(Direction.DESC, "postOption.post.createdAt")
+ ),
+
+ HOT(
+ Sort.by(Direction.DESC, "totalVoteCount"),
+ Sort.by(Direction.DESC, "postOption.post.totalVoteCount")
+ );
+
+ private final Sort postBaseSort;
+ private final Sort voteBaseSort;
+
+ PostSortType(final Sort postBaseSort, final Sort voteBaseSort) {
+ this.postBaseSort = postBaseSort;
+ this.voteBaseSort = voteBaseSort;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java
new file mode 100644
index 000000000..de350346f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java
@@ -0,0 +1,22 @@
+package com.votogether.domain.post.exception;
+
+import com.votogether.global.exception.ExceptionType;
+import lombok.Getter;
+
+@Getter
+public enum CommentExceptionType implements ExceptionType {
+
+ INVALID_CONTENT_LENGTH(2000, "유효하지 않은 댓글 길이입니다."),
+ COMMENT_NOT_FOUND(2001, "해당 댓글이 존재하지 않습니다."),
+ NOT_BELONG_POST(2002, "댓글의 게시글 정보와 일치하지 않습니다."),
+ NOT_WRITER(2003, "댓글 작성자가 아닙니다.");
+
+ private final int code;
+ private final String message;
+
+ CommentExceptionType(final int code, final String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java
new file mode 100644
index 000000000..7e09bd98e
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java
@@ -0,0 +1,29 @@
+package com.votogether.domain.post.exception;
+
+import com.votogether.global.exception.ExceptionType;
+import lombok.Getter;
+
+@Getter
+public enum PostExceptionType implements ExceptionType {
+
+ POST_NOT_FOUND(1000, "해당 게시글이 존재하지 않습니다."),
+ POST_OPTION_NOT_FOUND(1001, "해당 게시글 투표 옵션이 존재하지 않습니다."),
+ UNRELATED_POST_OPTION(1002, "게시글 투표 옵션이 게시글과 연관되어 있지 않습니다."),
+ NOT_WRITER(1003, "해당 게시글 작성자가 아닙니다."),
+ POST_CLOSED(1004, "게시글이 이미 마감되었습니다."),
+ POST_NOT_HALF_DEADLINE(1005, "게시글이 마감 시간까지 절반의 시간 이상이 지나지 않으면 조기마감을 할 수 없습니다."),
+ NOT_VOTER(1004, "해당 게시글 작성자는 투표할 수 없습니다."),
+ DEADLINE_EXCEED_THREE_DAYS(1005, "마감 기한은 생성 시간으로부터 3일을 초과할 수 없습니다."),
+ WRONG_IMAGE(1006, "이미지 저장에 실패했습니다. 다시 시도해주세요."),
+ CANNOT_DELETE_BECAUSE_MORE_THAN_TWENTY_VOTES(1007, "투표가 20개 이상이므로 해당 게시글을 삭제할 수 없습니다."),
+ VOTING_PROGRESS_NOT_EDITABLE(1008, "해당 게시글은 투표가 진행되어 수정할 수 없습니다.");
+
+ private final int code;
+ private final String message;
+
+ PostExceptionType(final int code, final String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java
new file mode 100644
index 000000000..e4010b24b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java
@@ -0,0 +1,17 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.comment.Comment;
+import java.util.List;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface CommentRepository extends JpaRepository {
+
+ @EntityGraph(attributePaths = {"member"})
+ List findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(final Post post);
+
+ List findAllByMember(final Member member);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java
new file mode 100644
index 000000000..d6e61a244
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java
@@ -0,0 +1,7 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.post.entity.PostCategory;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostCategoryRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java
new file mode 100644
index 000000000..32bb8f3f8
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java
@@ -0,0 +1,7 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.post.entity.PostContentImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostContentImageRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java
new file mode 100644
index 000000000..18d0f022e
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java
@@ -0,0 +1,35 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.vo.PostClosingType;
+import com.votogether.domain.post.entity.vo.PostSortType;
+import java.util.List;
+import org.springframework.data.domain.Pageable;
+
+public interface PostCustomRepository {
+
+ List findAllByClosingTypeAndSortTypeAndCategoryId(
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ );
+
+ List findAllWithKeyword(
+ final String keyword,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ );
+
+ List findAllByWriterWithClosingTypeAndSortTypeAndCategoryId(
+ final Member writer,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ );
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java
new file mode 100644
index 000000000..e271c4932
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java
@@ -0,0 +1,130 @@
+package com.votogether.domain.post.repository;
+
+import static com.votogether.domain.post.entity.QPost.post;
+import static com.votogether.domain.post.entity.QPostCategory.postCategory;
+
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.vo.PostClosingType;
+import com.votogether.domain.post.entity.vo.PostSortType;
+import com.votogether.global.persistence.OrderByNull;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+@RequiredArgsConstructor
+@Repository
+public class PostCustomRepositoryImpl implements PostCustomRepository {
+
+ private final JPAQueryFactory jpaQueryFactory;
+
+ @Override
+ public List findAllByClosingTypeAndSortTypeAndCategoryId(
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ ) {
+ return jpaQueryFactory
+ .selectFrom(post)
+ .distinct()
+ .join(post.writer).fetchJoin()
+ .leftJoin(post.postCategories.postCategories, postCategory)
+ .where(
+ categoryIdEq(categoryId),
+ deadlineEq(postClosingType),
+ post.isHidden.eq(false)
+ )
+ .orderBy(orderBy(postSortType))
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+ }
+
+ @Override
+ public List findAllByWriterWithClosingTypeAndSortTypeAndCategoryId(
+ final Member writer,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ ) {
+ return jpaQueryFactory
+ .selectFrom(post)
+ .join(post.writer).fetchJoin()
+ .where(
+ categoryIdEq(categoryId),
+ deadlineEq(postClosingType),
+ post.writer.eq(writer),
+ post.isHidden.eq(false)
+ )
+ .orderBy(orderBy(postSortType))
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+ }
+
+ private BooleanExpression categoryIdEq(final Long categoryId) {
+ return categoryId == null ? null : postCategory.category.id.eq(categoryId);
+ }
+
+ private BooleanExpression deadlineEq(final PostClosingType postClosingType) {
+ final LocalDateTime now = LocalDateTime.now();
+ switch (postClosingType) {
+ case PROGRESS:
+ return post.deadline.after(now);
+ case CLOSED:
+ return post.deadline.before(now);
+ case ALL:
+ default:
+ return null;
+ }
+ }
+
+ private OrderSpecifier orderBy(final PostSortType postSortType) {
+ switch (postSortType) {
+ case LATEST:
+ return post.createdAt.desc();
+ case HOT:
+ return post.totalVoteCount.desc();
+ default:
+ return OrderByNull.DEFAULT;
+ }
+ }
+
+ @Override
+ public List findAllWithKeyword(
+ final String keyword,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Pageable pageable
+ ) {
+ return jpaQueryFactory
+ .selectFrom(post)
+ .distinct()
+ .join(post.writer).fetchJoin()
+ .leftJoin(post.postCategories.postCategories, postCategory)
+ .where(
+ containsKeywordInTitleOrContent(keyword),
+ categoryIdEq(categoryId),
+ deadlineEq(postClosingType),
+ post.isHidden.eq(false)
+ )
+ .orderBy(orderBy(postSortType))
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+ }
+
+ private BooleanExpression containsKeywordInTitleOrContent(final String keyword) {
+ return post.postBody.title.contains(keyword)
+ .or(post.postBody.content.contains(keyword));
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java
new file mode 100644
index 000000000..e4507c0ec
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java
@@ -0,0 +1,8 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.post.entity.PostOption;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostOptionRepository extends JpaRepository {
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java
new file mode 100644
index 000000000..15fb326d6
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java
@@ -0,0 +1,41 @@
+package com.votogether.domain.post.repository;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.entity.Post;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface PostRepository extends JpaRepository, PostCustomRepository {
+
+ Slice findByDeadlineBefore(final LocalDateTime currentTime, final Pageable pageable);
+
+ Slice findByDeadlineAfter(final LocalDateTime currentTime, final Pageable pageable);
+
+ int countByWriter(final Member member);
+
+ List findAllByWriter(final Member member);
+
+ @Query("SELECT COUNT(p)" +
+ "FROM Member m " +
+ "LEFT JOIN Post p ON m.id = p.writer.id AND p.writer IN :members " +
+ "WHERE m IN :members " +
+ "GROUP BY m.id")
+ List findCountsByMembers(@Param("members") final List members);
+
+ @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member "
+ + "AND v.postOption.post.deadline < CURRENT_TIMESTAMP")
+ Slice findClosedPostsVotedByMember(@Param("member") final Member member, final Pageable pageable);
+
+ @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member "
+ + "AND v.postOption.post.deadline > CURRENT_TIMESTAMP")
+ Slice findOpenPostsVotedByMember(@Param("member") final Member member, final Pageable pageable);
+
+ @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member ")
+ Slice findPostsVotedByMember(@Param("member") final Member member, final Pageable pageable);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java
new file mode 100644
index 000000000..183d205e3
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java
@@ -0,0 +1,85 @@
+package com.votogether.domain.post.service;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest;
+import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest;
+import com.votogether.domain.post.dto.response.comment.CommentResponse;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.comment.Comment;
+import com.votogether.domain.post.exception.CommentExceptionType;
+import com.votogether.domain.post.exception.PostExceptionType;
+import com.votogether.domain.post.repository.CommentRepository;
+import com.votogether.domain.post.repository.PostRepository;
+import com.votogether.global.exception.NotFoundException;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class PostCommentService {
+
+ private final PostRepository postRepository;
+ private final CommentRepository commentRepository;
+
+ @Transactional
+ public void createComment(
+ final Member member,
+ final Long postId,
+ final CommentRegisterRequest commentRegisterRequest
+ ) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+
+ final Comment comment = Comment.builder()
+ .member(member)
+ .content(commentRegisterRequest.content())
+ .build();
+
+ post.addComment(comment);
+ }
+
+ @Transactional(readOnly = true)
+ public List getComments(final Long postId) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+
+ return commentRepository.findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(post)
+ .stream()
+ .map(CommentResponse::from)
+ .toList();
+ }
+
+ @Transactional
+ public void updateComment(
+ final Long postId,
+ final Long commentId,
+ final CommentUpdateRequest commentUpdateRequest,
+ final Member member
+ ) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+ final Comment comment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND));
+
+ comment.validateBelong(post);
+ comment.validateWriter(member);
+
+ comment.updateContent(commentUpdateRequest.content());
+ }
+
+ @Transactional
+ public void deleteComment(final Long postId, final Long commentId, final Member member) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+ final Comment comment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND));
+
+ comment.validateBelong(post);
+ comment.validateWriter(member);
+
+ commentRepository.delete(comment);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostService.java b/backend/src/main/java/com/votogether/domain/post/service/PostService.java
new file mode 100644
index 000000000..a658d0383
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/post/service/PostService.java
@@ -0,0 +1,410 @@
+package com.votogether.domain.post.service;
+
+import com.votogether.domain.category.entity.Category;
+import com.votogether.domain.category.repository.CategoryRepository;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.entity.vo.AgeRange;
+import com.votogether.domain.member.entity.vo.Gender;
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.domain.post.dto.request.post.PostCreateRequest;
+import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest;
+import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest;
+import com.votogether.domain.post.dto.request.post.PostUpdateRequest;
+import com.votogether.domain.post.dto.response.post.PostDetailResponse;
+import com.votogether.domain.post.dto.response.post.PostRankingResponse;
+import com.votogether.domain.post.dto.response.post.PostResponse;
+import com.votogether.domain.post.dto.response.post.PostSummaryResponse;
+import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.PostBody;
+import com.votogether.domain.post.entity.PostOption;
+import com.votogether.domain.post.entity.vo.PostClosingType;
+import com.votogether.domain.post.entity.vo.PostSortType;
+import com.votogether.domain.post.exception.PostExceptionType;
+import com.votogether.domain.post.repository.PostOptionRepository;
+import com.votogether.domain.post.repository.PostRepository;
+import com.votogether.domain.vote.repository.VoteRepository;
+import com.votogether.domain.vote.repository.dto.VoteStatus;
+import com.votogether.global.exception.BadRequestException;
+import com.votogether.global.exception.NotFoundException;
+import com.votogether.global.util.ImageUploader;
+import java.time.LocalDate;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+@Service
+public class PostService {
+
+ private static final int BASIC_PAGING_SIZE = 10;
+ private static final int MAXIMUM_DEADLINE = 3;
+
+ private final Map>> postsVotedByMemberMapper;
+ private final PostRepository postRepository;
+ private final PostOptionRepository postOptionRepository;
+ private final CategoryRepository categoryRepository;
+ private final VoteRepository voteRepository;
+
+ public PostService(
+ final PostRepository postRepository,
+ final PostOptionRepository postOptionRepository,
+ final CategoryRepository categoryRepository,
+ final VoteRepository voteRepository
+ ) {
+ this.postRepository = postRepository;
+ this.postOptionRepository = postOptionRepository;
+ this.categoryRepository = categoryRepository;
+ this.voteRepository = voteRepository;
+ this.postsVotedByMemberMapper = new EnumMap<>(PostClosingType.class);
+ initPostsVotedByMemberMapper();
+ }
+
+ private void initPostsVotedByMemberMapper() {
+ postsVotedByMemberMapper.put(PostClosingType.ALL, postRepository::findPostsVotedByMember);
+ postsVotedByMemberMapper.put(PostClosingType.PROGRESS, postRepository::findOpenPostsVotedByMember);
+ postsVotedByMemberMapper.put(PostClosingType.CLOSED, postRepository::findClosedPostsVotedByMember);
+ }
+
+ @Transactional
+ public Long save(
+ final PostCreateRequest postCreateRequest,
+ final Member loginMember,
+ final List contentImages,
+ final List optionImages
+ ) {
+ final List categories = categoryRepository.findAllById(postCreateRequest.categoryIds());
+ final Post post = toPostEntity(postCreateRequest, loginMember, contentImages, optionImages, categories);
+ post.validateDeadlineNotExceedByMaximumDeadline(MAXIMUM_DEADLINE);
+
+ return postRepository.save(post).getId();
+ }
+
+ private Post toPostEntity(
+ final PostCreateRequest postCreateRequest,
+ final Member loginMember,
+ final List contentImages,
+ final List optionImages,
+ final List categories
+ ) {
+ final Post post = toPost(postCreateRequest, loginMember);
+
+ post.mapPostOptionsByElements(
+ transformElements(postCreateRequest.postOptions(), PostOptionCreateRequest::content),
+ transformElements(optionImages, ImageUploader::upload)
+ );
+ post.mapCategories(categories);
+ addContentImageIfPresent(post, contentImages);
+
+ return post;
+ }
+
+ private Post toPost(
+ final PostCreateRequest postCreateRequest,
+ final Member loginMember
+ ) {
+ return Post.builder()
+ .writer(loginMember)
+ .postBody(toPostBody(postCreateRequest.title(), postCreateRequest.content()))
+ .deadline(postCreateRequest.deadline())
+ .build();
+ }
+
+ private PostBody toPostBody(final String title, final String content) {
+ return PostBody.builder()
+ .title(title)
+ .content(content)
+ .build();
+ }
+
+ private void addContentImageIfPresent(final Post post, final List contentImages) {
+ if (isContentImagesPresent(contentImages)) {
+ post.addContentImage(ImageUploader.upload(contentImages.get(0)));
+ }
+ }
+
+ private boolean isContentImagesPresent(final List contentImages) {
+ return Objects.nonNull(contentImages) && !contentImages.isEmpty();
+ }
+
+ @Transactional(readOnly = true)
+ public List getAllPostBySortTypeAndClosingTypeAndCategoryId(
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Member loginMember
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE, postSortType.getPostBaseSort());
+ final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId(
+ postClosingType,
+ postSortType,
+ categoryId,
+ pageable
+ );
+ return posts.stream()
+ .map(post -> PostResponse.of(post, loginMember))
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public List getPostsGuest(
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE);
+ final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId(
+ postClosingType,
+ postSortType,
+ categoryId,
+ pageable
+ );
+
+ return transformElements(posts, PostResponse::forGuest);
+ }
+
+ @Transactional(readOnly = true)
+ public PostDetailResponse getPostById(final Long postId, final Member loginMember) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+
+ return PostDetailResponse.of(post, loginMember);
+ }
+
+ @Transactional(readOnly = true)
+ public VoteOptionStatisticsResponse getVoteStatistics(final long postId, final Member member) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+
+ post.validateWriter(member);
+
+ final List voteStatuses =
+ voteRepository.findVoteCountByPostIdGroupByAgeRangeAndGender(post.getId());
+ return VoteOptionStatisticsResponse.from(groupVoteStatus(voteStatuses));
+ }
+
+ @Transactional(readOnly = true)
+ public VoteOptionStatisticsResponse getVoteOptionStatistics(
+ final long postId,
+ final long optionId,
+ final Member member
+ ) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+ final PostOption postOption = postOptionRepository.findById(optionId)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_OPTION_NOT_FOUND));
+
+ if (!postOption.isBelongsTo(post)) {
+ throw new BadRequestException(PostExceptionType.UNRELATED_POST_OPTION);
+ }
+ post.validateWriter(member);
+
+ final List voteStatuses =
+ voteRepository.findVoteCountByPostOptionIdGroupByAgeRangeAndGender(postOption.getId());
+ return VoteOptionStatisticsResponse.from(groupVoteStatus(voteStatuses));
+ }
+
+ private Map> groupVoteStatus(final List voteStatuses) {
+ return voteStatuses.stream()
+ .collect(Collectors.groupingBy(
+ status -> groupAgeRange(status.birthYear()),
+ LinkedHashMap::new,
+ Collectors.groupingBy(
+ VoteStatus::gender,
+ LinkedHashMap::new,
+ Collectors.summingLong(VoteStatus::count)
+ )
+ ));
+ }
+
+ private String groupAgeRange(final Integer birthYear) {
+ final int age = LocalDate.now().getYear() - birthYear + 1;
+ if (age <= 0) {
+ throw new BadRequestException(MemberExceptionType.INVALID_AGE);
+ }
+ return AgeRange.from(age).getName();
+ }
+
+ @Transactional(readOnly = true)
+ public List getPostsVotedByMember(
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Member member
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE, postSortType.getVoteBaseSort());
+
+ Slice posts = postsVotedByMemberMapper.get(postClosingType).apply(member, pageable);
+
+ return posts.stream()
+ .map(post -> PostResponse.of(post, member))
+ .toList();
+ }
+
+ @Transactional
+ public void closePostEarlyById(final Long id, final Member loginMember) {
+ final Post post = postRepository.findById(id)
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+
+ post.validateWriter(loginMember);
+ post.validateDeadLine();
+ post.closeEarly();
+ }
+
+ @Transactional(readOnly = true)
+ public List getPostsByWriter(
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Member member
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE);
+ final List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId(
+ member,
+ postClosingType,
+ postSortType,
+ categoryId,
+ pageable
+ );
+
+ return posts.stream()
+ .map(post -> PostResponse.of(post, member))
+ .toList();
+ }
+
+ public List searchPostsWithKeyword(
+ final String keyword,
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId,
+ final Member member
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE);
+ final List posts =
+ postRepository.findAllWithKeyword(keyword, postClosingType, postSortType, categoryId, pageable);
+
+ return posts.stream()
+ .map(post -> PostResponse.of(post, member))
+ .toList();
+ }
+
+ public List searchPostsWithKeywordForGuest(
+ final String keyword,
+ final int page,
+ final PostClosingType postClosingType,
+ final PostSortType postSortType,
+ final Long categoryId
+ ) {
+ final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE);
+ final List posts =
+ postRepository.findAllWithKeyword(keyword, postClosingType, postSortType, categoryId, pageable);
+
+ return posts.stream()
+ .map(PostResponse::forGuest)
+ .toList();
+ }
+
+ @Transactional
+ public void delete(final Long postId) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new BadRequestException(PostExceptionType.POST_NOT_FOUND));
+ post.validatePossibleToDelete();
+
+ postRepository.deleteById(postId);
+ }
+
+ @Transactional
+ public void update(
+ final long postId,
+ final PostUpdateRequest request,
+ final Member member,
+ final List contentImages,
+ final List optionImages
+ ) {
+ final Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new BadRequestException(PostExceptionType.POST_NOT_FOUND));
+
+ post.validateExistVote();
+ post.validateWriter(member);
+ post.validateDeadLine();
+ post.validateDeadLineToModify(request.deadline());
+
+ postOptionsInit(post);
+ post.update(
+ toPostBody(request.title(), request.content()),
+ request.imageUrl(),
+ transformElements(contentImages, ImageUploader::upload),
+ categoryRepository.findAllById(request.categoryIds()),
+ transformElements(request.postOptions(), PostOptionUpdateRequest::content),
+ transformElements(request.postOptions(), PostOptionUpdateRequest::imageUrl),
+ transformElements(optionImages, ImageUploader::upload),
+ request.deadline()
+ );
+ }
+
+ private void postOptionsInit(final Post post) {
+ post.postOptionsClear();
+ postRepository.flush();
+ }
+
+ private List transformElements(final List elements, final Function process) {
+ return elements.stream()
+ .map(process)
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public List getRanking() {
+ final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId(
+ PostClosingType.ALL,
+ PostSortType.HOT,
+ null,
+ PageRequest.of(0, BASIC_PAGING_SIZE)
+ );
+
+ final Map rankings = calculateRanking(posts);
+
+ return rankings.entrySet().stream()
+ .map(entry ->
+ new PostRankingResponse(
+ entry.getValue(),
+ PostSummaryResponse.from(entry.getKey())
+ )
+ )
+ .toList();
+ }
+
+ private Map calculateRanking(final List posts) {
+ final Map rankings = new LinkedHashMap<>();
+
+ int currentRanking = 1;
+ int previousRanking = -1;
+ long previousVoteCount = -1;
+ for (Post post : posts) {
+ final long currentVoteCount = post.getTotalVoteCount();
+ final int ranking = (currentVoteCount == previousVoteCount) ? previousRanking : currentRanking;
+ rankings.put(post, ranking);
+
+ previousRanking = ranking;
+ previousVoteCount = currentVoteCount;
+ currentRanking++;
+ }
+
+ return rankings;
+ }
+}
+
diff --git a/backend/src/main/java/com/votogether/domain/ranking/controller/RankingController.java b/backend/src/main/java/com/votogether/domain/ranking/controller/RankingController.java
new file mode 100644
index 000000000..56d832791
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/controller/RankingController.java
@@ -0,0 +1,31 @@
+package com.votogether.domain.ranking.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.ranking.dto.response.RankingResponse;
+import com.votogether.domain.ranking.service.RankingService;
+import com.votogether.global.jwt.Auth;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RestController
+public class RankingController implements RankingControllerDocs {
+
+ private final RankingService rankingService;
+
+ @GetMapping("/members/me/ranking/passion")
+ public ResponseEntity getRanking(@Auth final Member member) {
+ final RankingResponse response = rankingService.getPassionRanking(member);
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/members/ranking/passion/guest")
+ public ResponseEntity> getPassionRankings() {
+ final List response = rankingService.getPassionRanking();
+ return ResponseEntity.ok(response);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/ranking/controller/RankingControllerDocs.java b/backend/src/main/java/com/votogether/domain/ranking/controller/RankingControllerDocs.java
new file mode 100644
index 000000000..007fae1d0
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/controller/RankingControllerDocs.java
@@ -0,0 +1,22 @@
+package com.votogether.domain.ranking.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.ranking.dto.response.RankingResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "랭킹", description = "랭킹 API")
+public interface RankingControllerDocs {
+
+ @Operation(summary = "나의 열정 랭킹 조회", description = "나의 열정 랭킹 정보를 조회한다.")
+ @ApiResponse(responseCode = "200", description = "나의 열정 랭킹 정보 조회 성공")
+ ResponseEntity getRanking(final Member member);
+
+ @Operation(summary = "상위 10명의 열정 랭킹 조회", description = "상위 10명의 열정 랭킹 정보를 조회한다.")
+ @ApiResponse(responseCode = "200", description = "상위 10명의 열정 랭킹 조회 성공")
+ ResponseEntity> getPassionRankings();
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/ranking/dto/response/RankingResponse.java b/backend/src/main/java/com/votogether/domain/ranking/dto/response/RankingResponse.java
new file mode 100644
index 000000000..a33a61f92
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/dto/response/RankingResponse.java
@@ -0,0 +1,34 @@
+package com.votogether.domain.ranking.dto.response;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.ranking.entity.PassionRankings;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "랭킹 정보 응답")
+public record RankingResponse(
+ @Schema(description = "랭킹", example = "2")
+ int ranking,
+
+ @Schema(description = "닉네임", example = "유저")
+ String nickname,
+
+ @Schema(description = "게시글 수", example = "5")
+ int postCount,
+
+ @Schema(description = "투표 수", example = "6")
+ int voteCount,
+
+ @Schema(description = "점수", example = "31")
+ long score
+) {
+
+ public static RankingResponse of(final PassionRankings passionRankings, final Member member) {
+ return new RankingResponse(
+ passionRankings.getRanking(member),
+ member.getNickname(),
+ passionRankings.getPassionRecord(member).getPostCount(),
+ passionRankings.getPassionRecord(member).getVoteCount(),
+ passionRankings.getScore(member));
+ }
+}
+
diff --git a/backend/src/main/java/com/votogether/domain/ranking/entity/PassionRankings.java b/backend/src/main/java/com/votogether/domain/ranking/entity/PassionRankings.java
new file mode 100644
index 000000000..823843e0b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/entity/PassionRankings.java
@@ -0,0 +1,59 @@
+package com.votogether.domain.ranking.entity;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.ranking.entity.vo.PassionRecord;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+public class PassionRankings {
+
+ private final Map rankings = new LinkedHashMap<>();
+ private final Map passionBoard;
+
+ public PassionRankings(final Map passionBoard) {
+ this.passionBoard = passionBoard;
+ calculateRanking();
+ }
+
+ private void calculateRanking() {
+ final List members = passionBoard.entrySet().stream()
+ .sorted(Comparator.comparingLong(entry -> -entry.getValue().calculateScore()))
+ .map(Entry::getKey)
+ .toList();
+
+ int currentRanking = 1;
+ int previousRanking = -1;
+ long previousScore = -1;
+ for (final Member member : members) {
+ long currentScore = passionBoard.get(member).calculateScore();
+ int ranking = (currentScore == previousScore) ? previousRanking : currentRanking;
+
+ this.rankings.put(member, ranking);
+
+ previousRanking = ranking;
+ previousScore = currentScore;
+ currentRanking++;
+ }
+ }
+
+ public List getTop10Members() {
+ return new ArrayList<>(rankings.keySet())
+ .subList(0, Math.min(10, rankings.size()));
+ }
+
+ public long getScore(final Member member) {
+ return passionBoard.get(member).calculateScore();
+ }
+
+ public int getRanking(final Member member) {
+ return rankings.get(member);
+ }
+
+ public PassionRecord getPassionRecord(final Member member) {
+ return passionBoard.get(member);
+ }
+}
diff --git a/backend/src/main/java/com/votogether/domain/ranking/entity/vo/PassionRecord.java b/backend/src/main/java/com/votogether/domain/ranking/entity/vo/PassionRecord.java
new file mode 100644
index 000000000..64f9784c5
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/entity/vo/PassionRecord.java
@@ -0,0 +1,23 @@
+package com.votogether.domain.ranking.entity.vo;
+
+import lombok.Getter;
+
+@Getter
+public class PassionRecord {
+
+ private static final int POST_WEIGHT = 5;
+ private static final int VOTE_WEIGHT = 1;
+
+ private final int postCount;
+ private final int voteCount;
+
+ public PassionRecord(int postCount, int voteCount) {
+ this.postCount = postCount;
+ this.voteCount = voteCount;
+ }
+
+ public long calculateScore() {
+ return postCount * POST_WEIGHT + voteCount * VOTE_WEIGHT;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/ranking/service/RankingService.java b/backend/src/main/java/com/votogether/domain/ranking/service/RankingService.java
new file mode 100644
index 000000000..e811e6ede
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/ranking/service/RankingService.java
@@ -0,0 +1,54 @@
+package com.votogether.domain.ranking.service;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.repository.MemberRepository;
+import com.votogether.domain.post.repository.PostRepository;
+import com.votogether.domain.ranking.dto.response.RankingResponse;
+import com.votogether.domain.ranking.entity.PassionRankings;
+import com.votogether.domain.ranking.entity.vo.PassionRecord;
+import com.votogether.domain.vote.repository.VoteRepository;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class RankingService {
+
+ private final MemberRepository memberRepository;
+ private final PostRepository postRepository;
+ private final VoteRepository voteRepository;
+
+ @Transactional(readOnly = true)
+ public RankingResponse getPassionRanking(final Member member) {
+ final PassionRankings passionRankings = getPassionRankings();
+ return RankingResponse.of(passionRankings, member);
+ }
+
+ @Transactional(readOnly = true)
+ public List getPassionRanking() {
+ final PassionRankings passionRankings = getPassionRankings();
+ return passionRankings.getTop10Members()
+ .stream()
+ .map(member -> RankingResponse.of(passionRankings, member)
+ ).toList();
+ }
+
+ private PassionRankings getPassionRankings() {
+ final List members = memberRepository.findAll();
+ final List postCounts = postRepository.findCountsByMembers(members);
+ final List voteCounts = voteRepository.findCountsByMembers(members);
+
+ final Map passionBoard = new LinkedHashMap<>();
+
+ for (int i = 0; i < members.size(); i++) {
+ passionBoard.put(members.get(i), new PassionRecord(postCounts.get(i), voteCounts.get(i)));
+ }
+
+ return new PassionRankings(passionBoard);
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/controller/ReportController.java b/backend/src/main/java/com/votogether/domain/report/controller/ReportController.java
new file mode 100644
index 000000000..fc94dfd68
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/controller/ReportController.java
@@ -0,0 +1,26 @@
+package com.votogether.domain.report.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.report.dto.request.ReportRequest;
+import com.votogether.domain.report.service.ReportService;
+import com.votogether.global.jwt.Auth;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RestController
+public class ReportController implements ReportControllerDocs {
+
+ private final ReportService reportService;
+
+ @PostMapping("/report")
+ public ResponseEntity report(@Valid @RequestBody final ReportRequest request, @Auth final Member member) {
+ reportService.report(member, request);
+ return ResponseEntity.ok().build();
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java b/backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java
new file mode 100644
index 000000000..a07c32024
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java
@@ -0,0 +1,33 @@
+package com.votogether.domain.report.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.report.dto.request.ReportRequest;
+import com.votogether.global.exception.ExceptionResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "신고", description = "신고 API")
+public interface ReportControllerDocs {
+
+ @Operation(summary = "신고", description = "게시글, 댓글, 닉네임을 신고한다.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "신고 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "올바르지 않은 요청",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 신고 대상",
+ content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
+ )
+ })
+ ResponseEntity report(final ReportRequest request, final Member member);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/dto/request/ReportRequest.java b/backend/src/main/java/com/votogether/domain/report/dto/request/ReportRequest.java
new file mode 100644
index 000000000..d4749b914
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/dto/request/ReportRequest.java
@@ -0,0 +1,21 @@
+package com.votogether.domain.report.dto.request;
+
+import com.votogether.domain.report.entity.vo.ReportType;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "신고 요청")
+public record ReportRequest(
+ @Schema(description = "신고 유형", example = "POST")
+ ReportType type,
+
+ @Schema(description = "신고 대상 ID", example = "1")
+ @NotNull(message = "신고 대상 ID는 빈 값일 수 없습니다.")
+ Long id,
+
+ @Schema(description = "신고 사유", example = "불건전한 닉네임")
+ @NotBlank(message = "신고 사유는 빈 값이거나 공백으로만 이루어질 수 없습니다.")
+ String reason
+) {
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/entity/Report.java b/backend/src/main/java/com/votogether/domain/report/entity/Report.java
new file mode 100644
index 000000000..9d313c41f
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/entity/Report.java
@@ -0,0 +1,64 @@
+package com.votogether.domain.report.entity;
+
+import com.votogether.domain.common.BaseEntity;
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.report.entity.vo.ReportType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(
+ uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "reportType", "targetId"})},
+ indexes = {@Index(columnList = "targetId, reportType")}
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Entity
+public class Report extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member member;
+
+ @Enumerated(value = EnumType.STRING)
+ @Column(length = 20, nullable = false)
+ private ReportType reportType;
+
+ @Column(nullable = false)
+ private Long targetId;
+
+ @Column(nullable = false, length = 50)
+ private String reason;
+
+ @Builder
+ private Report(
+ final Member member,
+ final ReportType reportType,
+ final Long targetId,
+ final String reason
+ ) {
+ this.member = member;
+ this.reportType = reportType;
+ this.targetId = targetId;
+ this.reason = reason;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/entity/vo/ReportType.java b/backend/src/main/java/com/votogether/domain/report/entity/vo/ReportType.java
new file mode 100644
index 000000000..3b38119fc
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/entity/vo/ReportType.java
@@ -0,0 +1,10 @@
+package com.votogether.domain.report.entity.vo;
+
+public enum ReportType {
+
+ POST,
+ COMMENT,
+ NICKNAME,
+ ;
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/exception/ReportExceptionType.java b/backend/src/main/java/com/votogether/domain/report/exception/ReportExceptionType.java
new file mode 100644
index 000000000..ea38d6342
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/exception/ReportExceptionType.java
@@ -0,0 +1,27 @@
+package com.votogether.domain.report.exception;
+
+import com.votogether.global.exception.ExceptionType;
+import lombok.Getter;
+
+@Getter
+public enum ReportExceptionType implements ExceptionType {
+
+ REPORT_MY_POST(1200, "자신의 게시글은 신고할 수 없습니다."),
+ ALREADY_HIDDEN_POST(1201, "이미 블라인드 처리된 글입니다."),
+ DUPLICATE_POST_REPORT(1202, "하나의 글에 대해서 중복하여 신고할 수 없습니다."),
+ REPORT_MY_COMMENT(1203, "자신의 댓글은 신고할 수 없습니다."),
+ ALREADY_HIDDEN_COMMENT(1204, "이미 블라인드 처리된 댓글입니다."),
+ DUPLICATE_COMMENT_REPORT(1205, "하나의 댓글에 대해서 중복하여 신고할 수 없습니다."),
+ REPORT_MY_NICKNAME(1206, "자신의 닉네임은 신고할 수 없습니다."),
+ DUPLICATE_NICKNAME_REPORT(1207, "하나의 닉네임에 대해서 중복하여 신고할 수 없습니다."),
+ ;
+
+ private final int code;
+ private final String message;
+
+ ReportExceptionType(final int code, final String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java b/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java
new file mode 100644
index 000000000..9f56a5d0b
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java
@@ -0,0 +1,24 @@
+package com.votogether.domain.report.repository;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.report.entity.Report;
+import com.votogether.domain.report.entity.vo.ReportType;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ReportRepository extends JpaRepository {
+
+ int countByReportTypeAndTargetId(final ReportType reportType, final Long targetId);
+
+ Optional findByMemberAndReportTypeAndTargetId(
+ final Member member,
+ final ReportType reportType,
+ final Long targetId
+ );
+
+ List findAllByMember(final Member member);
+
+ List findAllByReportTypeAndTargetId(final ReportType reportType, final Long targetId);
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/report/service/ReportService.java b/backend/src/main/java/com/votogether/domain/report/service/ReportService.java
new file mode 100644
index 000000000..5357b6c02
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/report/service/ReportService.java
@@ -0,0 +1,164 @@
+package com.votogether.domain.report.service;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.member.exception.MemberExceptionType;
+import com.votogether.domain.member.repository.MemberRepository;
+import com.votogether.domain.post.entity.Post;
+import com.votogether.domain.post.entity.comment.Comment;
+import com.votogether.domain.post.exception.CommentExceptionType;
+import com.votogether.domain.post.exception.PostExceptionType;
+import com.votogether.domain.post.repository.CommentRepository;
+import com.votogether.domain.post.repository.PostRepository;
+import com.votogether.domain.report.dto.request.ReportRequest;
+import com.votogether.domain.report.entity.Report;
+import com.votogether.domain.report.entity.vo.ReportType;
+import com.votogether.domain.report.exception.ReportExceptionType;
+import com.votogether.domain.report.repository.ReportRepository;
+import com.votogether.global.exception.BadRequestException;
+import com.votogether.global.exception.NotFoundException;
+import java.util.Objects;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class ReportService {
+
+ private final ReportRepository reportRepository;
+ private final PostRepository postRepository;
+ private final CommentRepository commentRepository;
+ private final MemberRepository memberRepository;
+
+ @Transactional
+ public void report(final Member reporter, final ReportRequest request) {
+ if (request.type() == ReportType.POST) {
+ reportPost(reporter, request);
+ }
+ if (request.type() == ReportType.COMMENT) {
+ reportComment(reporter, request);
+ }
+ if (request.type() == ReportType.NICKNAME) {
+ reportNickname(reporter, request);
+ }
+ }
+
+ private void reportPost(
+ final Member reporter,
+ final ReportRequest request
+ ) {
+ final Post reportedPost = postRepository.findById(request.id())
+ .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
+ validatePost(reporter, reportedPost, request);
+
+ saveReport(reporter, request);
+ blindPost(request, reportedPost);
+ }
+
+ private void validatePost(
+ final Member reporter,
+ final Post reportedPost,
+ final ReportRequest request
+ ) {
+ reportedPost.validateMine(reporter);
+ reportedPost.validateHidden();
+ validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_POST_REPORT);
+ }
+
+ private void validateDuplicatedReport(
+ final Member reporter,
+ final ReportRequest request,
+ final ReportExceptionType exceptionType
+ ) {
+ reportRepository.findByMemberAndReportTypeAndTargetId(reporter, request.type(), request.id())
+ .ifPresent(report -> {
+ throw new BadRequestException(exceptionType);
+ });
+ }
+
+ private void saveReport(final Member reporter, final ReportRequest request) {
+ final Report report = Report.builder()
+ .member(reporter)
+ .reportType(request.type())
+ .targetId(request.id())
+ .reason(request.reason())
+ .build();
+ reportRepository.save(report);
+ }
+
+ private void blindPost(
+ final ReportRequest request,
+ final Post reportedPost
+ ) {
+ final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id());
+ if (reportCount >= 5) {
+ reportedPost.blind();
+ }
+ }
+
+ private void reportComment(
+ final Member reporter,
+ final ReportRequest request
+ ) {
+ final Comment reportedComment = commentRepository.findById(request.id())
+ .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND));
+ validateComment(reporter, request, reportedComment);
+
+ saveReport(reporter, request);
+ blindComment(request, reportedComment);
+ }
+
+ private void validateComment(
+ final Member reporter,
+ final ReportRequest request,
+ final Comment reportedComment
+ ) {
+ reportedComment.validateMine(reporter);
+ reportedComment.validateHidden();
+ validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_COMMENT_REPORT);
+ }
+
+ private void blindComment(
+ final ReportRequest request,
+ final Comment reportedComment
+ ) {
+ final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id());
+ if (reportCount >= 5) {
+ reportedComment.blind();
+ }
+ }
+
+ private void reportNickname(
+ final Member reporter,
+ final ReportRequest request
+ ) {
+ final Member reportedMember = memberRepository.findById(request.id())
+ .orElseThrow(() -> new NotFoundException(MemberExceptionType.NONEXISTENT_MEMBER));
+ validateNickname(reporter, request);
+
+ saveReport(reporter, request);
+ changeNicknameByReport(reportedMember, request.type());
+ }
+
+ private void validateNickname(
+ final Member reporter,
+ final ReportRequest request
+ ) {
+ validateMyNickname(reporter, request);
+ validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_NICKNAME_REPORT);
+ }
+
+ private void validateMyNickname(final Member reporter, final ReportRequest request) {
+ if (Objects.equals(reporter.getId(), request.id())) {
+ throw new BadRequestException(ReportExceptionType.REPORT_MY_NICKNAME);
+ }
+ }
+
+ private void changeNicknameByReport(final Member reportedMember, final ReportType reportType) {
+ final int reportCount = reportRepository.countByReportTypeAndTargetId(reportType, reportedMember.getId());
+ if (reportCount >= 3) {
+ reportedMember.changeNicknameByReport();
+ }
+ }
+
+}
diff --git a/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java b/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java
new file mode 100644
index 000000000..7c4fbd5e2
--- /dev/null
+++ b/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java
@@ -0,0 +1,42 @@
+package com.votogether.domain.vote.controller;
+
+import com.votogether.domain.member.entity.Member;
+import com.votogether.domain.vote.service.VoteService;
+import com.votogether.global.jwt.Auth;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class VoteController implements VoteControllerDocs {
+
+ private final VoteService voteService;
+
+ @PostMapping("/posts/{postId}/options/{optionId}")
+ public ResponseEntity vote(
+ @PathVariable final Long postId,
+ @PathVariable("optionId") final Long postOptionId,
+ @Auth final Member member
+ ) {
+ voteService.vote(member, postId, postOptionId);
+ return ResponseEntity.status(HttpStatus.CREATED).build();
+ }
+
+ @PatchMapping("/posts/{postId}/options")
+ public ResponseEntity