diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000..2590a64
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,14 @@
+language: "ko-KR"
+early_access: false
+reviews:
+ profile: "chill"
+ request_changes_workflow: false
+ high_level_summary: true
+ poem: true
+ review_status: true
+ collapse_walkthrough: false
+ auto_review:
+ enabled: true
+ drafts: false
+chat:
+ auto_reply: true
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8af972c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+/gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..290ada1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,41 @@
+name: ๐ ๋ฒ๊ทธ ๋ฆฌํฌํธ
+description: ๋ฒ๊ทธ๊ฐ ๋ฐ์ํ์ ๋ ์ฌ์ฉํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "fix: "
+labels: ["๐bug"]
+assignees: []
+
+body:
+ - type: textarea
+ id: bug_summary
+ attributes:
+ label: ๐ ์ด๋ค ๋ฒ๊ทธ์ธ๊ฐ์?
+ description: ๋ฐ๊ฒฌ๋ ๋ฒ๊ทธ๋ฅผ ๊ฐ๋จํ๊ฒ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) ๋ก๊ทธ์ธ ๋ฒํผ ํด๋ฆญ ์ 500 ์๋ฌ ๋ฐ์
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: bug_scenario
+ attributes:
+ label: ๐คทโโ๏ธ ์ด๋ค ์ํฉ์์ ๋ฐ์ํ ๋ฒ๊ทธ์ธ๊ฐ์?
+ description: ๊ฐ๋ฅํ๋ค๋ฉด Given-When-Then ํ์์ผ๋ก ์์ฑํด์ฃผ์ธ์.
+ placeholder: |
+ **Given:** ๋ก๊ทธ์ธ ํ์ด์ง์ ์ ์ํ ์ํ์์
+ **When:** ๋ก๊ทธ์ธ ๋ฒํผ์ ํด๋ฆญํ์ ๋
+ **Then:** ๋ฉ์ธ ํ์ด์ง๋ก ์ด๋ํด์ผ ํ์ง๋ง 500 ์๋ฌ ๋ฐ์
+ value: "> "
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected_result
+ attributes:
+ label: ๐ค ์์ ๊ฒฐ๊ณผ
+ description: ๊ธฐ๋ํ๋ ์ ์ ๋์์ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) ๋ก๊ทธ์ธ ๋ฒํผ ํด๋ฆญ ์ ์ ์์ ์ผ๋ก ๋ฉ์ธ ํ์ด์ง๋ก ์ด๋
+ value: "- "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml
new file mode 100644
index 0000000..223abc3
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/chore.yml
@@ -0,0 +1,30 @@
+name: โ๏ธ ์์
+description: ์ธํ
, ๋ฐฐํฌ, ํ๊ฒฝ์ค์ ๋ฑ ๊ธฐ๋ฅ ์ธ ์์
์ ๊ธฐ๋กํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "chore: "
+labels: ["โ๏ธsetting"]
+assignees: []
+
+body:
+ - type: textarea
+ id: chore_type
+ attributes:
+ label: โ๏ธ ์ด๋ค ์์
์ธ๊ฐ์?
+ description: ์์
์ ๋ฒ์์ ๋ชฉ์ ์ ๊ฐ๋จํ ์ค๋ช
ํด์ฃผ์ธ์. (CI/CD ํ์ดํ๋ผ์ธ ์ธํ
, Dockerfile ์์ ๋ฑ)
+ placeholder: |
+ ์) GitHub Actions ์ํฌํ๋ก์ฐ์์ ํ
์คํธ ๋จ๊ณ ์ถ๊ฐ
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: tasks
+ attributes:
+ label: ๐ ์์
์์ธ ๋ด์ฉ
+ description: ์์
ํด์ผ ํ ํญ๋ชฉ๋ค์ ์ฒดํฌ๋ฆฌ์คํธ ํ์์ผ๋ก ์ ๋ฆฌํด์ฃผ์ธ์.
+ placeholder: |
+ - [ ] Dockerfile ์์
+ - [ ] Nginx ์ค์ ์
๋ฐ์ดํธ
+ - [ ] ๋ฐฐํฌ ์คํฌ๋ฆฝํธ ๊ฐ์
+ value: "- [ ] "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml
new file mode 100644
index 0000000..d26f14f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/docs.yml
@@ -0,0 +1,30 @@
+name: ๐ ๋ฌธ์ ์์
+description: README, ์ด์/PR ํ
ํ๋ฆฟ, ๊ฐ์ด๋ ๋ฑ ๋ฌธ์ ๊ด๋ จ ์์
์ ๊ธฐ๋กํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "docs: "
+labels: ["๐docs"]
+assignees: []
+
+body:
+ - type: textarea
+ id: doc_type
+ attributes:
+ label: ๐ ์ด๋ค ๋ฌธ์์ธ๊ฐ์?
+ description: ์์ฑํ๊ฑฐ๋ ์์ ํ๋ ค๋ ๋ฌธ์์ ์ข
๋ฅ์ ๋ชฉ์ ์ ๊ฐ๋จํ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) README์ ํ๋ก์ ํธ ๋น๋ ๋ฐฉ๋ฒ ์ถ๊ฐ
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: tasks
+ attributes:
+ label: ๐ ์์
์์ธ ๋ด์ฉ
+ description: ๋ฌธ์ ์์
์ ์ฃผ์ ๋ด์ฉ์ ์ฒดํฌ๋ฆฌ์คํธ๋ก ์ ๋ฆฌํด์ฃผ์ธ์.
+ placeholder: |
+ - [ ] README์ ๋น๋ ๋ฐ ์คํ ๋ฐฉ๋ฒ ์ถ๊ฐ
+ - [ ] CONTRIBUTING.md ์์ฑ
+ - [ ] API ์ฌ์ฉ ๊ฐ์ด๋ ์
๋ฐ์ดํธ
+ value: "- [ ] "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
new file mode 100644
index 0000000..53b467d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature.yml
@@ -0,0 +1,29 @@
+name: โจ ๊ธฐ๋ฅ ์ถ๊ฐ
+description: ์๋ก์ด ๊ธฐ๋ฅ์ ์ถ๊ฐํ ๋ ์ฌ์ฉํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "feat: "
+labels: ["โจfeature"]
+assignees: []
+
+body:
+ - type: textarea
+ id: summary
+ attributes:
+ label: ๐ ์ด๋ค ๊ธฐ๋ฅ์ธ๊ฐ์?
+ description: ์ถ๊ฐํ๋ ค๋ ๊ธฐ๋ฅ์ ๋ํด ๊ฐ๊ฒฐํ๊ฒ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) ์ฌ์ฉ์ ํ์๊ฐ์
, ๋ก๊ทธ์ธ ๊ธฐ๋ฅ ๊ตฌํ
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: tasks
+ attributes:
+ label: ๐ ์์
์์ธ ๋ด์ฉ
+ description: ๊ตฌํํด์ผ ํ ์์
ํญ๋ชฉ์ ์ฒดํฌ๋ฆฌ์คํธ๋ก ์์ฑํด์ฃผ์ธ์.
+ placeholder: |
+ - [ ] ์ฌ์ฉ์ ๋ก๊ทธ์ธ API
+ - [ ] ์ฌ์ฉ์ ์จ๋ณด๋ฉ API
+ value: "- [ ] "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml
new file mode 100644
index 0000000..6823224
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/refactor.yml
@@ -0,0 +1,41 @@
+name: โป๏ธ ๋ฆฌํฉํ ๋ง
+description: ๋ฆฌํฉํ ๋ง ์์
์ ๊ธฐ๋กํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "refactor: "
+labels: ["โป๏ธrefactor"]
+assignees: []
+
+body:
+ - type: textarea
+ id: target
+ attributes:
+ label: ๐ ๋ฆฌํฉํ ๋ง ๋์
+ description: ์ด๋ค ๊ธฐ๋ฅ/๋ชจ๋์ ๋ฆฌํฉํ ๋งํ๋์ง ๋ช
ํํ ์์ฑํด์ฃผ์ธ์.
+ placeholder: |
+ ์) UserService ํ์๊ฐ์
๋ก์ง
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: reason
+ attributes:
+ label: ๐ ๏ธ ๋ฆฌํฉํ ๋ง ์ฌ์
+ description: ๋ฆฌํฉํ ๋ง์ ํ๊ฒ ๋ ๋ฐฐ๊ฒฝ์ด๋ ํ์์ฑ์ ๊ฐ๋จํ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) ์ฝ๋ ์ค๋ณต ์ ๊ฑฐ ๋ฐ ํ
์คํธ ์ฉ์ด์ฑ ํฅ์
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: tasks
+ attributes:
+ label: ๐ ์์
์์ธ ๋ด์ฉ
+ description: ์ฃผ์ ๋ณ๊ฒฝ ์ฌํญ์ ์ฒดํฌ๋ฆฌ์คํธ ํ์์ผ๋ก ์ ๋ฆฌํด์ฃผ์ธ์.
+ placeholder: |
+ - [ ] ์ค๋ณต๋ ๋ก์ง ๋ถ๋ฆฌ
+ - [ ] ๋ฉ์๋ ๋ค์ด๋ฐ ๊ฐ์
+ - [ ] ๋จ์ ํ
์คํธ ๋ณด๊ฐ
+ value: "- [ ] "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/test.yml b/.github/ISSUE_TEMPLATE/test.yml
new file mode 100644
index 0000000..05cf479
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/test.yml
@@ -0,0 +1,40 @@
+name: ๐งช ํ
์คํธ ์ฝ๋ ์์ฑ
+description: ํ
์คํธ ์ฝ๋ ์์ฑ ์ด์๋ฅผ ๊ธฐ๋กํ๋ ํ
ํ๋ฆฟ์
๋๋ค.
+title: "test: "
+labels: ["๐งชtest"]
+assignees: []
+
+body:
+ - type: textarea
+ id: test_scope
+ attributes:
+ label: ๐งช ์ด๋ค ํ
์คํธ์ธ๊ฐ์?
+ description: ์์ฑํ ํ
์คํธ์ ๋ฒ์์ ๋ชฉ์ ์ ๊ฐ๋จํ ์ค๋ช
ํด์ฃผ์ธ์.
+ placeholder: |
+ ์) UserService ํ์๊ฐ์
๋ก์ง ๋จ์ ํ
์คํธ
+ value: "- "
+ validations:
+ required: true
+
+ - type: textarea
+ id: test_items
+ attributes:
+ label: ๐ ํ
์คํธ ํญ๋ชฉ
+ description: ์์ฑํ ํ
์คํธ ์ผ์ด์ค๋ค์ ์ฒดํฌ๋ฆฌ์คํธ๋ก ์ ๋ฆฌํด์ฃผ์ธ์.
+ placeholder: |
+ - [ ] ์๋ชป๋ ID๋ก ์กฐํ ์ 404 ๋ฐํ
+ - [ ] ์ ํจํ ์
๋ ฅ๊ฐ์ผ๋ก ์ ์ฅ ์ ์ ์์ ์ผ๋ก ๋ฐ์ดํฐ๊ฐ ๋ฐ์๋จ
+ value: "- [ ] "
+ validations:
+ required: false
+
+ - type: textarea
+ id: expected_result
+ attributes:
+ label: ๐ฏ ๊ธฐ๋ ๊ฒฐ๊ณผ
+ description: ํ
์คํธ๊ฐ ํต๊ณผํด์ผ ํ๋ ๊ธฐ์ค์ด๋ ์๋๋ฆฌ์ค๋ฅผ ์์ฑํด์ฃผ์ธ์.
+ placeholder: |
+ ์) ์๋ชป๋ ID๋ก ์กฐํ ์ 404 ๋ฐํ
+ value: "- "
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..a63e0a2
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,33 @@
+## ๐ ์ฐ๊ด๋ ์ด์
+- #
+
+## ๐ ๋ณ๊ฒฝ ์ ํ
+- [ ] โจ ๊ธฐ๋ฅ ์ถ๊ฐ (feature)
+- [ ] ๐ ๋ฒ๊ทธ ์์ (fix)
+- [ ] ๐ ๋ฌธ์ ๋ณ๊ฒฝ (docs)
+- [ ] โป๏ธ ๋ฆฌํฉํ ๋ง (refactor)
+- [ ] ๐งช ํ
์คํธ ์ถ๊ฐ / ์์ (test)
+- [ ] โ๏ธ ์ค์ ๋ณ๊ฒฝ (chore)
+
+## ๐ ์์
๋ด์ฉ
+
+-
+
+## ๐ธ ์คํฌ๋ฆฐ์ท
+>
+
+## ๐ฌ ๋ฆฌ๋ทฐ ์๊ตฌ์ฌํญ
+
+
+### ๐ ๋ฆฌ๋ทฐ ๊ท์น
+
+Reviewer๋ ์๋ **P5 Rule**์ ์ฐธ๊ณ ํ์ฌ ๋ฆฌ๋ทฐ๋ฅผ ์งํํฉ๋๋ค.
+P5 Rule์ ํตํด Reviewer๋ Reviewee์๊ฒ ๋ฆฌ๋ทฐ์ ์๋๋ฅผ ๋ณด๋ค ์ ํํ ์ ๋ฌํ ์ ์์ต๋๋ค.
+
+> - **P1:** ๊ผญ ๋ฐ์ํด์ฃผ์ธ์ (Comment)
+> - **P2:** ์ ๊ทน์ ์ผ๋ก ๊ณ ๋ คํด์ฃผ์ธ์ (Comment)
+> - **P3:** ์ฌ๋งํ๋ฉด ๋ฐ์ํด ์ฃผ์ธ์ (Comment)
+> - **P4:** ๋ฐ์ํด๋ ์ข๊ณ ๋์ด๊ฐ๋ ์ข์ต๋๋ค (Approve)
+> - **P5:** ๊ทธ๋ฅ ์ฌ์ํ ์๊ฒฌ์
๋๋ค (Approve)
+
+-
\ No newline at end of file
diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
new file mode 100644
index 0000000..33d7dff
--- /dev/null
+++ b/.github/workflows/cicd.yml
@@ -0,0 +1,152 @@
+name: CI/CD Workflow
+
+on:
+ push:
+ branches: [ "main", "develop"]
+
+jobs:
+ CI-CD:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'corretto'
+
+ - name: Gradle Caching
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew clean build -x test
+
+ - name: Create secrets files from GitHub Secrets
+ run: |
+ mkdir -p ./src/main/resources/secret
+ echo "${{ secrets.PROD_ENV_CONTENT }}" > ./src/main/resources/secret/prod.env
+ echo "${{ secrets.DEV_ENV_CONTENT }}" > ./src/main/resources/secret/dev.env
+ cat <<'EOF' > ./src/main/resources/secret/deploy.sh
+ ${{ secrets.DEPLOY_FILE }}
+ EOF
+ chmod 600 ./src/main/resources/secret/*.env
+ chmod 600 ./src/main/resources/secret/deploy.sh
+ echo "โ
.env & .sh ํ์ผ ์์ฑ ์๋ฃ"
+
+ - name: Docker Login
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # ------------------ ํ์ผ๋ณ ๊ฐ๋ณ ์ ์ก ------------------
+ - name: Copy prod.env to NCP server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ source: ./src/main/resources/secret/prod.env
+ target: /root
+ strip_components: 5
+
+ - name: Copy dev.env to NCP server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ source: ./src/main/resources/secret/dev.env
+ target: /root
+ strip_components: 5
+
+ - name: Copy deploy.sh to NCP server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ source: ./src/main/resources/secret/deploy.sh
+ target: /root
+ strip_components: 5
+
+ - name: Copy docker-compose.yml to NCP server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ source: ./docker-compose.yml
+ target: /root
+
+ # ---------------------- PROD Deployment ----------------------
+ - name: Docker build & push (prod)
+ if: github.ref == 'refs/heads/main'
+ run: |
+ SHA=${{ github.sha }}
+ SHORT_SHA=${SHA:0:7}
+ IMAGE_NAME=${{ secrets.DOCKER_REPO }}/${{ secrets.DOCKER_IMAGE_NAME }}
+
+ echo "โ
Building $IMAGE_NAME with tags $SHORT_SHA and prod"
+
+ docker build -f Dockerfile -t $IMAGE_NAME:$SHORT_SHA .
+ docker tag $IMAGE_NAME:$SHORT_SHA $IMAGE_NAME:prod
+
+ docker push $IMAGE_NAME:$SHORT_SHA
+ docker push $IMAGE_NAME:prod
+
+ echo "โ
Pushed $IMAGE_NAME:$SHORT_SHA and $IMAGE_NAME:prod"
+
+ - name: Deploy to prod
+ if: github.ref == 'refs/heads/main'
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ echo "๐ Deploying to PROD server..."
+
+ cd /root
+ chmod +x deploy.sh
+ ./deploy.sh prod
+
+ # ---------------------- DEV Deployment ----------------------
+ - name: Docker build & push (develop)
+ if: github.ref == 'refs/heads/develop'
+ run: |
+ IMAGE_NAME=${{ secrets.DOCKER_REPO }}/${{ secrets.DOCKER_IMAGE_NAME }}
+
+ echo "โ
Building $IMAGE_NAME with tags dev"
+
+ docker build -f Dockerfile -t $IMAGE_NAME:dev .
+ docker push $IMAGE_NAME:dev
+
+ echo "โ
Pushed $IMAGE_NAME:dev"
+
+ - name: Deploy to develop
+ if: github.ref == 'refs/heads/develop'
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ echo "๐ Deploying to DEV server..."
+
+ cd /root
+ chmod +x deploy.sh
+ ./deploy.sh dev
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8b675af
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,46 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+src/main/resources/secret/
+
+### application.yml ###
+application.yml
+application-local.yml
+application-dev.yml
+application-prod.yml
+application-test.yml
+
+### 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/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e5cc0bf
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,6 @@
+FROM amazoncorretto:21-alpine
+WORKDIR /app
+COPY ./build/libs/cheeeese-0.0.1-SNAPSHOT.jar /app/cheeeese.jar
+EXPOSE 8080
+ENTRYPOINT ["java"]
+CMD ["-jar", "cheeeese.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 554ba03..328f1c2 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,82 @@
-# BE
-๐ง Kusitms 32nd Cheeeese Backend Repository ๐ง
+# ๐ง ์น์ด์ด์ฆ : ๋ฑ 7์ผ๋ง ์ด๋ฆฌ๋ ํน๋ณํ ๊ณต์ ์จ๋ฒ ์๋น์ค
+> ๐ ์๋น์ค ๋งํฌ: [https://say-cheese.me](https://say-cheese.me)
+
+
+
+## ๐งโ๐คโ๐ง Backend Members
+
+|**์ฐ๋คํ** | **์ฃผ์ ๋น** |
+| :--------: |:----------:|
+|
|
|
+| `backend` | `backend` |
+
+## ๐ API ๋ช
์ธ์
+[[Swagger] ๐ง ์น์ด์ด์ฆ API ๋ช
์ธ์](https://dev.say-cheese.me/swagger-ui/index.html#/)
+
+## ๐๏ธ ERD
+
+
+## ๐๏ธ System Architecture
+
+
+## ๐ ๏ธ ๊ธฐ์ ์คํ
+- Language & Framework
+ - Spring Boot 3.5.6
+ - Java 21
+ - JPA
+
+- Database & Cache
+ - MySQL
+ - Redis
+
+- CI/CD & Deployment
+ - GitHub Actions + Docker
+ - ์ฝ๋ ํธ์ โ ์๋ ๋น๋ โ ํ
์คํธ โ Docker ์ด๋ฏธ์ง ์์ฑ โ ๋ฐฐํฌ๊น์ง ์๋ํ
+ - ์ปจํ
์ด๋ ๊ธฐ๋ฐ์ผ๋ก ํ๊ฒฝ ์ผ๊ด์ฑ ํ๋ณด ๋ฐ ๋ฌด์ค๋จ ๋ฐฐํฌ ๊ฐ๋ฅ
+ - ์ด์ ํ๊ฒฝ์ ๋ง์ถ Blue-Green ๋ฐฉ์ ๊ตฌํ
+
+- Monitoring & Logging
+ - Promtail : ์๋ฒ ๋ก๊ทธ๋ฅผ ์์งํ์ฌ ์ค์ ์ง์คํ ๋ก๊น
๊ตฌ์ฑ
+ - Loki : ๋น์ฉ ์ต์ ํ๋ ๋ก๊ทธ ์ ์ฅ์๋ก ๋๋ ๋ก๊ทธ ์ฒ๋ฆฌ์ ์ ํฉ
+ - Grafana : ๋์๋ณด๋๋ฅผ ํตํด ์๋ฒ ์ํยท๋ก๊ทธยท์งํ ์ค์๊ฐ ์๊ฐํ
+ - Prometheus : ์๋ฒ/์ ํ๋ฆฌ์ผ์ด์
๋ฉํธ๋ฆญ ์์ง ๋ฐ Alert ๊ธฐ๋ฐ ๋ชจ๋ํฐ๋ง ๊ตฌํ
+
+## ๐ ํด๋ ๊ตฌ์กฐ
+```csharp
+src
+โโโ main
+ โโโ java
+ โโโ com.cheeeese
+ โโโ album # ๐ธ ์จ๋ฒ ๋๋ฉ์ธ
+ โโโ photo # ๐ผ๏ธ ์ฌ์ง ๋๋ฉ์ธ
+ โโโ cheese4cut # ๐๏ธ ์น์ฆ๋ค์ปท ๋๋ฉ์ธ
+ โโโ user # ๐ค ์ฌ์ฉ์ ๋๋ฉ์ธ
+ โโโ auth # ๐ ์ธ์ฆ / ์ธ๊ฐ
+ โโโ oauth2 # ๐ ์์
๋ก๊ทธ์ธ(OAuth2)
+ โ โโโ application # ์๋น์ค / ์ ์ค์ผ์ด์ค
+ โ โโโ domain # ์ํฐํฐ / ๋น์ฆ๋์ค ๊ท์น
+ โ โโโ dto # ์์ฒญยท์๋ต DTO
+ โ โโโ exception # ์์ธ
+ โ โโโ infrastructure # JPA / ์ธ๋ถ ์ฐ๋
+ โ โโโ presentation # ์ปจํธ๋กค๋ฌ / API
+ โ
+ โโโ global # ๐ ์ ์ญ ์ค์ / ์ ํธ / AOP / ๊ณตํต ์์ธ
+ โโโ CheeeeseApplication.java # ๐ ๋ฉ์ธ ์คํ ํ์ผ
+```
+
+
+## ๐ฌ Commit Convention
+#์ด์ ๋ฒํธ ํ๊ทธ: ์ปค๋ฐ ๋ฉ์์ง ํํ๋ก ์์ฑ
+> e.g. #1 feat: ์นด์นด์ค ๋ก๊ทธ์ธ ๊ตฌํ
+
+| Type | ๋ด์ฉ |
+| ---------- | ---------------------------------- |
+| `feat` | ์๋ก์ด ๊ธฐ๋ฅ ๊ตฌํ |
+| `chore` | ๋ถ์์ ์ธ ์ฝ๋ ์์ ๋ฐ ๊ธฐํ ๋ณ๊ฒฝ์ฌํญ |
+| `docs` | ๋ฌธ์ ์ถ๊ฐ ๋ฐ ์์ , ์ญ์ |
+| `fix` | ๋ฒ๊ทธ ์์ |
+| `hotfix` | ์๋น์ค ์ฅ์ ๋ฑ ๊ธด๊ธ ์ด์ ์์ |
+| `test` | ํ
์คํธ ์ฝ๋ ์ถ๊ฐ ๋ฐ ์์ , ์ญ์ |
+| `refactor` | ์ฝ๋ ๋ฆฌํฉํ ๋ง |
+| `style` | ์ฝ๋ ํฌ๋งทํ
, ์ธ๋ฏธ์ฝ๋ก ๋๋ฝ ๋ฑ ๊ธฐ๋ฅ ๋ณ๊ฒฝ ์๋ ์คํ์ผ ์์ |
+| `deploy` | ๋ฐฐํฌ ๊ด๋ จ ์์
(CI/CD, ์๋ฒ ์ค์ , ๋ฐฐํฌ ์คํฌ๋ฆฝํธ ๋ฑ) |
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..b1a889f
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,80 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.5.6'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'com'
+version = '0.0.1-SNAPSHOT'
+description = 'Kusitms 32nd Cheeeese Server'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+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'
+ compileOnly 'org.projectlombok:lombok'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ annotationProcessor 'org.projectlombok:lombok'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ // swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
+
+ // security
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ // oauth
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+
+ // jwt
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
+ // redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+
+ // uuid v7
+ implementation 'com.github.f4b6a3:uuid-creator:5.2.0'
+
+ // AWS SDK for S3
+ implementation 'software.amazon.awssdk:s3:2.25.46'
+ implementation 'software.amazon.awssdk:auth:2.25.46'
+ implementation 'software.amazon.awssdk:regions:2.25.46'
+
+ testImplementation 'com.h2database:h2'
+ // aop
+ implementation 'org.springframework.boot:spring-boot-starter-aop'
+
+ // monitoring
+ implementation 'net.logstash.logback:logstash-logback-encoder:6.6'
+
+ // prometheus
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'io.micrometer:micrometer-registry-prometheus'
+}
+
+tasks.named('test') {
+ useJUnitPlatform {
+ excludeTags 'benchmark'
+ }
+
+ jvmArgs '-XX:+EnableDynamicAgentLoading'
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..595798f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,87 @@
+version: "3.9"
+
+services:
+ # ------------------------------------------------------------
+ # โ
Redis ์๋น์ค (ํญ์ ์คํ)
+ # ------------------------------------------------------------
+ redis:
+ image: redis:7.2-alpine
+ container_name: redis
+ restart: always
+ command: [ "redis-server", "--requirepass", "${SPRING_DATA_REDIS_PASSWORD}" ]
+ ports:
+ - "6379:6379"
+ env_file:
+ - /root/.env
+ volumes:
+ - redis-data:/data
+ networks:
+ - cheeeeese-network
+
+ # ------------------------------------------------------------
+ # โ
Backend ์๋น์ค (Blue-Green ์ ํ ๋์)
+ # ------------------------------------------------------------
+ backend-green-prod:
+ image: mozzarella32/backend:prod # deploy.sh์์ docker pull๋ก ์ต์ ์ด๋ฏธ์ง ๊ฐฑ์
+ container_name: cheeeeese-green-prod
+ env_file:
+ - /root/prod.env
+ depends_on:
+ - redis
+ ports:
+ - "8080:8080" # Green ์ธ์คํด์ค ํฌํธ
+ restart: always
+ networks:
+ - cheeeeese-network
+
+ backend-blue-prod:
+ image: mozzarella32/backend:prod
+ container_name: cheeeeese-blue-prod
+ env_file:
+ - /root/prod.env
+ depends_on:
+ - redis
+ ports:
+ - "8081:8080" # Blue ์ธ์คํด์ค ํฌํธ
+ restart: always
+ networks:
+ - cheeeeese-network
+
+ # ------------------------------------------------------------
+ # โ
๊ฐ๋ฐ ํ๊ฒฝ์ฉ
+ # ------------------------------------------------------------
+ backend-green-dev:
+ image: mozzarella32/backend:dev
+ container_name: cheeeeese-green-dev
+ env_file:
+ - /root/dev.env
+ depends_on:
+ - redis
+ ports:
+ - "8082:8080"
+ restart: always
+ networks:
+ - cheeeeese-network
+
+ backend-blue-dev:
+ image: mozzarella32/backend:dev
+ container_name: cheeeeese-blue-dev
+ env_file:
+ - /root/dev.env
+ depends_on:
+ - redis
+ ports:
+ - "8083:8080"
+ restart: always
+ networks:
+ - cheeeeese-network
+
+# ------------------------------------------------------------
+# โ
๋คํธ์ํฌ ๋ฐ ๋ณผ๋ฅจ ์ ์
+# ------------------------------------------------------------
+networks:
+ cheeeeese-network:
+ driver: bridge
+
+volumes:
+ redis-data:
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..23d15a9
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# 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/platforms/jvm/plugins-application/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##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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="\\\"\\\""
+
+
+# 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
+ if ! command -v java >/dev/null 2>&1
+ then
+ 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
+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=SC2039,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=SC2039,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 optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# 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/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@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
+@rem SPDX-License-Identifier: Apache-2.0
+@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. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+: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/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..116850a
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'cheeeese'
diff --git a/src/main/java/com/cheeeese/CheeeeseApplication.java b/src/main/java/com/cheeeese/CheeeeseApplication.java
new file mode 100644
index 0000000..e920eb3
--- /dev/null
+++ b/src/main/java/com/cheeeese/CheeeeseApplication.java
@@ -0,0 +1,19 @@
+package com.cheeeese;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableJpaAuditing
+@EnableRedisRepositories(basePackages = "com.cheeeese.auth.infrastructure.persistence")
+@EnableScheduling
+public class CheeeeseApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(CheeeeseApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java b/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java
new file mode 100644
index 0000000..f0af29a
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java
@@ -0,0 +1,36 @@
+package com.cheeeese.album.application;
+
+import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AlbumExpirationScheduler {
+
+ private final AlbumExpirationRedisRepository albumExpirationRedisRepository;
+ private final AlbumExpirationService albumExpirationService;
+
+ @Scheduled(fixedDelay = 10000L)
+ public void handleAlbumExpirations() {
+ Set expiredAlbumIds = albumExpirationRedisRepository.getExpiredAlbumIds();
+
+ if (expiredAlbumIds.isEmpty()) {
+ return;
+ }
+
+ for (Long albumId : expiredAlbumIds) {
+ try {
+ albumExpirationService.expireAlbum(albumId);
+ albumExpirationRedisRepository.unregister(albumId);
+ } catch (Exception exception) {
+ log.error("[AlbumExpiration] Failed to process album id={}", albumId, exception);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java b/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
new file mode 100644
index 0000000..6d6772e
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java
@@ -0,0 +1,81 @@
+package com.cheeeese.album.application;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.exception.AlbumException;
+import com.cheeeese.album.exception.code.AlbumErrorCode;
+import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
+import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper;
+import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository;
+import com.cheeeese.photo.domain.Photo;
+import com.cheeeese.photo.domain.PhotoStatus;
+import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AlbumExpirationService {
+
+ private static final int CHEESE4CUT_PHOTO_COUNT = 4;
+
+ private final AlbumRepository albumRepository;
+ private final PhotoRepository photoRepository;
+ private final Cheese4cutRepository cheese4cutRepository;
+
+ @Transactional
+ public void expireAlbum(Long albumId) {
+ Album album = albumRepository.findById(albumId)
+ .orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND));
+
+ if (album.getStatus() != Album.AlbumStatus.EXPIRED) {
+ albumRepository.updateStatus(albumId, Album.AlbumStatus.EXPIRED);
+ log.info("[AlbumExpiration] Album id={} status updated to EXPIRED", albumId);
+ }
+
+ if (cheese4cutRepository.findByAlbumId(albumId).isPresent()) {
+ return;
+ }
+
+ List topPhotoIds = photoRepository.findTop4CompletedPhotoIdsByLikes(
+ albumId,
+ PhotoStatus.COMPLETED,
+ PageRequest.of(0, CHEESE4CUT_PHOTO_COUNT)
+ );
+
+ if (topPhotoIds.size() < CHEESE4CUT_PHOTO_COUNT) {
+ log.warn(
+ "[AlbumExpiration] Album id={} does not have enough photos to create cheese4cut (found={})",
+ albumId,
+ topPhotoIds.size()
+ );
+ return;
+ }
+
+ List photos = photoRepository.findAllByIdIn(topPhotoIds);
+ Map photoMap = photos.stream()
+ .collect(Collectors.toMap(Photo::getId, Function.identity()));
+
+ List orderedPhotos = topPhotoIds.stream()
+ .map(photoMap::get)
+ .collect(Collectors.toList());
+
+ if (orderedPhotos.stream().anyMatch(Objects::isNull)) {
+ log.warn("[AlbumExpiration] Album id={} has missing photos for cheese4cut creation", albumId);
+ return;
+ }
+
+ cheese4cutRepository.save(Cheese4cutMapper.toEntity(album, orderedPhotos));
+
+ log.info("[AlbumExpiration] Cheese4cut created automatically for album id={}", albumId);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/application/AlbumQueryService.java b/src/main/java/com/cheeeese/album/application/AlbumQueryService.java
new file mode 100644
index 0000000..76f5079
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/AlbumQueryService.java
@@ -0,0 +1,187 @@
+package com.cheeeese.album.application;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.type.Role;
+import com.cheeeese.album.dto.response.ClosedAlbumPageResponse;
+import com.cheeeese.album.dto.response.ClosedAlbumSummaryResponse;
+import com.cheeeese.album.dto.response.OpenAlbumPageResponse;
+import com.cheeeese.album.dto.response.OpenAlbumSummaryResponse;
+import com.cheeeese.album.infrastructure.mapper.AlbumQueryMapper;
+import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
+import com.cheeeese.cheese4cut.domain.Cheese4cutPhoto;
+import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutPhotoRepository;
+import com.cheeeese.global.util.resolver.CdnUrlResolver;
+import com.cheeeese.photo.domain.Photo;
+import com.cheeeese.photo.domain.PhotoStatus;
+import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
+import com.cheeeese.user.domain.User;
+import com.cheeeese.user.exception.UserException;
+import com.cheeeese.user.exception.code.UserErrorCode;
+import com.cheeeese.user.infrastructure.persistence.UserRepository;
+import lombok.RequiredArgsConstructor;
+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 java.time.LocalDateTime;
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AlbumQueryService {
+
+ private static final int RECENT_THUMBNAIL_COUNT = 3;
+
+ private final UserAlbumRepository userAlbumRepository;
+ private final UserRepository userRepository;
+ private final PhotoRepository photoRepository;
+ private final Cheese4cutPhotoRepository cheese4cutPhotoRepository;
+ private final CdnUrlResolver cdnUrlResolver;
+
+ public OpenAlbumPageResponse getOpenAlbums(User user, int page, int size) {
+ Pageable pageable = PageRequest.of(page, size);
+
+ Slice albums = userAlbumRepository.findOpenAlbumsByUserId(
+ user.getId(),
+ Album.AlbumStatus.ACTIVE,
+ LocalDateTime.now(),
+ pageable
+ );
+
+ List responses = buildOpenAlbumResponses(albums.getContent());
+
+ return AlbumQueryMapper.toOpenAlbumPageResponse(responses, albums);
+ }
+
+ public OpenAlbumPageResponse getMyOpenAlbums(User user, int page, int size) {
+ Pageable pageable = PageRequest.of(page, size);
+ Slice albums = userAlbumRepository.findOpenAlbumsByUserIdAndRole(
+ user.getId(),
+ Role.MAKER,
+ Album.AlbumStatus.ACTIVE,
+ LocalDateTime.now(),
+ pageable
+ );
+
+ List responses = buildOpenAlbumResponses(albums.getContent());
+
+ return AlbumQueryMapper.toOpenAlbumPageResponse(responses, albums);
+ }
+
+ public ClosedAlbumPageResponse getClosedAlbums(User user, int page, int size) {
+ Pageable pageable = PageRequest.of(page, size);
+
+ Slice expiredAlbums = userAlbumRepository.findClosedAlbumsByUserId(
+ user.getId(),
+ Album.AlbumStatus.EXPIRED,
+ pageable
+ );
+
+ List albumIds = expiredAlbums.getContent().stream()
+ .map(Album::getId)
+ .toList();
+
+ if (albumIds.isEmpty()) {
+ return AlbumQueryMapper.toClosedAlbumPageResponse(List.of(), expiredAlbums);
+ }
+
+ Map makerMap = getMakers(expiredAlbums.getContent());
+
+ List allCheese4cutPhotos = cheese4cutPhotoRepository.findAllCheese4cutPhotosByAlbumIds(albumIds);
+
+ Map> cheese4cutPhotoMap = allCheese4cutPhotos.stream()
+ .collect(Collectors.groupingBy(
+ c4p -> c4p.getCheese4cut().getAlbum().getId(),
+ Collectors.toList()
+ ));
+
+ List responses = expiredAlbums.getContent().stream()
+ .map(album -> {
+ List c4pList = cheese4cutPhotoMap.getOrDefault(album.getId(), List.of());
+
+ User maker = Optional.ofNullable(makerMap.get(album.getMakerId()))
+ .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
+
+ List thumbnails = c4pList.stream()
+ .sorted(Comparator.comparingInt(Cheese4cutPhoto::getPhotoRank)) // photoRank ์์ผ๋ก ์ ๋ ฌ
+ .map(Cheese4cutPhoto::getThumbnailImageUrl)
+ .map(cdnUrlResolver::resolveThumbnail)
+ .collect(Collectors.toList());
+
+ return AlbumQueryMapper.toClosedAlbumSummaryResponse(album, maker, thumbnails);
+ })
+ .collect(Collectors.toList());
+
+ return AlbumQueryMapper.toClosedAlbumPageResponse(responses, expiredAlbums);
+ }
+
+ private List buildOpenAlbumResponses(List albums) {
+ if (albums.isEmpty()) {
+ return List.of();
+ }
+
+ Map makerMap = getMakers(albums);
+ Map> recentThumbnailsMap = getRecentThumbnailsMap(albums);
+
+ return albums.stream()
+ .map(album -> {
+ User maker = Optional.ofNullable(makerMap.get(album.getMakerId()))
+ .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
+
+ List recentThumbnails = recentThumbnailsMap.getOrDefault(album.getId(), List.of());
+ return AlbumQueryMapper.toOpenAlbumSummaryResponse(album, maker, recentThumbnails);
+ })
+ .toList();
+ }
+
+ private Map getMakers(List albums) {
+ List makerIds = albums.stream()
+ .map(Album::getMakerId)
+ .distinct()
+ .toList();
+ Map makers = userRepository.findAllById(makerIds).stream()
+ .collect(Collectors.toMap(User::getId, Function.identity()));
+ if (makers.size() != makerIds.size()) {
+ throw new UserException(UserErrorCode.USER_NOT_FOUND);
+ }
+ return makers;
+ }
+
+ private Map> getRecentThumbnailsMap(List albums) {
+ List albumIds = albums.stream()
+ .map(Album::getId)
+ .toList();
+
+ if (albumIds.isEmpty()) {
+ return Map.of();
+ }
+
+ List photos = photoRepository.findTop3RecentPhotosInEachAlbum(
+ albumIds,
+ PhotoStatus.COMPLETED
+ );
+
+ Map> thumbnailsMap = new HashMap<>();
+
+ for (Photo photo : photos) {
+ Long albumId = photo.getAlbum().getId();
+ List thumbnails = thumbnailsMap.computeIfAbsent(albumId, key -> new ArrayList<>());
+
+ if (thumbnails.size() < RECENT_THUMBNAIL_COUNT) {
+ thumbnails.add(cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl()));
+ }
+ }
+
+ return thumbnailsMap.entrySet().stream()
+ .filter(entry -> entry.getValue().size() == RECENT_THUMBNAIL_COUNT)
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ entry -> List.copyOf(entry.getValue())
+ ));
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/application/AlbumService.java b/src/main/java/com/cheeeese/album/application/AlbumService.java
new file mode 100644
index 0000000..719772b
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/AlbumService.java
@@ -0,0 +1,336 @@
+package com.cheeeese.album.application;
+
+import com.cheeeese.album.application.logger.AlbumLogger;
+import com.cheeeese.album.application.support.AlbumReader;
+import com.cheeeese.album.application.validator.AlbumValidator;
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.type.AlbumJoinStatus;
+import com.cheeeese.album.domain.type.Role;
+import com.cheeeese.album.dto.request.AlbumCreationRequest;
+import com.cheeeese.album.dto.response.*;
+import com.cheeeese.album.exception.AlbumException;
+import com.cheeeese.album.exception.code.AlbumErrorCode;
+import com.cheeeese.album.infrastructure.mapper.AlbumMapper;
+import com.cheeeese.album.infrastructure.mapper.UserAlbumMapper;
+import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository;
+import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
+import com.cheeeese.global.security.CustomUserDetails;
+import com.cheeeese.global.util.ProfileImageUtil;
+import com.cheeeese.global.util.resolver.CdnUrlResolver;
+import com.cheeeese.photo.application.PhotoService;
+import com.cheeeese.album.domain.UserAlbum;
+import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
+import com.cheeeese.photo.domain.Photo;
+import com.cheeeese.photo.domain.PhotoStatus;
+import com.cheeeese.album.dto.response.AlbumBest4CutResponse;
+import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository;
+import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
+import com.cheeeese.user.domain.User;
+import com.cheeeese.user.domain.type.ProfileImageType;
+import com.cheeeese.user.exception.UserException;
+import com.cheeeese.user.exception.code.UserErrorCode;
+import com.cheeeese.user.infrastructure.persistence.UserRepository;
+import com.github.f4b6a3.uuid.UuidCreator;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AlbumService {
+
+ private final AlbumValidator albumValidator;
+ private final AlbumRepository albumRepository;
+ private final UserAlbumRepository userAlbumRepository;
+ private final UserRepository userRepository;
+ private final PhotoRepository photoRepository;
+ private final PhotoLikesRepository photoLikesRepository;
+ private final PhotoService photoService;
+ private final AlbumExpirationRedisRepository albumExpirationRedisRepository;
+ private final CdnUrlResolver cdnUrlResolver;
+ private final AlbumReader albumReader;
+ private final AlbumLogger albumLogger;
+
+ @Transactional
+ public AlbumCreationResponse createAlbum(User user, AlbumCreationRequest request) {
+ String code = UuidCreator.getTimeOrdered().toString();
+
+ long createdThisWeek = countUserAlbumsCreatedThisWeek(user);
+
+ albumValidator.validateAlbumCreation(createdThisWeek, request);
+
+ LocalDateTime expiredAt = LocalDateTime.now().plusDays(7);
+
+ Album album = AlbumMapper.toEntity(
+ user.getId(),
+ request.title(),
+ code,
+ request.themeEmoji(),
+ request.participant(),
+ request.eventDate(),
+ true,
+ expiredAt
+ );
+ boolean isFirst = !albumRepository.existsByMakerId(user.getId());
+
+ albumRepository.save(album);
+
+ userAlbumRepository.save(UserAlbumMapper.toEntity(
+ user,
+ album,
+ Role.MAKER
+ ));
+ userRepository.incrementAlbumCnt(user.getId());
+
+ albumExpirationRedisRepository.registerAlbum(album.getId(), expiredAt);
+
+ albumLogger.logAlbumCreated(user.getId(), album.getCode(), request.participant());
+
+ return AlbumMapper.toCreationResponse(album, isFirst);
+ }
+
+ public AlbumInvitationResponse getInvitationInfo(String code) {
+ Album album = albumValidator.validateAlbumCode(code);
+
+ if (album.isExpired()) {
+ return AlbumMapper.toExpiredInvitationResponse(album);
+ }
+ User maker = getMaker(album.getMakerId());
+ String makerProfileUrl = ProfileImageUtil.resolveProfileImage(maker, cdnUrlResolver);
+
+ return AlbumMapper.toInvitationResponse(album, maker, makerProfileUrl);
+ }
+
+ @Transactional
+ public AlbumEnterResponse enterAlbum(String code, User currentUser) {
+ Album album = albumValidator.validateAlbumCode(code);
+ albumValidator.validateAlbumEntry(album, currentUser);
+
+ Optional existing = userAlbumRepository.findByUserIdAndAlbumId(currentUser.getId(), album.getId());
+
+ User maker = getMaker(album.getMakerId());
+ String makerProfileUrl = ProfileImageUtil.resolveProfileImage(maker, cdnUrlResolver);
+ AlbumMakerInfo makerInfo = AlbumMapper.toMakerInfo(maker, makerProfileUrl);
+
+ // Case 1: ๊ธฐ์กด ์ฐธ์ฌ ์ด๋ ฅ ์กด์ฌ
+ if (existing.isPresent()) {
+ UserAlbum userAlbum = existing.get();
+
+ // ์ฌ๋ฐฉ๋ฌธ ๋ก๊ทธ
+ albumLogger.logAlbumViewed(currentUser.getId(), album.getCode(), userAlbum.getRole());
+
+ if (!userAlbum.isVisible()) {
+ userAlbum.show();
+ return AlbumMapper.toExistingResponse(album, AlbumJoinStatus.REJOINED, makerInfo);
+ }
+
+ return AlbumMapper.toExistingResponse(album, AlbumJoinStatus.EXISTING, makerInfo);
+ }
+
+ // Case 2: ์ ๊ท ์ฐธ์ฌ
+ albumValidator.validateAlbumCapacity(album);
+ userAlbumRepository.save(UserAlbumMapper.toEntity(
+ currentUser,
+ album,
+ Role.GUEST
+ ));
+
+ int updated = albumRepository.incrementParticipantCountAtomically(album.getId());
+ if (updated == 0) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_MAX_PARTICIPANT_REACHED);
+ }
+ userRepository.incrementAlbumCnt(currentUser.getId());
+
+ List recentPhotos = getRecentPhotosWithUploaderInfo(album.getId());
+
+ int remainingUploadSlots = calculateRemainingUploadSlots(album);
+
+ boolean photoExist = album.getCurrentPhotoCount() > 0;
+ albumLogger.logAlbumJoined(currentUser.getId(), album.getCode(), photoExist);
+
+ return AlbumMapper.toNewResponse(album, makerInfo, remainingUploadSlots, recentPhotos);
+ }
+
+ public UploadAvailableCountResponse getAvailablePhotoCount(String code) {
+ Album album = albumValidator.validateAlbumCode(code);
+
+ int availableCount = calculateRemainingUploadSlots(album);
+
+ return AlbumMapper.toAvailableCountResponse(
+ availableCount,
+ album.getMaxPhotoCount(),
+ album.getCurrentPhotoCount()
+ );
+ }
+
+ public AlbumParticipantResponse getAlbumParticipantList(Authentication authentication, String code) {
+
+ User currentUser = extractUser(authentication);
+
+ Album album = albumValidator.validateAlbumCode(code);
+
+ boolean isExpired = album.isExpired();
+
+ Role myRole = null;
+ Long currentUserId = currentUser != null ? currentUser.getId() : null;
+
+ if (currentUserId != null) {
+ Optional myUserAlbumOptional = userAlbumRepository.findByUserIdAndAlbumId(currentUserId, album.getId());
+
+ if (myUserAlbumOptional.isPresent()) {
+ myRole = myUserAlbumOptional.get().getRole();
+ }
+ }
+
+ // ์จ๋ฒ์ ์ ์ฒด ์ฐธ์ฌ์ ๋ชฉ๋ก
+ List userAlbums = userAlbumRepository.findAllByAlbumId(album.getId());
+
+ List participantInfos = buildSortedParticipantInfos(userAlbums, currentUserId);
+
+ return UserAlbumMapper.toAlbumParticipantResponse(
+ album,
+ isExpired,
+ myRole,
+ participantInfos
+ );
+ }
+
+ public AlbumInfoResponse getAlbumInfo(String code) {
+ Album album = albumValidator.validateAlbumCode(code);
+
+ User maker = userAlbumRepository.findMakerByAlbumId(album.getId(), Role.MAKER)
+ .map(UserAlbum::getUser)
+ .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_MAKER));
+
+ return AlbumMapper.toAlbumInfoResponse(album, maker);
+ }
+
+ public List getAlbumBest4Cut(User user, String code) {
+ Album album = albumValidator.validateAlbumCode(code);
+
+ albumValidator.validateAlbumParticipant(album, user);
+
+ List topPhotos = photoRepository.findTop4CompletedPhotosByLikes(
+ album.getId(),
+ PhotoStatus.COMPLETED,
+ PageRequest.of(0, 4)
+ );
+
+ List photoIds = topPhotos.stream()
+ .map(Photo::getId)
+ .toList();
+
+ Set likedPhotoIds = photoLikesRepository.findAllLikedPhotoIds(user.getId(), photoIds);
+
+ return topPhotos.stream()
+ .map(photo -> {
+ String thumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl());
+ boolean isLiked = likedPhotoIds.contains(photo.getId());
+ return AlbumMapper.toBest4CutResponse(photo, thumbnailUrl, isLiked);
+ })
+ .toList();
+ }
+
+ @Transactional
+ public void leaveAlbum(User user, String code) {
+ Album album = albumValidator.validateAlbumCode(code);
+ albumValidator.validateMakerLeaveAllowed(album, user);
+
+ UserAlbum userAlbum = albumReader.getAlbumParticipant(album, user);
+
+ userAlbum.hide();
+ }
+
+ private User extractUser(Authentication authentication) {
+ if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
+ return null;
+ }
+ Object principal = authentication.getPrincipal();
+ if (principal instanceof CustomUserDetails customUserDetails) {
+ return customUserDetails.getUser();
+ }
+ return null;
+ }
+
+ private int calculateRemainingUploadSlots(Album album) {
+ int current = album.getCurrentPhotoCount();
+ int max = album.getMaxPhotoCount();
+ return Math.max(0, max - current);
+ }
+
+ private long countUserAlbumsCreatedThisWeek(User user) {
+ return albumRepository.countByUserAndCreatedAtBetween(
+ user.getId(),
+ LocalDateTime.now().minusDays(7),
+ LocalDateTime.now()
+ );
+ }
+
+ private User getMaker(Long makerId) {
+ return userRepository.findById(makerId)
+ .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
+ }
+
+ private List getRecentPhotosWithUploaderInfo(Long albumId) {
+ List photos = photoService.getRecentPhotosForNewEnter(albumId);
+
+ if (photos.isEmpty()) {
+ return List.of();
+ }
+
+ // 1~4๊ฐ์ธ ๊ฒฝ์ฐ, 1๊ฐ๋ง ๋ฐํํ๋ ๋น์ฆ๋์ค ๋ก์ง ์ ์ฉ
+ if (photos.size() < 5) {
+ Photo photo = photos.get(0);
+ String profileUrl = ProfileImageUtil.resolveProfileImage(photo.getUser(), cdnUrlResolver);
+ return List.of(AlbumMapper.toRecentPhotoResponse(photo, profileUrl));
+ }
+
+ // 5๊ฐ์ธ ๊ฒฝ์ฐ, 5๊ฐ ๋ชจ๋ ๋ฐํ
+ return photos.stream()
+ .map(photo -> {
+ String profileUrl = ProfileImageUtil.resolveProfileImage(
+ photo.getUser(),
+ cdnUrlResolver
+ );
+ return AlbumMapper.toRecentPhotoResponse(photo, profileUrl);
+ })
+ .toList();
+ }
+
+ private List buildSortedParticipantInfos(
+ List userAlbums,
+ Long currentUserId
+ ) {
+ return userAlbums.stream()
+ .map(userAlbum -> {
+ User user = userAlbum.getUser();
+ Role role = userAlbum.getRole();
+ ProfileImageType type = ProfileImageType.fromName(user.getProfileImage());
+ String profileImageUrl = cdnUrlResolver.resolveProfile(type.getPath());
+ boolean isMe = currentUserId != null && user.getId().equals(currentUserId);
+
+ return UserAlbumMapper.toParticipantInfo(user, profileImageUrl, role, isMe);
+ })
+ .sorted(Comparator
+ .comparing(AlbumParticipantListResponse.ParticipantInfo::isMe, Comparator.reverseOrder())
+ .thenComparing(p -> p.role() == Role.MAKER ? 0 : 1)
+ .thenComparing(AlbumParticipantListResponse.ParticipantInfo::name)
+ )
+ .toList();
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java b/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java
new file mode 100644
index 0000000..7284152
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java
@@ -0,0 +1,57 @@
+package com.cheeeese.album.application.logger;
+
+import com.cheeeese.album.domain.type.Role;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+@Slf4j
+@Component
+public class AlbumLogger {
+
+ private static final String STAT_PREFIX = "[STAT]";
+
+ /**
+ * [์งํ 1] ์จ๋ฒ ์์ฑ
+ * album_created(user_id, album_code, expected_participant, created_at)
+ */
+ public void logAlbumCreated(Long userId, String albumCode, int expectedParticipant) {
+ try {
+ MDC.put("type", "album");
+ log.info("{} album_created | user_id={} album_code={} expected_participant={} created_at={}",
+ STAT_PREFIX, userId, albumCode, expectedParticipant, LocalDateTime.now());
+ } finally {
+ MDC.remove("type");
+ }
+ }
+
+ /**
+ * [์งํ 1, 2] ์จ๋ฒ ์
์ฅ (์ ๊ท)
+ * album_joined(user_id, album_code, is_first_join, photo_exist_on_join, joined_at)
+ */
+ public void logAlbumJoined(Long userId, String albumCode, boolean photoExistOnJoin) {
+ try {
+ MDC.put("type", "album");
+ log.info("{} album_joined | user_id={} album_code={} is_first_join=true photo_exist_on_join={} joined_at={}",
+ STAT_PREFIX, userId, albumCode, photoExistOnJoin, LocalDateTime.now());
+ } finally {
+ MDC.remove("type");
+ }
+ }
+
+ /**
+ * [์งํ 2, 4] ์จ๋ฒ ์ฌ๋ฐฉ๋ฌธ (์กฐํ)
+ * album_view_start(user_id, album_code, role, viewed_at)
+ */
+ public void logAlbumViewed(Long userId, String albumCode, Role role) {
+ try {
+ MDC.put("type", "album");
+ log.info("{} album_view_start | user_id={} album_code={} role={} viewed_at={}",
+ STAT_PREFIX, userId, albumCode, role, LocalDateTime.now());
+ } finally {
+ MDC.remove("type");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/application/support/AlbumReader.java b/src/main/java/com/cheeeese/album/application/support/AlbumReader.java
new file mode 100644
index 0000000..d3093c0
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/support/AlbumReader.java
@@ -0,0 +1,22 @@
+package com.cheeeese.album.application.support;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.UserAlbum;
+import com.cheeeese.album.exception.AlbumException;
+import com.cheeeese.album.exception.code.AlbumErrorCode;
+import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
+import com.cheeeese.user.domain.User;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class AlbumReader {
+
+ private final UserAlbumRepository userAlbumRepository;
+
+ public UserAlbum getAlbumParticipant(Album album, User user) {
+ return userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId())
+ .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_PARTICIPANT));
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java b/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java
new file mode 100644
index 0000000..4a598f3
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java
@@ -0,0 +1,120 @@
+package com.cheeeese.album.application.validator;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.type.Role;
+import com.cheeeese.album.dto.request.AlbumCreationRequest;
+import com.cheeeese.album.exception.AlbumException;
+import com.cheeeese.album.exception.code.AlbumErrorCode;
+import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
+import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
+import com.cheeeese.photo.domain.Photo;
+import com.cheeeese.photo.exception.PhotoException;
+import com.cheeeese.photo.exception.code.PhotoErrorCode;
+import com.cheeeese.user.domain.User;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class AlbumValidator {
+
+ private static final ZoneOffset KST_ZONE = ZoneOffset.of("+09:00");
+ private final AlbumRepository albumRepository;
+ private final UserAlbumRepository userAlbumRepository;
+
+ public void validateAlbumCreation(long createdThisWeek, AlbumCreationRequest request) {
+ if (request.themeEmoji() == null || request.themeEmoji().isBlank()) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_THEME_EMOJI_NOT_SELECTED);
+ }
+
+ if (request.title() == null || request.title().isBlank()) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_TITLE_REQUIRED);
+ }
+
+ if (request.eventDate() == null) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_REQUIRED);
+ }
+
+ if (request.eventDate().isAfter(LocalDate.now(KST_ZONE))) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_INVALID);
+ }
+
+ if (request.participant() < 1 || request.participant() > 64) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_INVALID_CAPACITY);
+ }
+
+ if (createdThisWeek >= 3) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_CREATION_LIMIT_EXCEEDED);
+ }
+ }
+
+ public Album validateAlbumCode(String code) {
+ return albumRepository.findByCode(code)
+ .orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND));
+ }
+
+ public void validateAlbumEntry(Album album, User user) {
+ // 1. ์จ๋ฒ ๋ง๋ฃ ํ์ธ
+ validateAlbumExpiration(album);
+
+ // 2. ๋ธ๋๋ฆฌ์คํธ ํ์ธ (๊ถํ ์ฒดํฌ)
+ validateUserBlacklisted(album, user);
+ }
+
+ public void validateAlbumCapacity(Album album) {
+ int current = album.getCurrentParticipant();
+ int max = album.getParticipant();
+
+ if (current >= max) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_MAX_PARTICIPANT_REACHED);
+ }
+ }
+
+ public void validateDownloadPermission(Album album, User user, List photos) {
+ validateAlbumParticipant(album, user);
+
+ boolean existsPhotoInAlbum = photos.stream().allMatch(photo -> photo.getAlbum().getId().equals(album.getId()));
+
+ if (!existsPhotoInAlbum) {
+ throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND_IN_ALBUM);
+ }
+ }
+
+ public void validateAlbumParticipant(Album album, User user) {
+ validateAlbumEntry(album, user);
+
+ userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId())
+ .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_PARTICIPANT));
+ }
+
+ public void validateMakerLeaveAllowed(Album album, User user) {
+ if (!album.getMakerId().equals(user.getId())) {
+ return;
+ }
+
+ // Maker์ผ ๊ฒฝ์ฐ, ๋ง๋ฃ๋ ์จ๋ฒ๋ง ๋๊ฐ๊ธฐ ๊ฐ๋ฅ
+ if (album.getStatus() != Album.AlbumStatus.EXPIRED) {
+ throw new AlbumException(AlbumErrorCode.MAKER_CANNOT_LEAVE_UNTIL_CLOSED);
+ }
+ }
+
+ private void validateAlbumExpiration(Album album) {
+ if (album.isExpired()) {
+ throw new AlbumException(AlbumErrorCode.ALBUM_EXPIRED);
+ }
+ }
+
+ /**
+ * ์ฌ์ฉ์๊ฐ ์จ๋ฒ์ ๋ธ๋๋ฆฌ์คํธ์ ๋ฑ๋ก๋์ด ์๋์ง ํ์ธํฉ๋๋ค.
+ */
+ private void validateUserBlacklisted(Album album, User user) {
+ userAlbumRepository.findByAlbumIdAndUserIdAndRole(album.getId(), user.getId(), Role.BLACK)
+ .ifPresent(userAlbum -> {
+ throw new AlbumException(AlbumErrorCode.USER_IS_BLACKLISTED);
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/domain/Album.java b/src/main/java/com/cheeeese/album/domain/Album.java
new file mode 100644
index 0000000..c236aea
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/domain/Album.java
@@ -0,0 +1,97 @@
+package com.cheeeese.album.domain;
+
+import com.cheeeese.global.domain.BaseEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Getter
+@Table(name = "album")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Album extends BaseEntity {
+
+ @Id
+ @Column(name = "album_id", nullable = false)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "maker_id", nullable = false)
+ private Long makerId;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "code", nullable = false, unique = true)
+ private String code;
+
+ @Column(name = "theme_emoji")
+ private String themeEmoji;
+
+ @Column(name = "participant", nullable = false)
+ private int participant;
+
+ @Column(name = "current_participant", nullable = false)
+ private int currentParticipant;
+
+ @Column(name = "event_date", nullable = false)
+ private LocalDate eventDate;
+
+ @Column(name = "max_photo_count", nullable = false)
+ private int maxPhotoCount;
+
+ @Column(name = "current_photo_count", nullable = false)
+ private int currentPhotoCount;
+
+ @Column(name = "is_info_available", nullable = false)
+ private boolean isInfoAvailable;
+
+ @Column(name = "expired_at", nullable = false)
+ private LocalDateTime expiredAt;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ private AlbumStatus status;
+
+ public enum AlbumStatus {
+ ACTIVE, EXPIRED, DELETED
+ }
+
+ public boolean isExpired() {
+ return this.expiredAt.isBefore(LocalDateTime.now()) || this.status == AlbumStatus.EXPIRED;
+ }
+
+ @Builder
+ private Album(
+ Long makerId,
+ String title,
+ String code,
+ String themeEmoji,
+ int participant,
+ int currentParticipant,
+ LocalDate eventDate,
+ int maxPhotoCount,
+ int currentPhotoCount,
+ boolean isInfoAvailable,
+ LocalDateTime expiredAt,
+ AlbumStatus status
+ ) {
+ this.makerId = makerId;
+ this.title = title;
+ this.code = code;
+ this.themeEmoji = themeEmoji;
+ this.participant = participant;
+ this.currentParticipant = currentParticipant;
+ this.eventDate = eventDate;
+ this.maxPhotoCount = maxPhotoCount;
+ this.currentPhotoCount = currentPhotoCount;
+ this.isInfoAvailable = isInfoAvailable;
+ this.expiredAt = expiredAt;
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/domain/UserAlbum.java b/src/main/java/com/cheeeese/album/domain/UserAlbum.java
new file mode 100644
index 0000000..cf0955d
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/domain/UserAlbum.java
@@ -0,0 +1,53 @@
+package com.cheeeese.album.domain;
+
+import com.cheeeese.album.domain.type.Role;
+import com.cheeeese.global.domain.BaseEntity;
+import com.cheeeese.user.domain.User;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@Table(name = "user_album", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "album_id"}))
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class UserAlbum extends BaseEntity {
+
+ @Id
+ @Column(name = "user_album_id", nullable = false)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "album_id", nullable = false)
+ private Album album;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "role", nullable = false)
+ private Role role;
+
+ @Column(name = "is_visible", nullable = false)
+ private boolean isVisible;
+
+ @Builder
+ private UserAlbum(User user, Album album, Role role, boolean isVisible) {
+ this.user = user;
+ this.album = album;
+ this.role = role;
+ this.isVisible = isVisible;
+ }
+
+ public void hide() {
+ this.isVisible = false;
+ }
+
+ public void show() {
+ this.isVisible = true;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java b/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java
new file mode 100644
index 0000000..62da612
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java
@@ -0,0 +1,7 @@
+package com.cheeeese.album.domain.type;
+
+public enum AlbumJoinStatus {
+ NEW,
+ EXISTING,
+ REJOINED
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java b/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java
new file mode 100644
index 0000000..549d503
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java
@@ -0,0 +1,15 @@
+package com.cheeeese.album.domain.type;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum AlbumSorting {
+ POPULAR("popular", "๋ฑ ๋ง์์"),
+ CAPTURED_AT("captured", "์ต๊ทผ ์ดฌ์ํ ์๊ฐ์"),
+ CREATED_AT("uploaded", "์ต๊ทผ ์
๋ก๋๋ ์ฌ์ง์");
+
+ private final String param;
+ private final String description;
+}
diff --git a/src/main/java/com/cheeeese/album/domain/type/Role.java b/src/main/java/com/cheeeese/album/domain/type/Role.java
new file mode 100644
index 0000000..3ba0eeb
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/domain/type/Role.java
@@ -0,0 +1,7 @@
+package com.cheeeese.album.domain.type;
+
+public enum Role {
+ MAKER,
+ GUEST,
+ BLACK
+}
diff --git a/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java b/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java
new file mode 100644
index 0000000..8cc8f21
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java
@@ -0,0 +1,31 @@
+package com.cheeeese.album.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์์ฑ ์์ฒญ",
+ requiredProperties = {
+ "themeEmoji",
+ "title",
+ "participant",
+ "eventDate"
+ }
+)
+public record AlbumCreationRequest(
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "U+1F9C0")
+ String themeEmoji,
+
+ @Schema(description = "์จ๋ฒ ์ด๋ฆ", example = "์กธ์
์")
+ String title,
+
+ @Schema(description = "์ฐธ์ฌ์ ์", example = "64")
+ int participant,
+
+ @Schema(description = "ํ์ฌ ๋ ์ง", example = "2025-02-01")
+ LocalDate eventDate
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java
new file mode 100644
index 0000000..1b896ec
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java
@@ -0,0 +1,18 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "๋ฒ ์คํธ ์จ๋ฒ์ปท ์กฐํ API")
+public record AlbumBest4CutResponse(
+ @Schema(description = "์ธ๋ค์ผ ์ด๋ฏธ์ง url", example = "https://cdn.say-cheese.me/...")
+ String thumbnailUrl,
+
+ @Schema(description = "์ข์์ ์", example = "1")
+ int likeCnt,
+
+ @Schema(description = "์ข์์ ์ฌ๋ถ", example = "true")
+ boolean isLiked
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java
new file mode 100644
index 0000000..f41d4ec
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java
@@ -0,0 +1,43 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์์ฑ ์๋ต",
+ requiredProperties = {
+ "themeEmoji",
+ "title",
+ "eventDate",
+ "createdAt",
+ "isFirst",
+ "currentPhotoCnt",
+ "code"
+ }
+)public record AlbumCreationResponse(
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "U+1F9C0")
+ String themeEmoji,
+
+ @Schema(description = "ํ์ฌ ์ด๋ฆ", example = "ํ์์ฆ MT")
+ String title,
+
+ @Schema(description = "ํ์ฌ ๋ ์ง", example = "2025.02.01")
+ LocalDate eventDate,
+
+ @Schema(description = "์จ๋ฒ ์์ฑ์ผ์", example = "2026-01-05T17:23:16.201417")
+ LocalDateTime createdAt,
+
+ @Schema(description = "ํด๋น ์ฌ์ฉ์๊ฐ ์ต์ด๋ก ์์ฑํ ์จ๋ฒ์ธ์ง ์ฌ๋ถ", example = "true")
+ boolean isFirst,
+
+ @Schema(description = "ํ์ฌ๊น์ง ์
๋ก๋๋ ์ฌ์ง ์", example = "1")
+ int currentPhotoCnt,
+
+ @Schema(description = "์จ๋ฒ ์ฝ๋", example = "786ccd09-5f22-4aa9-a32b-f62dd2e94cc8")
+ String code
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java
new file mode 100644
index 0000000..2126298
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java
@@ -0,0 +1,10 @@
+package com.cheeeese.album.dto.response;
+
+import com.cheeeese.album.domain.type.AlbumJoinStatus;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "์จ๋ฒ ์
์ฅ ์๋ต (๋คํ ๊ตฌ์กฐ)")
+public sealed interface AlbumEnterResponse
+ permits NewEnterResponse, ExistingEnterResponse {
+ AlbumJoinStatus joinStatus();
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java
new file mode 100644
index 0000000..de7bafa
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java
@@ -0,0 +1,39 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Builder
+@Schema(description = "์จ๋ฒ ์ ๋ณด API")
+public record AlbumInfoResponse(
+ @Schema(description = "์์ฑ์ ID", example = "1")
+ Long makerId,
+
+ @Schema(description = "์์ฑ์ ์ด๋ฆ", example = "์ฃผ์ ๋น")
+ String name,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "๊น์ํ๋ฌด๊ฑฐ๋ถ์ด")
+ String title,
+
+ @Schema(description = "ํ
๋ง ์ด๋ชจ์ง", example = "U+1F9C0")
+ String themeEmoji,
+
+ @Schema(description = "์ฐธ์ฌ ๊ฐ๋ฅ ์ธ์ ์", example = "64")
+ int participant,
+
+ @Schema(description = "ํ์ฌ ์ฐธ์ฌ์ ์", example = "30")
+ int currentParticipant,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง", example = "2025-02-01")
+ LocalDate eventDate,
+
+ @Schema(description = "ํ์ฌ ์ฌ์ง ์", example = "1212")
+ int currentPhotoCnt,
+
+ @Schema(description = "์จ๋ฒ ๋ง๋ฃ์ผ์", example = "2025-11-17 16:53:35.430336")
+ LocalDateTime expiredAt
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java
new file mode 100644
index 0000000..068e854
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java
@@ -0,0 +1,42 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์ด๋์ฅ ํ์ธ ์๋ต DTO",
+ requiredProperties = {
+ "title",
+ "themeEmoji",
+ "eventDate",
+ "expiredAt",
+ "makerName",
+ "makerProfileImage",
+ "isExpired"
+ }
+)public record AlbumInvitationResponse(
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "๊ฒฝ์ํ๋ถ ์กธ์
์")
+ String title,
+
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "U+1F9C0")
+ String themeEmoji,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง", example = "2025-02-26")
+ String eventDate,
+
+ @Schema(description = "์ด๋ ์ข
๋ฃ ์๊ฐ (๋ง๋ฃ์ผ)", example = "2025-03-05T00:00:00")
+ LocalDateTime expiredAt,
+
+ @Schema(description = "๋ฉ์ด์ปค ์ด๋ฆ", example = "์ด์ ")
+ String makerName,
+
+ @Schema(description = "๋ฉ์ด์ปค ํ๋กํ ์ด๋ฏธ์ง URL", example = "http://example.com/host_profile.png")
+ String makerProfileImage,
+
+ @Schema(description = "์จ๋ฒ ๋ง๋ฃ ์ฌ๋ถ", example = "false")
+ boolean isExpired
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java b/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java
new file mode 100644
index 0000000..2c33888
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java
@@ -0,0 +1,20 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Schema(
+ description = "์จ๋ฒ ๋ฉ์ด์ปค ์ ๋ณด ์๋ต DTO",
+ requiredProperties = {
+ "makerName",
+ "makerProfileImage"
+ }
+)
+@Builder
+public record AlbumMakerInfo(
+ @Schema(description = "๋ฉ์ด์ปค ์ด๋ฆ", example = "์ฐ๋คํ")
+ String makerName,
+
+ @Schema(description = "๋ฉ์ด์ปค ํ๋กํ ์ด๋ฏธ์ง URL", example = "https://cdn.cheeeese.com/users/1/profile.jpg")
+ String makerProfileImage
+) {}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java
new file mode 100644
index 0000000..b8a3175
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java
@@ -0,0 +1,43 @@
+package com.cheeeese.album.dto.response;
+
+import com.cheeeese.album.domain.type.Role;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์ฐธ์ฌ์ ๊ณตํต ์ ๋ณด ๊ตฌ์กฐ",
+ requiredProperties = {
+ "participants"
+ }
+)
+public record AlbumParticipantListResponse(
+ @Schema(description = "์ฐธ๊ฐ์ ๋ชฉ๋ก (์ ๋ ฌ ํฌํจ)")
+ List participants
+) {
+ @Builder
+ @Schema(
+ description = "์ฐธ๊ฐ์ ๊ฐ๋ณ ์ ๋ณด",
+ requiredProperties = {
+ "name",
+ "profileImage",
+ "role",
+ "isMe"
+ }
+ )
+ public record ParticipantInfo(
+ @Schema(description = "์ด๋ฆ", example = "์ฐ๋คํ")
+ String name,
+
+ @Schema(description = "ํ๋กํ ์ด๋ฏธ์ง URL", example = "https://cdn.cheeeese.com/users/1/profile.jpg")
+ String profileImage,
+
+ @Schema(description = "์ญํ (MAKER/GUEST)", example = "GUEST")
+ Role role,
+
+ @Schema(description = "ํ์ฌ ๋ก๊ทธ์ธ ์ฌ์ฉ์ ์ฌ๋ถ", example = "true")
+ boolean isMe
+ ) {}
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java
new file mode 100644
index 0000000..d0d4c88
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java
@@ -0,0 +1,53 @@
+package com.cheeeese.album.dto.response;
+
+import com.cheeeese.album.domain.type.Role;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์ฐธ์ฌ์ ๋ชฉ๋ก ์๋ต DTO (ํ์ฑ/๋ง๋ฃ ๊ณต์ฉ)",
+ requiredProperties = {
+ "isExpired",
+ "title",
+ "themeEmoji",
+ "eventDate",
+ "expiredAt",
+ "maxParticipantCount",
+ "currentParticipantCount",
+ "participants"
+ }
+)
+public record AlbumParticipantResponse(
+
+ @Schema(description = "๋ง๋ฃ ์ฌ๋ถ", example = "false")
+ boolean isExpired,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "์กธ์
์")
+ String title,
+
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง (๋๋ ์ด๋ฏธ์ง URL)", example = "U+1F9C0")
+ String themeEmoji,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง (yyyy-MM-dd)", example = "2025-02-01")
+ LocalDate eventDate,
+
+ @Schema(description = "๋ง๋ฃ ์ผ์ (ISO ํ์)", example = "2025-10-30T12:11:27.282")
+ LocalDateTime expiredAt,
+
+ @Schema(description = "์ด ์ฐธ์ฌ ๊ฐ๋ฅ ์ธ์", example = "64")
+ Integer maxParticipantCount,
+
+ @Schema(description = "ํ์ฌ ์จ๋ฒ ์ฐธ๊ฐ ์ธ์", example = "58")
+ Integer currentParticipantCount,
+
+ @Schema(description = "์ฐธ๊ฐ์ ๋ชฉ๋ก (์ ๋ ฌ ํฌํจ)")
+ List participants,
+
+ @Schema(description = "ํ์ฌ ์ฌ์ฉ์์ ์ญํ (MAKER / GUEST)", example = "GUEST", nullable = true)
+ Role myRole
+) {}
diff --git a/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java
new file mode 100644
index 0000000..ebfa295
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java
@@ -0,0 +1,35 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "๋ซํ ์จ๋ฒ ๋ชฉ๋ก ํ์ด์ง ์๋ต",
+ requiredProperties = {
+ "responses",
+ "listSize",
+ "isFirst",
+ "isLast",
+ "hasNext"
+ }
+)
+public record ClosedAlbumPageResponse(
+ @Schema(description = "๋ซํ ์จ๋ฒ ๋ชฉ๋ก")
+ List responses,
+
+ @Schema(description = "ํ์ฌ ํ์ด์ง์ ์จ๋ฒ ์", example = "2")
+ int listSize,
+
+ @Schema(description = "์ฒซ ํ์ด์ง ์ฌ๋ถ", example = "true")
+ boolean isFirst,
+
+ @Schema(description = "๋ง์ง๋ง ํ์ด์ง ์ฌ๋ถ", example = "false")
+ boolean isLast,
+
+ @Schema(description = "๋ค์ ํ์ด์ง ์กด์ฌ ์ฌ๋ถ", example = "true")
+ boolean hasNext
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java
new file mode 100644
index 0000000..3d39fad
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java
@@ -0,0 +1,34 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "๋ซํ ์จ๋ฒ ์์ฝ ์ ๋ณด",
+ requiredProperties = {
+ "code",
+ "title",
+ "makerName",
+ "eventDate"
+ }
+)
+public record ClosedAlbumSummaryResponse(
+ @Schema(description = "์จ๋ฒ ์ฝ๋", example = "786ccd09-...")
+ String code,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "๋ด ์ํ")
+ String title,
+
+ @Schema(description = "์จ๋ฒ ์์ฑ์ ์ด๋ฆ", example = "์น์ฆ๋ฉ์ด์ปค")
+ String makerName,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง", example = "2025-05-01")
+ LocalDate eventDate,
+
+ @Schema(description = "์น์ฆ๋ค์ปท ์ธ๋ค์ผ ๋ชฉ๋ก (4๊ฐ)", nullable = true)
+ List thumbnails
+) {}
diff --git a/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java
new file mode 100644
index 0000000..e553007
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java
@@ -0,0 +1,40 @@
+package com.cheeeese.album.dto.response;
+
+import com.cheeeese.album.domain.type.AlbumJoinStatus;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+@Schema(
+ description = "์จ๋ฒ ์ฌ์
์ฅ ๋๋ ๊ธฐ์กด ์ฐธ์ฌ์ ์๋ต DTO",
+ requiredProperties = {
+ "joinStatus",
+ "title",
+ "themeEmoji",
+ "eventDate",
+ "expiredAt",
+ "makerInfo"
+ }
+)
+@Builder
+public record ExistingEnterResponse(
+ @Schema(description = "์ฐธ์ฌ ์ํ (EXISTING | REJOINED)", example = "EXISTING")
+ AlbumJoinStatus joinStatus,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "์ฐ๋ฆฌ ์ฌํ ์จ๋ฒ")
+ String title,
+
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "๐ธ")
+ String themeEmoji,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง (YYYY-MM-DD ํ์ ๋ฌธ์์ด)", example = "2025-10-31")
+ String eventDate,
+
+ @Schema(description = "์จ๋ฒ ๋ง๋ฃ ์๊ฐ", example = "2025-11-30T23:59:59")
+ LocalDateTime expiredAt,
+
+ @Schema(description = "์จ๋ฒ ์์ฑ์(ํธ์คํธ) ์ ๋ณด")
+ AlbumMakerInfo makerInfo
+
+) implements AlbumEnterResponse {}
diff --git a/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java
new file mode 100644
index 0000000..d652f80
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java
@@ -0,0 +1,72 @@
+package com.cheeeese.album.dto.response;
+
+import com.cheeeese.album.domain.type.AlbumJoinStatus;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(
+ description = "์จ๋ฒ ์ฒซ ์
์ฅ(์ ๊ท ์ฐธ์ฌ์) ์๋ต DTO",
+ requiredProperties = {
+ "joinStatus",
+ "title",
+ "themeEmoji",
+ "eventDate",
+ "expiredAt",
+ "makerInfo",
+ "remainingUploadSlots",
+ "recentPhotos"
+ }
+)
+@Builder
+public record NewEnterResponse(
+ @Schema(description = "์ฐธ์ฌ ์ํ (ํญ์ NEW)", example = "NEW")
+ AlbumJoinStatus joinStatus,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "์ฌ๋ฆ ๋ฐ์บ์ค")
+ String title,
+
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "๐ด")
+ String themeEmoji,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง (YYYY-MM-DD ํ์ ๋ฌธ์์ด)", example = "2025-08-14")
+ String eventDate,
+
+ @Schema(description = "์จ๋ฒ ๋ง๋ฃ ์๊ฐ", example = "2025-09-01T00:00:00")
+ LocalDateTime expiredAt,
+
+ @Schema(description = "์จ๋ฒ ์์ฑ์ ์ ๋ณด")
+ AlbumMakerInfo makerInfo,
+
+ @Schema(description = "๋จ์ ์
๋ก๋ ๊ฐ๋ฅ ์ฌ์ง ์", example = "5")
+ Integer remainingUploadSlots,
+
+ @Schema(description = "์ต๊ทผ ์
๋ก๋๋ ์ฌ์ง ๋ชฉ๋ก (์ต๋ 5๊ฐ)", implementation = RecentPhotoResponse.class)
+ List recentPhotos
+) implements AlbumEnterResponse {
+ @Builder
+ @Schema(
+ description = "์ต๊ทผ ์
๋ก๋ ์ฌ์ง ์ ๋ณด DTO",
+ requiredProperties = {
+ "thumbnailUrl",
+ "uploaderName",
+ "uploaderProfileImage"
+ }
+ )
+ public record RecentPhotoResponse(
+ @NotNull
+ @Schema(description = "์ฌ์ง ์ธ๋ค์ผ URL", example = "https://cdn.cheeeese.com/album/1/thumb1.jpg")
+ String thumbnailUrl,
+
+ @NotNull
+ @Schema(description = "์
๋ก๋ ์ฌ์ฉ์ ์ด๋ฆ", example = "๊น์น์ฆ")
+ String uploaderName,
+
+ @NotNull
+ @Schema(description = "์
๋ก๋ ์ฌ์ฉ์ ํ๋กํ ์ด๋ฏธ์ง URL", example = "https://cdn.cheeeese.com/user/1/profile.jpg")
+ String uploaderProfileImage
+ ) {}
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java
new file mode 100644
index 0000000..4b524ce
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java
@@ -0,0 +1,35 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "์ด๋ฆฐ ์จ๋ฒ ๋ชฉ๋ก ํ์ด์ง ์๋ต",
+ requiredProperties = {
+ "responses",
+ "listSize",
+ "isFirst",
+ "isLast",
+ "hasNext"
+ }
+)
+public record OpenAlbumPageResponse(
+ @Schema(description = "์ด๋ฆฐ ์จ๋ฒ ๋ชฉ๋ก")
+ List responses,
+
+ @Schema(description = "ํ์ฌ ํ์ด์ง์ ์จ๋ฒ ์", example = "2")
+ int listSize,
+
+ @Schema(description = "์ฒซ ํ์ด์ง ์ฌ๋ถ", example = "true")
+ boolean isFirst,
+
+ @Schema(description = "๋ง์ง๋ง ํ์ด์ง ์ฌ๋ถ", example = "false")
+ boolean isLast,
+
+ @Schema(description = "๋ค์ ํ์ด์ง ์กด์ฌ ์ฌ๋ถ", example = "true")
+ boolean hasNext
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java
new file mode 100644
index 0000000..70a80b2
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java
@@ -0,0 +1,52 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Builder
+@Schema(
+ description = "์ด๋ฆฐ ์จ๋ฒ ์์ฝ ์ ๋ณด",
+ requiredProperties = {
+ "code",
+ "themeEmoji",
+ "title",
+ "eventDate",
+ "makerName",
+ "currentParticipant",
+ "participant",
+ "expiredAt"
+ }
+)
+public record OpenAlbumSummaryResponse(
+ @Schema(description = "์จ๋ฒ ์ฝ๋", example = "1f0ba577-39f3-69b6-abab-455897f404fe")
+ String code,
+
+ @Schema(description = "์จ๋ฒ ํ
๋ง ์ด๋ชจ์ง", example = "U+1F36F")
+ String themeEmoji,
+
+ @Schema(description = "์จ๋ฒ ์ ๋ชฉ", example = "๋ด ์ํ")
+ String title,
+
+ @Schema(description = "์ด๋ฒคํธ ๋ ์ง", example = "2025-05-01")
+ LocalDate eventDate,
+
+ @Schema(description = "์จ๋ฒ ์์ฑ์ ์ด๋ฆ", example = "์น์ฆ๋ฉ์ด์ปค")
+ String makerName,
+
+ @Schema(description = "ํ์ฌ ์ฐธ์ฌ์ ์", example = "8")
+ int currentParticipant,
+
+ @Schema(description = "์ ์ฒด ์ฐธ์ฌ์ ์", example = "10")
+ int participant,
+
+ @Schema(description = "์จ๋ฒ ๋ง๋ฃ ์์ ์ผ์", example = "2025-05-05T12:00:00")
+ LocalDateTime expiredAt,
+
+ @Schema(description = "์ต๊ทผ ์
๋ก๋๋ ์ฌ์ง ์ธ๋ค์ผ 3์ฅ", nullable = true)
+ List recentPhotoThumbnails
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java b/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java
new file mode 100644
index 0000000..614bf8e
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java
@@ -0,0 +1,25 @@
+package com.cheeeese.album.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Builder
+@Schema(
+ description = "์จ๋ฒ ์
๋ก๋ ๊ฐ๋ฅ ์ฌ์ง ์ ์๋ต DTO",
+ requiredProperties = {
+ "availableCount",
+ "maxPhotoCount",
+ "currentPhotoCount"
+ }
+)
+public record UploadAvailableCountResponse(
+ @Schema(description = "ํ์ฌ ์จ๋ฒ์ ์
๋ก๋ ๊ฐ๋ฅํ ์ต๋ ์ฌ์ง ์", example = "850")
+ int availableCount,
+
+ @Schema(description = "์จ๋ฒ์ ์ต๋ ์ฌ์ง ๊ฐ์", example = "1000")
+ int maxPhotoCount,
+
+ @Schema(description = "ํ์ฌ ์
๋ก๋๋ ์ฌ์ง ์ (UPLOADING, PROCESSING, COMPLETED ํฌํจ)", example = "150")
+ int currentPhotoCount
+) {
+}
diff --git a/src/main/java/com/cheeeese/album/exception/AlbumException.java b/src/main/java/com/cheeeese/album/exception/AlbumException.java
new file mode 100644
index 0000000..630fb61
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/exception/AlbumException.java
@@ -0,0 +1,12 @@
+package com.cheeeese.album.exception;
+
+import com.cheeeese.album.exception.code.AlbumErrorCode;
+import com.cheeeese.global.exception.BusinessException;
+import lombok.Getter;
+
+@Getter
+public class AlbumException extends BusinessException {
+ public AlbumException(AlbumErrorCode errorCode) {
+ super(errorCode);
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java b/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java
new file mode 100644
index 0000000..46ddb8b
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java
@@ -0,0 +1,30 @@
+package com.cheeeese.album.exception.code;
+
+import com.cheeeese.global.common.code.BaseCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@RequiredArgsConstructor
+public enum AlbumErrorCode implements BaseCode {
+
+ ALBUM_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์ฌํ์ง ์๊ฑฐ๋ ์ ํจํ์ง ์์ ์จ๋ฒ ์ฝ๋์
๋๋ค."),
+ ALBUM_EXPIRED(HttpStatus.BAD_REQUEST, "๋ง๋ฃ๋ ์จ๋ฒ์
๋๋ค."),
+ ALBUM_MAX_PARTICIPANT_REACHED(HttpStatus.BAD_REQUEST, "์จ๋ฒ์ ์ต๋ ์ฐธ๊ฐ ์ธ์์๋ฅผ ์ด๊ณผํ์ต๋๋ค."),
+ USER_IS_BLACKLISTED(HttpStatus.FORBIDDEN, "์จ๋ฒ ๊ด๋ฆฌ์์ ์ํด ์ ๊ทผ์ด ๊ธ์ง๋ ์ฌ์ฉ์์
๋๋ค."),
+ USER_ALREADY_JOINED_CONCURRENTLY(HttpStatus.CONFLICT, "๋์์ฑ ์ค๋ฅ: ์ด๋ฏธ ์จ๋ฒ์ ์ฐธ์ฌ ์์ฒญ์ด ์๋ฃ๋์์ต๋๋ค."),
+ ALBUM_THEME_EMOJI_NOT_SELECTED(HttpStatus.BAD_REQUEST, "์จ๋ฒ ์ธ๋ค์ผ ์ด๋ชจ์ง๊ฐ ์ ํ๋์ง ์์์ต๋๋ค."),
+ ALBUM_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "ํ์ฌ ์ด๋ฆ์ด ์
๋ ฅ๋์ง ์์์ต๋๋ค."),
+ ALBUM_EVENT_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "ํ์ฌ ๋ ์ง๊ฐ ์
๋ ฅ๋์ง ์์์ต๋๋ค."),
+ ALBUM_EVENT_DATE_INVALID(HttpStatus.BAD_REQUEST, "ํ์ฌ ๋ ์ง๋ ์ค๋ ๋๋ ๊ณผ๊ฑฐ๋ง ์ ํ ๊ฐ๋ฅํฉ๋๋ค."),
+ ALBUM_INVALID_CAPACITY(HttpStatus.BAD_REQUEST, "์จ๋ฒ ์ธ์์ ์ต์ 1๋ช
์ด์ ์ต๋ 64๋ช
์ดํ์ฌ์ผ ํฉ๋๋ค."),
+ ALBUM_CREATION_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "์ฌ์ฉ์๋ ์ผ์ฃผ์ผ์ ์ต๋ 3๊ฐ์ ์จ๋ฒ๋ง ์์ฑํ ์ ์์ต๋๋ค."),
+ USER_NOT_PARTICIPANT(HttpStatus.FORBIDDEN, "์ฌ์ฉ์๋ ํด๋น ์จ๋ฒ์ ์ฐธ๊ฐ์๊ฐ ์๋๋๋ค."),
+ USER_NOT_MAKER(HttpStatus.FORBIDDEN, "ํด๋น ์ฌ์ฉ์๋ MAKER๊ฐ ์๋๋๋ค."),
+ MAKER_CANNOT_LEAVE_UNTIL_CLOSED(HttpStatus.BAD_REQUEST, "์จ๋ฒ ๋ฉ์ด์ปค๋ ์จ๋ฒ์ด ๋ซํ ์ํ์์๋ง ๋๊ฐ ์ ์์ต๋๋ค."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java
new file mode 100644
index 0000000..4798d02
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java
@@ -0,0 +1,172 @@
+package com.cheeeese.album.infrastructure.mapper;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.type.AlbumJoinStatus;
+import com.cheeeese.album.dto.response.*;
+import com.cheeeese.photo.domain.Photo;
+import com.cheeeese.album.dto.response.AlbumBest4CutResponse;
+import com.cheeeese.user.domain.User;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class AlbumMapper {
+
+ /**
+ * Album Entity ์์ฑ
+ */
+ public static Album toEntity(
+ Long makerId,
+ String title,
+ String code,
+ String themeEmoji,
+ int participant,
+ LocalDate eventDate,
+ boolean isInfoAvailable,
+ LocalDateTime expiredAt
+ ) {
+ return Album.builder()
+ .makerId(makerId)
+ .title(title)
+ .code(code)
+ .themeEmoji(themeEmoji)
+ .participant(participant)
+ .currentParticipant(1)
+ .eventDate(eventDate)
+ .maxPhotoCount(2000)
+ .currentPhotoCount(0)
+ .isInfoAvailable(isInfoAvailable)
+ .expiredAt(expiredAt)
+ .status(Album.AlbumStatus.ACTIVE)
+ .build();
+ }
+
+ /**
+ * Album ์์ฑ ํ, UUID Code ๋ฐ๊ธ
+ */
+ public static AlbumCreationResponse toCreationResponse(Album album, boolean isFirst) {
+ return AlbumCreationResponse.builder()
+ .themeEmoji(album.getThemeEmoji())
+ .title(album.getTitle())
+ .eventDate(album.getEventDate())
+ .createdAt(album.getCreatedAt())
+ .isFirst(isFirst)
+ .currentPhotoCnt(album.getCurrentPhotoCount())
+ .code(album.getCode())
+ .build();
+ }
+
+ /**
+ * Album ์ํฐํฐ์ Maker User ์ ๋ณด๋ฅผ ์ด๋์ฅ ์๋ต DTO๋ก ๋ณํํฉ๋๋ค.
+ */
+ public static AlbumInvitationResponse toInvitationResponse(Album album, User user, String profileImage) {
+ return AlbumInvitationResponse.builder()
+ .title(album.getTitle())
+ .themeEmoji(album.getThemeEmoji())
+ .eventDate(album.getEventDate().toString())
+ .expiredAt(album.getExpiredAt())
+ .makerName(user.getName())
+ .makerProfileImage(profileImage)
+ .isExpired(false)
+ .build();
+ }
+
+ /**
+ * ์จ๋ฒ ๋ง๋ฃ ์, ์ต์ ์ ๋ณด๋ง ๋ด์ ์๋ต DTO๋ก ๋ณํํฉ๋๋ค.
+ */
+ public static AlbumInvitationResponse toExpiredInvitationResponse(Album album) {
+ return AlbumInvitationResponse.builder()
+ .title(album.getTitle())
+ .themeEmoji(album.getThemeEmoji())
+ .eventDate(album.getEventDate().toString())
+ .expiredAt(album.getExpiredAt())
+ .makerName(null)
+ .makerProfileImage(null)
+ .isExpired(true)
+ .build();
+ }
+
+ public static ExistingEnterResponse toExistingResponse(Album album, AlbumJoinStatus status, AlbumMakerInfo makerInfo) {
+ return ExistingEnterResponse.builder()
+ .joinStatus(status)
+ .title(album.getTitle())
+ .themeEmoji(album.getThemeEmoji())
+ .eventDate(album.getEventDate().toString())
+ .expiredAt(album.getExpiredAt())
+ .makerInfo(makerInfo)
+ .build();
+ }
+
+ public static NewEnterResponse toNewResponse(
+ Album album,
+ AlbumMakerInfo makerInfo,
+ int remainingUploadSlots,
+ List recentPhotos
+ ) {
+ return NewEnterResponse.builder()
+ .joinStatus(AlbumJoinStatus.NEW)
+ .title(album.getTitle())
+ .themeEmoji(album.getThemeEmoji())
+ .eventDate(album.getEventDate().toString())
+ .expiredAt(album.getExpiredAt())
+ .makerInfo(makerInfo)
+ .remainingUploadSlots(remainingUploadSlots)
+ .recentPhotos(recentPhotos)
+ .build();
+ }
+
+ public static NewEnterResponse.RecentPhotoResponse toRecentPhotoResponse(Photo photo, String profileImage) {
+ User uploader = photo.getUser();
+
+ return NewEnterResponse.RecentPhotoResponse.builder()
+ .thumbnailUrl(photo.getThumbnailUrl())
+ .uploaderName(uploader.getName())
+ .uploaderProfileImage(profileImage)
+ .build();
+ }
+
+ /**
+ * ๋ฉ์ด์ปค User ์ํฐํฐ๋ฅผ ํธ์คํธ ์ ๋ณด ์๋ต DTO๋ก ๋ณํํฉ๋๋ค.
+ */
+ public static AlbumMakerInfo toMakerInfo(User user, String profileImage) {
+ return AlbumMakerInfo.builder()
+ .makerName(user.getName())
+ .makerProfileImage(profileImage)
+ .build();
+ }
+
+ public static UploadAvailableCountResponse toAvailableCountResponse(
+ int availableCount,
+ int maxCount,
+ int currentCount
+ ){
+ return UploadAvailableCountResponse.builder()
+ .availableCount(availableCount)
+ .maxPhotoCount(maxCount)
+ .currentPhotoCount(currentCount)
+ .build();
+ }
+
+ public static AlbumBest4CutResponse toBest4CutResponse(Photo photo, String thumbnailUrl, boolean isLiked) {
+ return AlbumBest4CutResponse.builder()
+ .thumbnailUrl(thumbnailUrl)
+ .likeCnt(photo.getLikesCnt())
+ .isLiked(isLiked)
+ .build();
+ }
+
+ public static AlbumInfoResponse toAlbumInfoResponse(Album album, User user) {
+ return AlbumInfoResponse.builder()
+ .title(album.getTitle())
+ .makerId(album.getMakerId())
+ .name(user.getName())
+ .themeEmoji(album.getThemeEmoji())
+ .participant(album.getParticipant())
+ .currentParticipant(album.getCurrentParticipant())
+ .eventDate(album.getEventDate())
+ .currentPhotoCnt(album.getCurrentPhotoCount())
+ .expiredAt(album.getExpiredAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java
new file mode 100644
index 0000000..6e5ea4a
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java
@@ -0,0 +1,73 @@
+package com.cheeeese.album.infrastructure.mapper;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.dto.response.ClosedAlbumPageResponse;
+import com.cheeeese.album.dto.response.ClosedAlbumSummaryResponse;
+import com.cheeeese.album.dto.response.OpenAlbumPageResponse;
+import com.cheeeese.album.dto.response.OpenAlbumSummaryResponse;
+import com.cheeeese.user.domain.User;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+
+public class AlbumQueryMapper {
+
+ public static OpenAlbumPageResponse toOpenAlbumPageResponse(
+ List responses,
+ Slice albums
+ ){
+ return OpenAlbumPageResponse.builder()
+ .responses(responses)
+ .listSize(responses.size())
+ .isFirst(albums.isFirst())
+ .isLast(albums.isLast())
+ .hasNext(albums.hasNext())
+ .build();
+ }
+
+ public static ClosedAlbumPageResponse toClosedAlbumPageResponse(
+ List responses,
+ Slice albums
+ ){
+ return ClosedAlbumPageResponse.builder()
+ .responses(responses)
+ .listSize(responses.size())
+ .isFirst(albums.isFirst())
+ .isLast(albums.isLast())
+ .hasNext(albums.hasNext())
+ .build();
+ }
+
+ public static OpenAlbumSummaryResponse toOpenAlbumSummaryResponse(
+ Album album,
+ User maker,
+ List thumbnails
+ ){
+ return OpenAlbumSummaryResponse.builder()
+ .code(album.getCode())
+ .themeEmoji(album.getThemeEmoji())
+ .title(album.getTitle())
+ .eventDate(album.getEventDate())
+ .makerName(maker.getName())
+ .currentParticipant(album.getCurrentParticipant())
+ .participant(album.getParticipant())
+ .expiredAt(album.getExpiredAt())
+ .recentPhotoThumbnails(thumbnails.isEmpty() ? null : thumbnails)
+ .build();
+ }
+
+ public static ClosedAlbumSummaryResponse toClosedAlbumSummaryResponse(
+ Album album,
+ User maker,
+ List thumbnails
+ ) {
+ return ClosedAlbumSummaryResponse.builder()
+ .code(album.getCode())
+ .title(album.getTitle())
+ .makerName(maker.getName())
+ .eventDate(album.getEventDate())
+ .thumbnails(thumbnails.isEmpty() ? null : thumbnails)
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java
new file mode 100644
index 0000000..7ee737e
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java
@@ -0,0 +1,50 @@
+package com.cheeeese.album.infrastructure.mapper;
+
+import com.cheeeese.album.domain.Album;
+import com.cheeeese.album.domain.UserAlbum;
+import com.cheeeese.album.domain.type.Role;
+import com.cheeeese.album.dto.response.AlbumParticipantListResponse;
+import com.cheeeese.album.dto.response.AlbumParticipantResponse;
+import com.cheeeese.user.domain.User;
+
+import java.util.List;
+
+public class UserAlbumMapper {
+
+ public static UserAlbum toEntity(User user, Album album, Role role) {
+ return UserAlbum.builder()
+ .user(user)
+ .album(album)
+ .role(role)
+ .isVisible(true)
+ .build();
+ }
+
+ public static AlbumParticipantListResponse.ParticipantInfo toParticipantInfo(User user, String profileImage, Role role, boolean isMe) {
+ return AlbumParticipantListResponse.ParticipantInfo.builder()
+ .name(user.getName())
+ .role(role)
+ .profileImage(profileImage)
+ .isMe(isMe)
+ .build();
+ }
+
+ public static AlbumParticipantResponse toAlbumParticipantResponse(
+ Album album,
+ boolean isExpired,
+ Role myRole,
+ List participants
+ ) {
+ return AlbumParticipantResponse.builder()
+ .isExpired(isExpired)
+ .title(album.getTitle())
+ .themeEmoji(album.getThemeEmoji())
+ .eventDate(album.getEventDate())
+ .expiredAt(album.getExpiredAt())
+ .maxParticipantCount(album.getParticipant())
+ .currentParticipantCount(album.getCurrentParticipant())
+ .participants(participants)
+ .myRole(myRole)
+ .build();
+ }
+}
diff --git a/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java
new file mode 100644
index 0000000..e02b27c
--- /dev/null
+++ b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java
@@ -0,0 +1,65 @@
+package com.cheeeese.album.infrastructure.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Component
+@RequiredArgsConstructor
+public class AlbumExpirationRedisRepository {
+
+ private static final String EXPIRATION_ZSET_KEY = "expired:album:zset";
+
+ @Qualifier("cacheRedisTemplate")
+ private final RedisTemplate cacheRedisTemplate;
+
+ /**
+ * ZSET์ ์จ๋ฒ ID์ ๋ง๋ฃ ์๊ฐ์ Score๋ก ๋ฑ๋ก
+ * Score๋ Unix Timestamp(๋ฐ๋ฆฌ์ด)๋ก ์ฌ์ฉ
+ * @param albumId ์จ๋ฒ ๊ณ ์ ID
+ * @param expiredAt ์จ๋ฒ ๋ง๋ฃ ์๊ฐ (LocalDateTime)
+ */
+ public void registerAlbum(Long albumId, LocalDateTime expiredAt) {
+ // LocalDateTime์ Unix Timestamp (ms)๋ก ๋ณํ
+ long expirationMillis = expiredAt.toInstant(ZoneOffset.UTC).toEpochMilli();
+
+ // ZADD key score member
+ cacheRedisTemplate.opsForZSet().add(EXPIRATION_ZSET_KEY, albumId.toString(), (double) expirationMillis);
+ }
+
+ /**
+ * ํ์ฌ ์๊ฐ์ ๊ธฐ์ค์ผ๋ก ๋ง๋ฃ๋ ์จ๋ฒ ID ๋ชฉ๋ก๋ง ZSET์์ ์กฐํ (O(log N + k))
+ * @return ๋ง๋ฃ๋ ์จ๋ฒ ID Set
+ */
+ public Set getExpiredAlbumIds() {
+ // ํ์ฌ ์๊ฐ์ Unix Timestamp (๋ฐ๋ฆฌ์ด)
+ long currentTimestamp = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli();
+
+ // ZRANGEBYSCORE key min max: Score๊ฐ 0๋ถํฐ ํ์ฌ ์๊ฐ๊น์ง์ธ ๋ชจ๋ Member๋ฅผ ์กฐํ
+ Set