diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..f29fa3f60
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+.git/
+.gitignore/
+.idea/
diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
new file mode 100644
index 000000000..1172ee039
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -0,0 +1,20 @@
+---
+name: "\bIssue 생성 템플릿"
+about: 해당 Issue 생성 템플릿을 통하여 Issue를 생성해주세요.
+title: 'Feat: Issue 제목'
+labels: ''
+assignees: ''
+
+---
+
+### 📝 Description
+
+- 구현할 내용 1
+- 구현할 내용 2
+
+---
+
+### 📝 Todo
+
+- [ ] 구현할 내용 1
+- [ ] 구현할 내용 2
\ 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 000000000..e509b97b3
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,35 @@
+## ✅ PR 유형
+어떤 변경 사항이 있었나요?
+
+- [ ] 새로운 기능 추가
+- [ ] 버그 수정
+- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
+- [ ] 코드 리팩토링
+- [ ] 주석 추가 및 수정
+- [ ] 문서 수정
+- [ ] 빌드 부분 혹은 패키지 매니저 수정
+- [ ] 파일 혹은 폴더명 수정
+- [ ] 파일 혹은 폴더 삭제
+
+---
+
+## 📝 작업 내용
+이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)
+
+- 작업한 내용 1
+- 작업한 내용 2
+
+---
+
+## ✏️ 관련 이슈
+본인이 작업한 내용이 어떤 Issue Number와 관련이 있는지만 작성해주세요
+
+ex)
+- Fixes : #00 (수정중인 이슈)
+- Resolves : #100 (무슨 이슈를 해결했는지)
+- Ref : #00 #01 (참고할 이슈)
+- Related to : #00 #01 (해당 커밋과 관려)
+
+---
+
+## 🎸 기타 사항 or 추가 코멘트
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..8ff846f21
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,115 @@
+name: Cnergy Backend CI/CD
+
+on:
+ push:
+ branches: [dev]
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+
+ services:
+ redis:
+ image: redis
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 6379:6379
+
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@v4
+
+ - name: Set up SSH key
+ uses: webfactory/ssh-agent@v0.5.3
+ with:
+ ssh-private-key: ${{ secrets.NCP_SSH_PRIVATE_KEY }}
+
+ - name: Download GeoLite2-City Database
+ run: |
+ mkdir -p src/main/resources/maxmind
+
+ curl -L -u ${{ secrets.GEOIP_ACCOUNT_ID }}:${{ secrets.GEOIP_LICENSE }} \
+ 'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&suffix=tar.gz&license_key=${{ secrets.GEOIP_LICENSE }}' \
+ -o GeoLite2-City.tar.gz
+
+ tar -xzf GeoLite2-City.tar.gz --wildcards --strip-components 1 -C src/main/resources/maxmind '*.mmdb'
+
+ rm GeoLite2-City.tar.gz
+
+ - name: Gradle 캐시 적용
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle
+
+ - name: JDK 17 세팅
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: YML 파일 세팅
+ env:
+ APPLICATION_PROPERTIES: ${{ secrets.APPLICATION_PROPERTIES }}
+ TEST_APPLICATION_PROPERTIES: ${{ secrets.TEST_APPLICATION_PROPERTIES }}
+ run: |
+ cd ./src
+ rm -rf main/resources/application.yml
+ mkdir -p test/resources
+ echo "$APPLICATION_PROPERTIES" > main/resources/application.yml
+ echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml
+
+ - name: gradlew 권한 부여
+ run: chmod +x gradlew
+
+ - name: 테스트 수행
+ run: ./gradlew test
+
+ - name: 스프링부트 빌드
+ run: ./gradlew build
+
+ - name: Docker Buildx 세팅
+ uses: docker/setup-buildx-action@v3
+
+ - name: NCP 레지스트리 로그인
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.NCP_CONTAINER_REGISTRY }}
+ username: ${{ secrets.NCP_ACCESS_KEY }}
+ password: ${{ secrets.NCP_SECRET_KEY }}
+
+ - name: 도커 이미지 빌드 후 푸시
+ if: success()
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-backend:${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ platforms: linux/amd64,linux/arm64
+
+ - name: Docker Compose 파일 NCP 서버로 전송
+ run: scp -o StrictHostKeyChecking=no -P ${{ secrets.NCP_PORT }} docker-compose.yml ${{ secrets.NCP_USERNAME }}@${{ secrets.NCP_HOST }}:./
+
+ - name: NCP 접속 후 배포
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.NCP_HOST }}
+ username: ${{ secrets.NCP_USERNAME }}
+ password: ${{ secrets.NCP_PASSWORD }}
+ port: ${{ secrets.NCP_PORT }}
+ script: |
+ echo "NCP_CONTAINER_REGISTRY=${{ secrets.NCP_CONTAINER_REGISTRY }}" > .env
+ echo "GITHUB_SHA=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" >> .env
+ echo "RABBITMQ_DEFAULT_USER=${{ secrets.RABBITMQ_DEFAULT_USER }}" >> .env
+ echo "RABBITMQ_DEFAULT_PASS=${{ secrets.RABBITMQ_DEFAULT_PASS }}" >> .env
+ sudo chmod +x ./deploy.sh
+ ./deploy.sh
diff --git a/.gitignore b/.gitignore
index c2065bc26..e08eee973 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,8 @@ out/
### VS Code ###
.vscode/
+
+### Configuration ###
+src/main/resources/application-*.yml
+src/main/resources/maxmind
+src/test/resources/application.yml
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..9e4e82758
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM openjdk:17.0.1-jdk-slim
+
+WORKDIR /app
+
+COPY ./build/libs/backend-0.0.1-SNAPSHOT.jar /app/backend.jar
+
+EXPOSE 8080
+ENTRYPOINT ["java"]
+CMD ["-jar", "backend.jar"]
diff --git a/Dockerfile-local b/Dockerfile-local
new file mode 100644
index 000000000..455fa0f72
--- /dev/null
+++ b/Dockerfile-local
@@ -0,0 +1,17 @@
+FROM gradle:8.10.1-jdk17 AS build
+
+WORKDIR /app
+
+COPY . /app
+
+RUN gradle clean build -x test --no-daemon
+
+FROM openjdk:17.0.1-jdk-slim
+
+WORKDIR /app
+
+COPY --from=build /app/build/libs/*.jar /app/backend.jar
+
+EXPOSE 8080
+ENTRYPOINT ["java"]
+CMD ["-jar", "backend.jar"]
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..22e5be2b0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,532 @@
+
+# ⭐ 팀 소개
+
+> **서비스명** : **조각조각** ⏰
+
+‘조각조각’은 자투리 시간이라는 ’작은 조각‘들을 모아 하나의 큰 퍼즐인 ‘나만의 시간‘을 만들어낸다는 의미를 담았습니다. 특히, 시계를 표현하는 의성어인 ’째깍째깍‘과 유사한 초성을 이용하여, ’시간‘이라는 컨셉에 더욱 충실할 수 있도록 했습니다.
+
+> **팀명** : **C-nergy 시너지** 💛
+
+C팀 + energy의 합성어로, C팀의 모든 팀원들이 각자의 에너지를 가지고 함께 함으로써 결국 최고의 시너지를 만들어가겠다는 의미를 담았습니다 😊
+
+
+> **R&R분배**
+
+| **분야** | **이름** | **포지션** |
+| --- | --- | --- |
+| ⚡️기획 | 한나영 | PM, 서비스 기획 - 데스크 리서치, 유저리서치, 아이데이션, 와이어프레임, 기능명세서, 화면명세서 |
+| ⚡️기획 | 신예진 | 서비스 기획 - 데스크 리서치, 유저 리서치, 아이데이션, 와이어프레임, 페르소나 및 CJM, 화면명세서 |
+| ⚡️기획 | 홍가연 | 서비스 기획 - 기획 리드, 데스크 리서치, 유저 리서치, 아이데이션, 와이어프레임, 기능명세서, 화면명세서 |
+| 🎨디자인 | 최은정 | 프로덕트 디자인 - 브랜딩디자인, UXUI디자인, 그래픽디자인 |
+| 💻프론트엔드 | 공예영 | UI 및 기능 구현, PR CI 구축 |
+| 💻프론트엔드 | 황동준 | 프론트엔드 리드, UI 및 기능 구현, ncp 배포 및 CI/CD 구축 |
+| 💻백엔드 | 전호영 | DB 및 API 구축, 서버 배포 ,네이버 클로바 추천 시스템 구현, 인프라 구축 |
+| 💻백엔드 | 한성민 | 개발 리드, DB 설계 및 API 구현, OpenAI 추천 시스템 구현, 서버 배포 |
+
+# 🌟 서비스 개요
+
+> **서비스 소개**
+
+⏰ ‘조각조각’은 **일상 속에서 발생하는 자투리 시간을 의미 있게 활용할 수 있도록 돕는 개인 맞춤형 활동 추천 서비스**입니다.
+
+
+**`세부 기능`**
+
+- 사용자의 자투리 시간과 상황(온·오프라인 여부, 위치 등)을 기반으로 다양한 카테고리(자기개발/엔터테인먼크/휴식/문화예술/건강/소셜)별 활동 추천 리스트를 제공합니다.
+- 사용자가 활용한 자투리 시간은 ‘시간 조각’으로 시각화해 아카이빙할 수 있으며, 자투리 시간을 온전한 ‘내 시간’으로 만들고 그 효능감을 인식할 수 있도록 돕습니다.
+
+
+> **서비스 차별성**
+>
+
+‘조각조각’은 일상 속에서 **불규칙하고 갑작스럽게 발생해 버려지기 쉬운 ‘자투리 시간’을 유의미한 활동으로 전환할 수 있도록** 돕습니다. 사용자의 시간 활용을 지원하는 일정 관리/루틴 관리 서비스와 같이 단순 생산성 향상과 시간 관리 가이드라인 제공을 목표로 하지 않는다는 점에서 차별화됩니다.
+
+특히 추천 카테고리의 다양화와 사용자 아카이빙 기록 시각화를 통해 재미 요소를 추가했으며, AI 기반의 추천 엔진(OpenAI, Clova AI)을 활용해 온/오프라인별 확장된 사용자 맞춤형 콘텐츠를 제공할 수 있습니다.
+
+
+> **목표 사용자**
+>
+
+‘조각조각’의 주요 목표 사용자는 **자투리 시간이 자주 발생하고, 시간을 의미있게 또는 다양한 방식으로 활용하고자 하는 니즈**가 있는 사람입니다.
+
+**`주요 타겟`**
+
+- 출퇴근 시간이나 학업 중 자투리 시간이 빈번히 발생하는 사람
+- 시간의 효율적인 활용 및 성취감을 중시하는 자기개발 니즈가 강한 사람
+- 반복적인 일상에서 벗어나 색다른/즐거운 활동을 경험하고 싶은 사람
+
+# 🤔 문제 인식(Problem)
+
+## 서비스 개발 동기
+
+‘하루 중에 버려지는 시간이 너무 많다 …’ 이런 생각 드신 적 있으신가요?
+
+배차 간격이 긴 버스를 기다릴 때, 갑자기 수업이 휴강 되었을 때, 약속 시간에 일찍 도착했을 때 … 무언가 새롭게 하기엔 애매한 것 같고, 멍하니 보내기엔 왠지 아까운 그 시간!
+
+>💡 팀 시너지는 우리의 일상에서 **누구에게나 발생할 수 있는 자투리 시간의 활용 한계와 아쉬움에 주목해**,
+> **모든 사람들이 자신만의 의미있는 자투리 시간을 만들 수 있는 방법**을 찾아 ‘조각조각’ 서비스를 고안하게 되었습니다.
+
+
+## 서비스 목적 및 필요성
+
+‘조각조각’은 자투리 시간을 가볍지만 의미 있게 활용할 수 있는 방법을 제안해, **자투리 시간 활용에 대한 니즈와 페인포인트를 충족**합니다.
+- 사용자의 시간과 상황, 취향을 반영한 개인화된 추천으로 `자투리 시간`을 `온전한 ‘내 시간'`으로 만들 수 있도록 돕습니다.
+- 사용자가 자투리 시간에 대한 인식을 `버려지는 시간`, `애매한 시간`이 아닌 `’무엇이든 가볍게 시도해볼 수 있는 시간’`으로 전환할 수 있도록 만듭니다.
+
+> **1️⃣ 시간의 효율적 활용에 대한 니즈 확산**
+
+💡 **시간을 효율적으로, 잘 활용할 수 있는 방법을 제안하는 시의성 있는 서비스**
+
+시간의 가치를 중요하게 생각하는 사회적 동향과 함께, 24시간을 효율적으로 쓰고 싶어하는 니즈가 사회적으로 확산 되고 있습니다.
+
+- 2024 트렌드 코리아 키워드로도 선정된 ‘분초사회’는 시간이 중요성이 매우 강조되는 사회로, 시간의 효율성을 극대화하기 위해 분초(分秒)를 다투며 산다는 의미입니다. 이처럼 현대사회에서 시간의 중요성이 높아지면서 ‘시성비’라는 용어도 등장할 만큼 시간의 ‘가성비’를 중시하는 현상이 일어나고 있습니다.
+- CJ ENM의 디지털마케팅 기업 메조미디어의 조사에 따르면 현대사회 사람들은 ‘현대사회에서 시간은 가장 큰 자원(82%)’이라고 생각하며 ‘남들보다 24시간을 효율적으로 써야 나의 가치를 더 높일 수 있다’고 답했습니다. **이는 시간의 가치를 무엇보다 중요하게 생각한다는 결과로, 시간을 효율적으로 사용하고자 하는 니즈가 사회적으로 퍼져있음을 확인할 수 있습니다.**
+
+출처: [분초사회](https://terms.naver.com/entry.naver?docId=6712649&cid=43667&categoryId=43667), [이명진 가자 내 시간은 '가장 큰 자원'... 시간 아끼는 '초단축 소비' 트렌드 대세](https://www.banronbodo.com/news/articleView.html?idxno=23135)
+
+> **2️⃣ 일상에서 빈번히 발생하는 자투리 시간에 대한 아쉬움**
+
+💡**매일 의미없이 버려지는 자투리 시간에 대한 아쉬움과 피로감을 해결할 수 있는 서비스**
+
+
+우리의 24시간 중 자투리 시간은 얼마나 발생하고 있을까요?
+
+2019년 취업사이트 게임잡 설문 결과, 성인남녀의 자투리 시간은 **하루 평균 2시간 반(147분)** 에 달한 것으로 나타났습니다. 직장인은 하루 평균 127분, 대학생은 160분의 자투리 시간이 발생하며, **2명 중 1명(44.9%)은 '매일 발생하는 자투리 시간이 아깝다'** 고 답변했습니다.
+
+자체적으로 진행한 유저리서치에서도 20대 성인남녀가 **자투리 시간이 ‘버려지는 시간’ 또는 ‘무언가를 하기엔 짧고 애매한 시간’으로 여겨지며, 그 시간이 아깝다고 느낀다**고 답변했습니다. 특히 직장인의 경우, 출퇴근 과정에서 발생하는 **자투리 시간의 반복성과 긴 시간에 불편함**을 표하고 있었습니다.
+
+- '자투리시간 사용 행태 및 불편 사항'에 관한 설문조사 결과, 20대 설문 응답자가 자투리 시간 발생에 대해 약 80%가 불편함을 느끼고 있다고 답변했으며 그에 대한 이유는 버려지는 시간의 아까움(71.6%), 짧고 애매한 시간으로 무엇을 할지 고민 됨(58.1%)이 상위 응답으로 나타났습니다.
+- 경기·인천 통학·통근 인구 153만 명 중 90% 이상(141만 명)이 서울로 이동하고 있으며(2020년 인구주택총조사), 직장인들이 출퇴근을 위해 하루에 소요하는 시간은 평균 1시간 24분으로 밝혀졌습니다(2022년 잡코리아 조사). 이러한 직장인들에게 출퇴근 길에 느끼는 피로도를 점수로 환산(*100점 만점 기준)하게 한 결과, 경기권은 74점, 서울과 지방은 71점을 보이며 직장인의 출퇴근 피로도의 심각성을 확인할 수 있었습니다. 이들에게 피로감을 느끼는 이유에 대해 복수응답으로 꼽아보게 한 결과로는 “오늘도 어김없이 출근이라는 현실 때문에 스트레스를 받는다”는 의견이 63%로 가장 높은 비율을 차지함이 드러났습니다.
+
+출처: [메트로신문 성인남녀 '하루 2시간여 자투리 시간'에 하는 것 톱5… 10명 중 8명은 '온라인 활동'](https://www.metroseoul.co.kr/article/2019102900083), [윤화정 기자 직장인 출퇴근 소요시간 평균 '1시간 24분'](http://www.worktoday.co.kr/news/articleView.html?idxno=26284)
+
+> **3️⃣ 반복되는 일상 속 새로운 활동에 대한 니즈**
+
+💡**남는 시간을 성취감 있게, 재미있게! 사용자에게 가치 있는 시간을 만들어 주는 서비스**
+
+그렇다면, 사용자는 자투리 시간에 **어떤 활동**을 하고 싶을까요?
+
+유저리서치 결과, 20대 설문 응답자들은 자투리 시간을 의미있게 만들기 위해 중요하게 생각하는 가치로 **습관 형성, 성취감, 재미있는 경험, 새로운 학습/지식 습득**을 꼽았습니다. 시간을 가치 있게 사용하고 싶어하는 성향이 반영되어 성취감을 느낄 수 있는 활동을 선호하는 것으로 판단됩니다. 실제로 통학 및 출퇴근 시간을 활용해 학습 앱으로 공부하며 체계적으로 시간을 관리하고, 자신만의 학업 경쟁력을 높이는 ‘틈새 학습 앱’이 인기를 끈다는 점에서도 그 니즈를 확인할 수 있었습니다.
+
+한편, Z세대를 중심으로 **일상적 상황에서 벗어난 재미있는 경험을 추구**하는 경향도 나타나고 있습니다. 낭만을 쫓는 ‘굳이 데이’, ‘도파민’ 등의 용어가 활발하게 쓰이며 색다르고 다양한 삶의 경험을 영위하고자 하는 니즈가 확산되고 있습니다.
+
+- '자투리시간 사용 행태 및 불편 사항'에 관한 설문조사 결과, 20대 설문 응답자의 92% 이상이 ‘자투리 시간을 의미있게 보내고 싶다’고 답했으며, ‘자투리 시간을 의미있게 만들기 위해 중요하다고 생각하는 가치’에 대해 습관 형성(57.3%), 성취감(50.6%), 재미있는 경험(46.1%), 새로운 학습/지식 습득(37.1%) 순으로 응답했습니다.
+- 프로통학러들 사이에서 통학시간에 틈새 학습 앱으로 공부하며 체계적으로 시간을 관리하고, 자신만의 학업 경쟁력을 높이는 학습법이 각광받고 있습니다. 이러한 흐름에 발맞춰 YBM인강, 똑똑보카, 오르조 등 교육업계 서비스에서도 하루 30분 이상 도로에서 시간을 소모하는 통학러(통학생)들을 위해 언제, 어디서나 편하게 자투리 시간을 활용해 공부할 수 있는 학습 앱을 속속 선보이고 있습니다.
+- Z세대 사이에서 ‘도파민’이 큰 화두입니다. 도파민은 스트레스를 완화해주고 기쁨을 느끼게 하는 우리 몸에 필수적인 호르몬입니다. 이에 따라 몰입이나 성취를 통해 느리지만 자연스럽게 도파민을 얻는 방식이 최근 떠오르고 있습니다. 더불어 최근 Z세대 사이 ‘낭만’이라는 단어가 자주 사용됩니다. 도파민을 추구하며 ‘낭만’이라는 이름으로 긍정적으로 색다른 경험들을 쫓고 다양한 삶의 경험을 영위하고 있습니다.
+
+출처: [이준문 기자 ‘프로통학러’ 학습 경쟁력 높이는 ‘틈새 학습 앱’ 각광](http://m.newstap.co.kr/news/articleView.html?idxno=204208), [캐릿이 4년 간 분석한 Z세대의 새로운 특징 ‘MZBTI 3.0’](https://www.careet.net/1293)
+
+# 👥 사용자 **(User)**
+
+## 유저 리서치
+
+### Survey
+
+> 데스크 리서치를 통한 문제 인식과 배경 조사를 기반으로 96명에게 ‘자투리 시간 활용 행태 및 불편함’에 대한 설문 조사를 진행하였습니다.
+
+
+**1️⃣ 자투리 시간 발생 현황 및 사용 행태**
+
+사람들이 자투리 시간을 어떻게 활용하고 있는 지 알아보았습니다.
+
+
+
+
+- 응답자의 85% 이상이 자투리 시간이 자주 발생한다고 답변했으며, 주로 대중교통을 이용하거나 주요 일정의 휴식 시간에서 발생.
+- 압도적인 수치로 소셜 미디어 및 음악 감상/영상 시청이 높은 편이었으며 이 외에도 책, 뉴스, 게임 등을 통해 자투리 시간을 활용하고 있음.
+ - 해당 활동으로 자투리 시간을 활용하는 이유로는 간편함, 높은 접근성, 생산성, 습관적, 휴식 등을 꼽았으며 어떤 일을 하기 애매한 시간이라 흘려보낸다는 답변도 많았음.
+
+⇒ 자투리 시간이 **애매한 시간이라는 인식**으로, 대부분 **간편하고 접근성이 높은** 휴대폰을 통한 활동을 한다는 것을 확인할 수 있었음.
+
+**2️⃣ 자투리 시간에서 느끼는 불편함**
+
+자투리 시간 발생 시 어떤 불편함을 느끼는 지 알아보았습니다.
+
+
+
+- 자투리 시간 발생에 대해 약 80%가 불편함을 느끼고 있다고 응답
+ - 이에 대한 이유로는 버려지는 시간의 아까움, 짧고 애매한 시간으로 무엇을 할 지 고민 됨, 과도한 핸드폰 사용량, 지루함 등을 이유로 꼽음
+
+⇒ 대부분의 사람들은 버려지는 시간을 아까워하지만 ‘자투리 시간’이 애매하다는 인식으로 어떤 일을 해야 할 지 모르고 있음.
+
+**3️⃣ 자투리 시간 활용 시 주요 가치**
+
+자투리 시간 활용에서 가장 중요하게 느끼는 것이 무엇인지 알아보았습니다.
+
+
+
+- 응답자의 92% 이상이 자투리 시간을 의미있게 보내고 싶다는 니즈가 있음
+- 자투리 시간 활용에서 중요하게 생각하는 것은 습관 형성, 성취감, 재미있는 경험 등
+
+### In-Depth Interview
+
+> 시행 기간: 2024.10.10 ~ 10.13 총 4일간
+>
+> 대상자: 자투리 시간을 의미있게 보내고 싶은 응답자&자투리 시간 활용에 불편함을 느끼는 응답자 중 5명
+
+In-Depth Interview를 진행하기에 앞서, 인터뷰이들의 자투리 행태를 보다 면밀히 파악하기 위해
+자투리 시간 활용에 대한 자가 기록 연구를 부탁했습니다.
+
+> 🖊️ **[자가 기록 연구 내용]**
+>
+> 자투리 시간이 발생할 때마다 해당 폼을 통해 활용 행태를 적도록 함.
+>
+> 유의미한 정보 수집을 위해 최소 하루 이상의 자가 기록을 진행할 수 있도록 하였으며, 구글폼의 주어진 양식을 통해 제출할 수 있도록 하여 사용자의 기록 편의성을 높일 수 있게 함.
+> - **자투리 시간 사용 전 :** 자투리 시간이 발생한 상황 (발생 이유/현재 시각/장소/발생한 자투리 시간)
+> - **자투리 시간 사용 후 :** 자투리 시간 사용 행태 (사용 방법/이유/감정)
+
+자가 기록 연구와 서베이 응답을 기반으로 각 인터뷰이 별로 30분 내외의 심층 인터뷰를 진행하였고,
+유사한 응답을 묶어 Affinity Diagram을 진행하였습니다.
+
+
+
+1. 자투리 시간에 대한 인식
+ ⇒ 대부분의 인터뷰이들은 자투리 시간을 ‘애매하게 남는 시간’, ‘아까운 시간’, ‘예상치 못하게 발생하는 시간’이라고 인식하고 있었음. 이로 인해 해당 시간에 무엇을 하면 좋을 지 모르겠다고 응답함.
+
+2. 자투리 시간을 보내는 행태
+ ⇒ 유의미한 일을 하고 싶다고 생각하지만 막상 자투리 시간이 다가오면 습관적으로 핸드폰을 키거나 무의미하게 흘려보내게 된다고 답해주었음.
+
+3. 자투리 시간을 보내는 것에 대한 아쉬움
+ ⇒ 모든 응답자가 하고자 하는 일을 못하고 의미없이 릴스, SNS 등 내가 ‘목적’으로 하지 않은 일을 하게 될 때 아쉬움, 나에 대한 부정적인 감정, 회의감 등을 느끼게 됨.
+
+4. 자투리 시간에 대한 니즈
+ ⇒ 사용자마다 어떤 방식으로 자투리 시간을 보내고 싶은 지는 상이했음. 자기개발, 독서, 스트레칭, 일정 관리 등. 하고 싶은 행위는 모두 달랐지만 근본적으로 자신이 유의미하다고 여기는 가치 및 목적에 대한 행위를 통해 시간을 낭비하지 않고 알차게 보내기를 바람.
+
+ +) 유튜브/릴스가 이미 재미와 흥미를 제공하고 있지만 이에 대한 행위에 부정적 감정을 느끼는 이유는 ‘목적'이 없는, 나의 선택이 들어가지 않은 알고리즘에 의한 콘텐츠 소비이기에 의미가 없음.
+
+5. 서비스 기능 관련
+ ⇒ 자투리 시간을 활용할 수 있는 활동을 추천 받는다면 본인에게 그 행위가 얼마나 유의미하고 매력적으로 다가올 지 중요할 것 같다고 응답. 또한 ‘자투리 시간’인만큼 부담스럽고 제약이 있는 활동 보다는 가볍고 편안한 활동을 원한다고 응답함.
+
+## 서비스 목표 타겟 정의
+
+> 앞선 유저 리서치를 바탕으로, 자투리 시간에 대한 인식과 사용 니즈에 기반하여 서비스 목표 타겟을 정의하였습니다.
+
+1) 자투리 시간이 ’무엇을 하기에는 애매하게 남는 시간‘이라는 인식
+
+ → 무엇을 할 지 모르고 그냥 습관적으로 흘려보내게 되는 사람들이 대부분.
+
+ → 결국 나의 의지가 담기지 못한 채 ‘목적성’을 잃고 흘려보내기 때문에 유의미한 활용이 어려움.
+2) ‘자투리 시간’인 만큼 부담스럽고 거창한 활동보다 가볍고 접근성이 높은 활동에 대한 니즈 존재
+
+
+> ❗**예상치 못하게 발생하는 자투리 시간을 무의미하게 흘려보내지 않고**
+> **가볍고 다양한 활동을 통해 ‘나만의 시간’으로 만들어가고 싶은 사람들**
+
+
+
+## Persona & Journey Map
+
+> 유저리서치 내용 및 인사이트를 바탕으로 서비스를 사용하는 메인 페르소나를 도출하였습니다.
+>
+
+
+
+
+
+## 👩💻 서비스(Service)
+
+### 서비스 카테고리
+
+> AI 기반 자투리 시간 활용 방향 추천 플랫폼
+
+### 타겟특화 포인트
+
+> 👥 자투리 시간을 유의미하게 보내고 싶은 사람을 대상으로, 자투리 시간에 적합한 온라인/오프라인 활동을 `개인 맞춤형으로 추천`함으로써 자투리 시간을 `온전한 ‘내 시간’`으로 만들어갈 수 있도록 함
+
+### 사용자에게 제공하는 혜택
+
+- **`인식의 전환`** : 자투리 시간을 ‘무엇인가를 제대로 하기에 애매한 시간’이 아닌 ‘무엇이든 가볍게 시도해볼 수 있는 시간’으로 인식을 전환시킨다.
+- **`‘나의’`** : **추천 온보딩**을 통해 오로지 ‘나의’ 자투리 시간을 만들어 갈 수 있도록 한다.
+- **`부담없는`** : 부담 없이 일상 속에서 자투리 시간을 유의미하게 보낼 수 있는 **활동을 추천**해준다.
+- **`쌓아가는`** : 활용한 자투리 시간을 **아카이빙**을 통해 쌓아가며 효능감/성취감을 느낄 수 있도록 한다.
+
+### 서비스 플로우
+
+> **IA**
+
+
+> **전체 서비스 플로우**
+>
+
+
+
+> **기능별 세부 플로우**
+>
+
+**`로그인 플로우`**
+
+
+
+**`메인홈 - 추천 플로우`**
+
+
+
+**`아카이빙 플로우`**
+
+
+
+**`마이페이지 플로우`**
+
+
+
+### **서비스 포인트 (참신성, 차별성 등)**
+
+> **유사 서비스 분석**
+
+| 서비스명 | 서비스 유형 | 메인 기능 및 특성 | 사용 목적 |
+|-------------------|-------------------------|-------------------------------------------------------------------------------------------------------|---------------------------------------------------|
+| **오늘 뭐할지 GetGPT** | AI 추천 | ChatGPT형 AI 추천 서비스 - 하루 할 일 추천 GPT - 프롬프트 작성 필요 - 기본 질문 존재 - 아카이빙 불가능 | GPT와의 대화를 통해 하루 할 일 추천 가능 |
+| **투두메이트** | 시간 활용 지원 (일정 관리) | 할 일 및 목표 리스트 생성 - 우선순위 설정 - 일정 알림 기능 - 완료 체크를 통한 성취감 제공 | 일정 관리 및 효율적 시간 분배를 통한 생산성 향상 |
+| **마이루틴** | 시간 활용 지원 (루틴 관리) | 개인 맞춤형 루틴 설정 및 계획 - 알림을 통한 지속적 습관 형성 - 활동 기록 및 성과 시각화 | 습관 형성 및 시간 관리 개선을 통해 생산성 향상, 목표 달성 도움 |
+| **조각조각** | 시간 활용 지원 및 AI 활용 추천 | 자투리 시간 활용 방법 추천 - 추천 활동 유형/카테고리의 다양성 - 아카이빙을 통한 시간 사용 분석 및 효용성 향상 | 버려지는 자투리 시간을 유의미한 활동으로 전환 |
+
+
+> 💫 **참신성 & 차별성**
+>
+>- 자투리 시간에 대한 인식 변화를 통한 사회적 임팩트 부여
+ > - 무엇인가를 제대로 하기에 애매한 시간 → 무엇이든 가볍게 시도해볼 수 있는 시간
+>- 온라인 활동 추천
+ > - 사용자가 입력한 추천 온보딩 데이터를 기반으로 다양한 활동 추천
+> - 모든 사용자가 쉽게 접근할 수 있는 온라인 활동을 추천함으로써 시공간적 제약을 최소화하여 자투리 시간을 활용할 수 있도록 함
+>- 오프라인 추천
+ > - 사용자 위치를 기반으로 방문 장소까지 구체적인 추천
+> - 사용자가 일상 속에서 쉽게 지나쳤던 장소 혹은 숨겨진 장소들을 재발견할 수 있도록 함으로써 지역 사회를 활성화
+
+### 핵심 기능
+
+| **기능** | **설명** |
+|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **추천 온보딩** | 활동 추천을 위해 진행하는 온보딩으로, 사용자가 현재 상황에 따라 올바른 추천을 받을 수 있도록 합니다. - **시간 선택**: 현재 사용할 수 있는 자투리 시간을 선택합니다. (10분 이상) - **온라인/오프라인 선택**: 현재 사용자가 위치하고 있는 공간이 실내인지 실외인지 선택합니다. - **활동 키워드 선택**: 사용자가 선호하는 활동의 형태를 선택합니다. |
+| **활동 추천** | 앞선 온보딩을 기반으로 사용자에게 맞는 활동을 추천합니다. - **온라인 활동**: 사용자의 휴대폰을 통해 할 수 있는 온라인 활동 추천 - **오프라인 활동**: 사용자의 현재 위치를 기반으로 할 수 있는 오프라인 활동 추천
사용자는 각 주제 별로 추천되는 5개의 활동 중 원하는 활동을 선택합니다. 만약 오프라인 활동에서 특정 장소 선택 시, 해당 장소의 위치 링크를 연결합니다. |
+| **아카이빙** | 사용자가 해당 활동을 끝내고 나면, 해당 기록이 저장됩니다. 저장된 기록은 트리맵 형태의 ‘활동 키워드’와 캘린더 형태의 ‘활동 캘린더’로 확인할 수 있습니다. |
+
+
+
+### 서비스 비즈니스 모델
+오프라인 추천 시 매장과 제휴
+
+제휴 매장을 오프라인 추천해줌으로써 비즈니스 모델 구현한다.
+
+1. 오프라인 매장과의 제휴 체결한다.
+2. 해당 매장이 사용자의 온보딩 정보와 부합하는 경우, 오프라인 활동에서 추천한다.
+
+> 💵 **수익구조**
+> 매장 → 조각조각 : 제휴 광고 수수료 제공
+> 조각조각 → 사용자 : 제휴 매장의 광고를 맞춤형으로 제공
+
+> 📌 **기대효과**
+> 매장 : 사용자 데이터를 바탕으로 한 개인화 된 추천을 통해 맞춤형 광고 가능
+> 사용자 : 현재 접근 가능한 맞춤형 매장을 추천 받을 수 있음
+
+
+
+
+
+
+## 🎨 디자인 (Design)
+
+### 로고 및 디자인 시스템
+
+> **디자인 컨셉**
+
+> ⏰
+> **조각조각의 디자인 컨셉**
+> 조각조각은 우리가 일상 속에서 모을 수 있는 자투리 시간을 **‘시간 조각’** 이라고 정의합니다.
+> 조각조각은 우리가 평소에 흘려보내던 일상 속의 자투리 시간을 색다르게 활용하게 함으로써
+> ‘시간 조각’을 모으는 경험을 제공합니다. 조각조각 내에서의 시간 조각은 엔터테인먼트, 소셜, 건강,
+> 자기개발, 문화 / 예술, 휴식 크게 6개 종류로 나뉩니다. 각각의 조각들은 분야에 맞게 형상화 되어있습니다. (ex: 휴식은 머그컵이라는 오브제로 형상화)
+>
+> **형상화된 시간 조각을 찾고, 모으고, 쌓고, 보면서 조금 더 색다르고 의미있는 시간을 찾길 도와줍니다.**
+
+
+
+
+
+
+
+> ❔**왜 도트에서 clay 질감의 3d 그래픽으로 전환하게 되었나요?**
+>
+> 기존 도트 그래픽이 가진 문제점을 해결하고자 했습니다.
+> 1. 사각형, 각진 그래픽으로 인해 동적인 느낌이 부족해 보인다는 점
+> 2. 단순히 도트 그래픽으로는 서비스 GUI를 완성도 있게 보여줄 수 없다는 한계점
+> 3. 도트 그래픽과 UI 컴포넌트의 조합 및 비중 설정을 잘못하게 될 시 서비스가 어수선하게 보일 수 있다는 리스크
+> 4. 초기 기획했던 가볍고 마치 간식을 꺼내 먹는 듯한 느낌을 추구하는 서비스 무드보다는 게임/게이미피케이션 서비스 무드에 가깝게 느껴지는 문제점
+>
+> → **서비스 무드와 콘텐츠 성격, 높은 디자인적 완성도 측면을 고려하여** 그래픽 디자인을 변경하였습니다.
+
+> ❔**그렇다면 왜 clay 질감의 3d 그래픽인가요?**
+>
+> UI 요소나 컨텐츠가 많이 들어가지 않는 조각조각의 서비스 특성상 그래픽을 좀 더 다이나믹하게 보여줄 수 있는 그래픽이 필요하다고 판단했습니다.
+> 단순 도트 그래픽으로는 화면 요소를 입체감 있게, 그리고 동적으로 보이게 구성하기가 어렵기 때문에 대안으로 다양한 3d 그래픽을 시도하게 되었습니다.
+> 여러 대안 중 질감이 덜 들어가는 clay 질감의 그래픽이 가장 가벼운 느낌을 주었고, 서비스 무드와 매치하는 요소로 판단하여 clay 질감을 활용하게 되었습니다.
+
+
+### 화면 디자인
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# 💻 개발(Development)
+
+> **Github URL:** [https://github.com/KUSITMS-30th-TEAM-C](https://github.com/KUSITMS-30th-TEAM-C)
+>
+> **배포 URL:** [https://cnergy.kro.kr/](https://cnergy.kro.kr/)
+
+## 개발 환경과 사용 기술 스택
+
+### ❇️ 프론트엔드 기술 스택 및 선정 이유
+
+- **Next.js 14**:
+ - **서버 사이드 렌더링(SSR)** 및 **정적 사이트 생성(SSG)** 지원으로 SEO 향상과 빠른 페이지 로딩 제공.
+ - **Full-stack 기능** 제공으로 API 라우팅과 프론트엔드를 통합할 수 있어 효율적인 개발 가능.
+ - **Edge Functions**와 같은 최신 성능 최적화 기능을 활용할 수 있음.
+- **React 18**:
+ - **Concurrent Mode**를 통해 성능을 향상하고, 대규모 애플리케이션에서 더욱 부드러운 UI 렌더링.
+ - 최신 **Hook 기반 API**로 코드 간결성 및 상태 관리 향상.
+ - 커뮤니티와 생태계가 매우 크고, 다양한 서드파티 라이브러리와의 호환성이 좋음.
+- **Tailwind CSS**:
+ - **유틸리티 우선 CSS 프레임워크**로, 빠르게 일관된 스타일링 가능.
+ - 클래스 단위로 CSS를 관리하기 때문에 유지보수성이 높고, 불필요한 CSS 코드 생성을 최소화.
+ - 커스텀 디자인을 쉽게 적용하면서도 **반응형 디자인**을 유연하게 구현할 수 있음.
+- **eslint - airbnb**:
+ - **코드 스타일 일관성**을 유지하고, **버그를 예방**하기 위해 AirBnB 스타일 가이드를 활용한 Linting 도구.
+ - 코드 품질 향상과 팀 간 협업 시 일관된 코딩 스타일을 유지.
+- **Prettier**:
+ - **자동 코드 포매팅**을 통해 코드 스타일을 일관되게 유지.
+ - 개발자의 생산성 향상과 코드 리뷰 시 시각적 노이즈를 줄일 수 있음.
+- **Storybook**:
+ - **UI 컴포넌트 개발 및 테스트 도구**로, 컴포넌트 단위 개발을 효율적으로 진행.
+ - **디자인 시스템**을 관리하고, 컴포넌트 간의 재사용성을 높임.
+- **TanStack Query & ContextAPI**:
+ - 효율적인 비동기 처리를 위해 사용
+- **CI/CD (GitHub Actions, NCP, Docker)**:
+ - **GitHub Actions**: 코드 변경 시 자동화된 빌드, 테스트, 배포 파이프라인을 설정하여 개발 프로세스의 효율성 증대.
+ - **NCP (Naver Cloud Platform)**: 안정적인 인프라 제공 및 한국 지역 기반 서비스 운영 시 유리.
+ - **Docker**: 개발 환경을 컨테이너화하여 일관성 있는 환경에서 애플리케이션 배포 가능.
+- **Zustand**:
+ - **가벼운 상태 관리 라이브러리**로, Redux보다 간단하고 코드가 간결해지며, 리액티브한 글로벌 상태 관리에 적합.
+
+### ❇️ 백엔드 기술 스택 및 선정 이유
+
+- **Java 17**
+ - LTS(Long-Term Support) 버전으로 2029년 9월까지 지원합니다.
+ - 최신 LTS 버전인 JDK21을 바로 사용하는 것보다 그 전 LTS 버전인 JDK 17을 사용하여 추후 JDK 21로 마이그레이션시 영향을 줄일 수 있기 때문입니다.
+ - Spring Boot 3.0부터는 JDK 17 이상부터 지원하므로 JDK17을 사용하였습니다.
+- **Spring Boot 3.3.4**
+ - 이번 프로젝트에서는 WebSocket을 이용한 채팅 기능을 서비스에 도입하지는 않았지만, Spring Boot 3.3 버전에서 제공하는 WebSocket의 가상 스레드 지원을 활용하여 동시성을 효율적으로 처리할 수 있는 점을 고려해 사용했습니다.
+- **Spring Data JPA**
+ - SQL을 직접 작성하지 않고 객체지향적인 코드로 데이터베이스를 다루기 위해서 JPA를 사용하고, Spring 프레임워크에서 JPA를 쉽게 사용할 수 있는 모듈인 Spring Data JPA를 지원하기 때문에 사용하였습니다.
+- **MySQL 8.x**
+ - 5 버전에 비해 향상된 성능, 강화된 보안 기능, 공간 데이터 처리 등을 사용하기 위해 사용했습니다.
+- **Docker**
+ - 개발 및 배포 환경을 쉽게 컨테이너화하기 위해 사용했습니다.
+- **Github Actions**
+ - Github와 하나로 통일된 환경에서 CI를 수행하기 위해 사용했습니다.
+- **Naver Cloud Platform**
+ - 한국에서 만든 클라우드 서비스로 서버, AI 등 프로젝트에 필요한 서비스를 사용했습니다.
+- **Sentry**
+ - 에러 로그 모니터링과 중앙집중적 에러 로그 관리를 위해 사용했습니다.
+- **Swagger**
+ - 클라이언트, 서버 간 API 명세서 용도로 사용했습니다.
+- **OpenAI**
+ - OpenAI는 다양한 온라인 활동 추천을 제공하는 데 강점이 있습니다. 이를 통해 사용자의 관심사에 맞는 폭넓고 창의적인 추천을 만들어 더 다양한 활동 선택지를 제공할 수 있어 사용하게 되었습니다.
+- **RabbitMQ**
+ - RabbitMQ의 “Delayed Message Exchange Plugin”을 활용하여, 종료 로직을 특정 시간만큼 지연된 메시지로 전달하는 방식으로 구현했습니다.
+ - 비동기적으로 종료 작업을 처리하여 주요 트랜잭션과 분리할 수 있는 장점이 있어 사용했습니다.
+
+## 🟩 NCP 사용 스택
+
+- **Server**
+ - 백엔드 배포 서버로 사용했습니다.
+ - 프론트엔드 배포 서버로 사용했습니다.
+- **VPC**
+ - 클라우드 내 전용 네트워크를 확보하기 위해 VPC를 사용했습니다.
+ - 현재 프론트엔드와 백엔드가 하나의 VPC내 다른 Subnet으로 구성되어있습니다.
+ - 추후 백엔드 Subnet을 Private으로 설정해 외부접근을 제한하고, 프론트엔드 서버를 통해서만 접근할 수 있도록해 보안을 강화할 예정입니다.
+- **Container Registry**
+ - 백엔드 및 프론트엔드 모두 Docker를 사용해 배포를 진행합니다. Public Registry에 이미지를 저장할 경우, 민감한 정보들을 관리하기 어렵기에 사설 Registry인 NCP Container Registry를 사용했습니다.
+ - Container Registry 의 경우 레지스트리에 등록된 이미지의 취약점을 분석해 정보를 제공해줍니다. 이를 통해 컨테이너의 취약점을 제거해 컨테이너를 더욱 안전하게 사용할 수 있습니다.
+- **Naver Clova Studio**
+ - 한국 사람들이 가장 많이 사용하는 검색엔진의 데이터를 활용하여 개발되었기 때문에, 한국 사용자에게 활동을 추천하는 프로젝트에 적합하다 판단해 사용합니다.
+ - 사용자에게 현재 상황과 취향을 입력받아 활동 추천 시 Clova AI를 사용합니다.
+ - 모델을 튜닝하여 사용자에게 다양한 활동을 추천합니다.
+- **NAVER Object Storage**
+ - 오브젝트 스토리지 버킷을 NCP CDN과 연결하여 CDN 서버에서 빠르고 효율적으로 이미지 제공
+
+
+## 🏛️ 시스템 아키텍처
+
+
+
+## 🧱 ERD
+
+
+
+## 🌊 개발부터 배포까지의 워크플로우
+
+> **1. 개발 단계**
+>
+- Git을 사용한 버전 관리
+- 브랜치 전략 적용 (Git-flow)
+- Pull Request를 통한 코드 리뷰
+ - reviewer의 approve 없이 dev 브랜치에 push 불가
+
+> **2. CI (지속적 통합) 구축**
+>
+- GitHub Actions 사용
+- 자동 빌드 및 테스트 실행
+- Docker 이미지 빌드 및 Registry 푸시
+
+> **3. 배포 준비**
+>
+- Docker Compose 를 통해 배포(SpringBoot, Redis, Rabbit MQ)
+
+> **4. 배포 프로세스**
+>
+1. CI/CD 파이프라인에서 새 Docker 이미지 빌드 및 푸시
+2. 운영 서버에서 최신 이미지 pull
+3. Docker Compose로 서비스 업데이트
+
+> **5. 모니터링 및 로깅 (Sentry 중심)**
+>
+- Sentry 대시보드 모니터링
+- 실시간 에러 추적
+- Grafana와 Prometheus를 활용한 서버 모니터링
+
+## 💻 프론트 - 비동기 데이터 페칭 최적화 및 이미지 로딩 성능 향상
+
+### **AsyncBoundary+tanstack-query를 이용한 데이터 페칭**
+
+- tanstack-query를 이용한 데이터 캐싱
+- SSR환경에서 사용 가능한 Suspense 구현
+- AsyncBoundary를 통한 비동기 데이터페칭 에러/로딩 상태 선언적 관리
+- AsyncBoundaryWithQuery 구현 : tanstack-query의 useQueryErrorResetBoundary를 활용하여 데이터 페칭 중 오류 발생 시 데이터 리페치
+- querykey가 바뀔때마다 컴포넌트 깜빡임(fallback 노출) 이슈
+ - useTransition 훅을 활용하여 상태 업데이트를 UI 렌더링보다 지연시킴으로써 데이터 페칭 중 발생하는 깜빡임을 방지
+
+### **NCP CDN과 Next.js Image태그를 이용한 빠른 이미지 로딩**
+
+- NCP 오브젝트 스토리지와 연동하여 정적 리소스의 캐싱 및 전달을 효율적으로 수행
+- **자동 최적화**: 브라우저와 디바이스에 맞는 해상도와 크기로 이미지를 자동 변환
+- **레이아웃 시프트 방지**: 이미지의 고정된 크기를 제공하여 CLS(Cumulative Layout Shift) 이슈 방지
+
+## 💻 백엔드 - 아키텍처 설계: 도메인 주도 설계(DDD) 적용
+
+- 도메인을 중심으로 아키텍처를 설계하여 비즈니스 로직을 명확하게 분리하였습니다.
+- 표현, 응용, 도메인, 인프라스트럭처 계층으로 분리하여 각 계층의 책임을 명확히 정의하고, 역할에 맞는 책임 분담을 실현하였습니다.
+- 의존 역전 원칙(DIP)을 적용하여 상위 계층이 하위 계층에 의존하지 않도록 하여, 계층 간의 결합도를 최소화하고 유연하고 확장 가능한 구조를 구현하였습니다.
+- 외부 모듈의 영향을 최소화하기 위해 도메인 모델에 JPA를 직접 결합하지 않고, POJO 객체를 사용하여 도메인의 순수성을 유지하였습니다. JPA 엔티티는 인프라 계층에서만 관리하여 도메인과 인프라를 명확하게 분리하였습니다.
+- 조회는 DAO를 활용하여 CQRS 패턴을 적용, 데이터 조회와 변경을 명확히 분리하여 각 책임을 독립적으로 처리하고 성능과 유지보수성을 향상시켰습니다.
diff --git a/build.gradle b/build.gradle
index 5a629f1f7..086298cdc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -23,17 +23,15 @@ 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'
- developmentOnly 'org.springframework.boot:spring-boot-devtools'
- annotationProcessor 'org.projectlombok:lombok'
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
- testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
-}
+apply from: "gradle/db.gradle"
+apply from: "gradle/email.gradle"
+apply from: "gradle/jwt.gradle"
+apply from: "gradle/lombok.gradle"
+apply from: "gradle/monitor.gradle"
+apply from: "gradle/rabbitmq.gradle"
+apply from: "gradle/spring.gradle"
+apply from: "gradle/swagger.gradle"
+apply from: "gradle/test.gradle"
+apply from: "gradle/webflux.gradle"
+
-tasks.named('test') {
- useJUnitPlatform()
-}
diff --git a/docker-compose-local.yml b/docker-compose-local.yml
new file mode 100644
index 000000000..7321597fd
--- /dev/null
+++ b/docker-compose-local.yml
@@ -0,0 +1,45 @@
+services:
+ cnergy-backend:
+ build:
+ context: .
+ dockerfile: Dockerfile-local
+ ports:
+ - '8080:8080'
+ environment:
+ SPRING_PROFILES_ACTIVE: local
+ depends_on:
+ - redis
+ networks:
+ - cnergy-backend-network
+
+ redis:
+ image: redis:6.0.9
+ ports:
+ - '6379:6379'
+ volumes:
+ - redis-data:/data
+ networks:
+ - cnergy-backend-network
+
+ rabbit:
+ container_name: cnergy-rabbitmq
+ hostname: cnergy-rabbit
+ image: heidiks/rabbitmq-delayed-message-exchange:4.0.2-management
+ environment:
+ - RABBITMQ_DEFAULT_USER=admin
+ - RABBITMQ_DEFAULT_PASS=password
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ volumes:
+ - rabbitmq-data:/var/lib/rabbitmq
+ networks:
+ - cnergy-backend-network
+
+volumes:
+ redis-data:
+ rabbitmq-data:
+
+networks:
+ cnergy-backend-network:
+ driver: bridge
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..0afbdf0d4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,63 @@
+services:
+ blue:
+ image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}"
+ container_name: cnergy-backend-blue
+ env_file:
+ - .env
+ environment:
+ TZ: Asia/Seoul
+ ports:
+ - '8080:8080'
+ depends_on:
+ - redis
+ networks:
+ - cnergy-backend-network
+
+ green:
+ image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}"
+ container_name: cnergy-backend-green
+ env_file:
+ - .env
+ environment:
+ TZ: Asia/Seoul
+ ports:
+ - '8081:8080'
+ depends_on:
+ - redis
+ networks:
+ - cnergy-backend-network
+
+ redis:
+ image: redis:6.0.9
+ container_name: redis
+ ports:
+ - '6379:6379'
+ volumes:
+ - redis-data:/data
+ networks:
+ - cnergy-backend-network
+
+ rabbit:
+ container_name: cnergy-rabbitmq
+ hostname: cnergy-rabbit
+ image: heidiks/rabbitmq-delayed-message-exchange:4.0.2-management
+ environment:
+ - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER}
+ - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS}
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ env_file:
+ - .env
+ volumes:
+ - rabbitmq-data:/var/lib/rabbitmq
+ networks:
+ - cnergy-backend-network
+
+volumes:
+ redis-data:
+ rabbitmq-data:
+
+networks:
+ cnergy-backend-network:
+ driver: bridge
diff --git a/gradle/db.gradle b/gradle/db.gradle
new file mode 100644
index 000000000..d644a3d38
--- /dev/null
+++ b/gradle/db.gradle
@@ -0,0 +1,11 @@
+dependencies {
+ // DB, JPA
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'com.maxmind.geoip2:geoip2:4.1.0'
+
+ testImplementation 'com.h2database:h2'
+
+ // Redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+}
diff --git a/gradle/email.gradle b/gradle/email.gradle
new file mode 100644
index 000000000..8445cba75
--- /dev/null
+++ b/gradle/email.gradle
@@ -0,0 +1,4 @@
+dependencies {
+ // Email
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+}
diff --git a/gradle/jwt.gradle b/gradle/jwt.gradle
new file mode 100644
index 000000000..60dfb9bf9
--- /dev/null
+++ b/gradle/jwt.gradle
@@ -0,0 +1,6 @@
+dependencies {
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
+}
diff --git a/gradle/lombok.gradle b/gradle/lombok.gradle
new file mode 100644
index 000000000..1b976a52f
--- /dev/null
+++ b/gradle/lombok.gradle
@@ -0,0 +1,5 @@
+dependencies {
+ // Lombok (code simplification)
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+}
diff --git a/gradle/monitor.gradle b/gradle/monitor.gradle
new file mode 100644
index 000000000..d9592baab
--- /dev/null
+++ b/gradle/monitor.gradle
@@ -0,0 +1,5 @@
+dependencies {
+ // monitoring (Prometheus)
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
+}
diff --git a/gradle/rabbitmq.gradle b/gradle/rabbitmq.gradle
new file mode 100644
index 000000000..f42c6e728
--- /dev/null
+++ b/gradle/rabbitmq.gradle
@@ -0,0 +1,5 @@
+dependencies {
+ // Rabbit MQ
+ implementation 'org.springframework.boot:spring-boot-starter-amqp'
+ testImplementation 'org.springframework.amqp:spring-rabbit-test'
+}
diff --git a/gradle/spring.gradle b/gradle/spring.gradle
new file mode 100644
index 000000000..ed595f552
--- /dev/null
+++ b/gradle/spring.gradle
@@ -0,0 +1,25 @@
+dependencies {
+ // WEB
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+
+ // Development tools (for local development only)
+ developmentOnly 'org.springframework.boot:spring-boot-devtools'
+
+ // Spring Batch
+ implementation 'org.springframework.boot:spring-boot-starter-batch'
+ testImplementation 'org.springframework.batch:spring-batch-test'
+
+ // Thymeleaf
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+
+ // Validation (for request/response validation)
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ // WebSocket (for real-time communication)
+ implementation 'org.springframework.boot:spring-boot-starter-websocket'
+
+ // Sentry
+ implementation 'io.sentry:sentry-spring-boot-starter-jakarta:7.17.0'
+
+
+}
diff --git a/gradle/swagger.gradle b/gradle/swagger.gradle
new file mode 100644
index 000000000..bf3507d37
--- /dev/null
+++ b/gradle/swagger.gradle
@@ -0,0 +1,4 @@
+dependencies {
+ // Swagger-UI
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
+}
diff --git a/gradle/test.gradle b/gradle/test.gradle
new file mode 100644
index 000000000..bc787725e
--- /dev/null
+++ b/gradle/test.gradle
@@ -0,0 +1,10 @@
+dependencies {
+ // Testing
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/gradle/webflux.gradle b/gradle/webflux.gradle
new file mode 100644
index 000000000..261eda978
--- /dev/null
+++ b/gradle/webflux.gradle
@@ -0,0 +1,7 @@
+dependencies {
+ // WebFlux 외부 API 호출
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+
+ // Netty
+ implementation "io.netty:netty-resolver-dns-native-macos:4.1.113.Final:osx-aarch_64"
+}
diff --git a/src/main/java/spring/backend/BackendApplication.java b/src/main/java/spring/backend/BackendApplication.java
index 5b9288112..698b1e56f 100644
--- a/src/main/java/spring/backend/BackendApplication.java
+++ b/src/main/java/spring/backend/BackendApplication.java
@@ -2,7 +2,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableScheduling;
+@EnableScheduling
+@EnableJpaAuditing
@SpringBootApplication
public class BackendApplication {
diff --git a/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java b/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java
new file mode 100644
index 000000000..069d1de86
--- /dev/null
+++ b/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java
@@ -0,0 +1,48 @@
+package spring.backend.activity.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.repository.ActivityRepository;
+import spring.backend.quickstart.domain.repository.QuickStartRepository;
+import spring.backend.activity.domain.service.FinishActivityAutoService;
+import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.quickstart.exception.QuickStartErrorCode;
+import spring.backend.member.domain.entity.Member;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+@Transactional
+public class QuickStartActivitySelectService {
+ private final ActivityRepository activityRepository;
+ private final QuickStartRepository quickStartRepository;
+ private final FinishActivityAutoService finishActivityAutoService;
+
+ public QuickStartActivitySelectResponse quickStartUserActivitySelect(Member member, Long quickStartId, QuickStartActivitySelectRequest quickStartActivitySelectRequest) {
+ validateQuickStart(quickStartId);
+ validateRequest(quickStartActivitySelectRequest);
+ Activity activity = Activity.create(member.getId(), quickStartId, quickStartActivitySelectRequest.spareTime(), quickStartActivitySelectRequest.type(), quickStartActivitySelectRequest.keyword(), quickStartActivitySelectRequest.title(), quickStartActivitySelectRequest.content(), quickStartActivitySelectRequest.location());
+ Activity savedActivity = activityRepository.save(activity);
+ finishActivityAutoService.finishActivityAuto(savedActivity);
+ return new QuickStartActivitySelectResponse(savedActivity.getId(), savedActivity.getTitle(), savedActivity.getKeyword());
+ }
+
+ private void validateQuickStart(Long quickStartId) {
+ if (quickStartRepository.findById(quickStartId) == null) {
+ log.error("[QuickStartActivitySelectRequest] Invalid quickStartId.");
+ throw QuickStartErrorCode.NOT_EXIST_QUICK_START.toException();
+ }
+ }
+
+ private void validateRequest(QuickStartActivitySelectRequest quickStartActivitySelectRequest) {
+ if (quickStartActivitySelectRequest == null) {
+ log.error("[QuickStartActivitySelectRequest] Invalid request.");
+ throw ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java b/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java
new file mode 100644
index 000000000..7f9055773
--- /dev/null
+++ b/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java
@@ -0,0 +1,40 @@
+package spring.backend.activity.application;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse;
+import spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse;
+import spring.backend.activity.dto.response.TotalSavedTimeAndActivityCountByKeywordInMonth;
+import spring.backend.activity.query.dao.ActivityDao;
+import spring.backend.core.converter.ImageConverter;
+import spring.backend.core.util.TimeUtil;
+import spring.backend.member.domain.entity.Member;
+
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ReadActivitiesByMemberAndKeywordInMonthService {
+ private final ActivityDao activityDao;
+ private final ImageConverter imageConverter;
+
+ public ActivitiesByMemberAndKeywordInMonthResponse readActivitiesByMemberAndKeywordInMonth(Member member, int year, int month, Keyword.Category keywordCategory) {
+ YearMonth yearMonth = YearMonth.of(year, month);
+ LocalDateTime firstDayOfMonth = TimeUtil.toStartDayOfMonth(yearMonth);
+ LocalDateTime endDayOfMonth = TimeUtil.toEndDayOfMonth(yearMonth);
+ List activities = activityDao.findActivitiesByMemberAndKeywordInMonth(member.getId(), firstDayOfMonth, endDayOfMonth, keywordCategory);
+ TotalSavedTimeAndActivityCountByKeywordInMonth totalSavedTimeAndActivityCountByKeywordInMonth = activityDao.findTotalSavedTimeAndActivityCountByKeywordInMonth(member.getId(), firstDayOfMonth, endDayOfMonth, keywordCategory);
+ Keyword keyword = Keyword.create(keywordCategory, imageConverter.convertToImageUrl(keywordCategory));
+ return new ActivitiesByMemberAndKeywordInMonthResponse(
+ totalSavedTimeAndActivityCountByKeywordInMonth.totalSavedTimeByKeywordInMonth(),
+ totalSavedTimeAndActivityCountByKeywordInMonth.totalActivityCountByKeywordInMonth(),
+ activities,
+ keyword
+ );
+ }
+}
diff --git a/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java b/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java
new file mode 100644
index 000000000..55e92449e
--- /dev/null
+++ b/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java
@@ -0,0 +1,48 @@
+package spring.backend.activity.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.domain.value.Keyword.Category;
+import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest;
+import spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse;
+import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse;
+import spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse;
+import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue;
+import spring.backend.activity.query.dao.ActivityDao;
+import spring.backend.core.converter.ImageConverter;
+import spring.backend.core.util.TimeUtil;
+import spring.backend.member.domain.entity.Member;
+
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+@Transactional(readOnly = true)
+public class ReadMonthlyActivityOverviewService {
+
+ private final ActivityDao activityDao;
+
+ private final ImageConverter imageConverter;
+
+ public MonthlyActivityOverviewResponse readMonthlyActivityOverview(Member member, MonthlyActivityOverviewRequest monthlyActivityOverviewRequest) {
+ YearMonth yearMonth = YearMonth.of(monthlyActivityOverviewRequest.year(), monthlyActivityOverviewRequest.month());
+ LocalDateTime startDayOfMonth = TimeUtil.toStartDayOfMonth(yearMonth);
+ LocalDateTime endDayOfMonth = TimeUtil.toEndDayOfMonth(yearMonth);
+ MonthlySavedTimeAndActivityCountResponse monthlySavedTimeAndActivityCountResponse = activityDao.findMonthlyTotalSavedTimeAndTotalCount(member.getId(), startDayOfMonth, endDayOfMonth);
+ List activityByKeywordSummaryResponses = activityDao.findMonthlyActivitiesByKeywordSummary(member.getId(), startDayOfMonth, endDayOfMonth);
+ List updatedActivityByKeywordSummaryResponses = activityByKeywordSummaryResponses.stream()
+ .map(response -> {
+ Category category = response.keyword().getCategory();
+ String imageUrl = imageConverter.convertToTransparent30ImageUrl(category);
+ KeywordJpaValue updatedKeyword = KeywordJpaValue.create(category, imageUrl);
+ return new MonthlyActivityCountByKeywordResponse(updatedKeyword, response.activityCount());
+ })
+ .toList();
+ return new MonthlyActivityOverviewResponse(member.getUpdatedAt().getYear(), member.getUpdatedAt().getMonth(), monthlySavedTimeAndActivityCountResponse, updatedActivityByKeywordSummaryResponses);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/application/UserActivitySelectService.java b/src/main/java/spring/backend/activity/application/UserActivitySelectService.java
new file mode 100644
index 000000000..b018220af
--- /dev/null
+++ b/src/main/java/spring/backend/activity/application/UserActivitySelectService.java
@@ -0,0 +1,39 @@
+package spring.backend.activity.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.repository.ActivityRepository;
+import spring.backend.activity.domain.service.FinishActivityAutoService;
+import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.member.domain.entity.Member;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+@Transactional
+public class UserActivitySelectService {
+
+ private final ActivityRepository activityRepository;
+
+ private final FinishActivityAutoService finishActivityAutoService;
+
+ public UserActivitySelectResponse userActivitySelect(Member member, UserActivitySelectRequest userActivitySelectRequest) {
+ validateRequest(userActivitySelectRequest);
+ Activity activity = Activity.create(member.getId(), null, userActivitySelectRequest.spareTime(), userActivitySelectRequest.type(), userActivitySelectRequest.keyword(), userActivitySelectRequest.title(), userActivitySelectRequest.content(), userActivitySelectRequest.location());
+ Activity savedActivity = activityRepository.save(activity);
+ finishActivityAutoService.finishActivityAuto(savedActivity);
+ return new UserActivitySelectResponse(savedActivity.getId(), savedActivity.getTitle(), savedActivity.getKeyword());
+ }
+
+ private void validateRequest(UserActivitySelectRequest userActivitySelectRequest) {
+ if (userActivitySelectRequest == null) {
+ log.error("[UserActivitySelectRequest] Invalid request.");
+ throw ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/activity/domain/entity/Activity.java b/src/main/java/spring/backend/activity/domain/entity/Activity.java
new file mode 100644
index 000000000..0e063fcf3
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/entity/Activity.java
@@ -0,0 +1,89 @@
+package spring.backend.activity.domain.entity;
+
+import lombok.Builder;
+import lombok.Getter;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.domain.value.Type;
+import spring.backend.activity.exception.ActivityErrorCode;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.UUID;
+
+@Getter
+@Builder
+public class Activity {
+
+ public static final Integer MAX_SAVED_TIME = 300;
+
+ private Long id;
+
+ private UUID memberId;
+
+ private Long quickStartId;
+
+ private Integer spareTime;
+
+ private Type type;
+
+ private Keyword keyword;
+
+ private String title;
+
+ private String content;
+
+ private String location;
+
+ @Builder.Default
+ private Boolean finished = false;
+
+ private LocalDateTime finishedAt;
+
+ private Integer savedTime;
+
+ private LocalDateTime createdAt;
+
+ private LocalDateTime updatedAt;
+
+ private Boolean deleted;
+
+ public void validateActivityOwner(UUID memberId) {
+ if (!this.memberId.equals(memberId)) {
+ throw ActivityErrorCode.MEMBER_ID_MISMATCH.toException();
+ }
+ }
+
+ public boolean isFinished() {
+ return finished != null && finished;
+ }
+
+ public void finish() {
+ if (isFinished()) {
+ throw ActivityErrorCode.ALREADY_FINISHED_ACTIVITY.toException();
+ }
+ finished = true;
+ finishedAt = LocalDateTime.now();
+ savedTime = calculateSavedTime();
+ }
+
+ private Integer calculateSavedTime() {
+ long savedTime = ChronoUnit.MINUTES.between(createdAt, finishedAt);
+ if (savedTime < 0 || savedTime > MAX_SAVED_TIME) {
+ throw ActivityErrorCode.INVALID_ACTIVITY_DURATION.toException();
+ }
+ return Math.toIntExact(savedTime);
+ }
+
+ public static Activity create(UUID memberId, Long quickStartId, Integer spareTime, Type type, Keyword keyword, String title, String content, String location) {
+ return Activity.builder()
+ .memberId(memberId)
+ .quickStartId(quickStartId)
+ .spareTime(spareTime)
+ .type(type)
+ .keyword(keyword)
+ .title(title)
+ .content(content)
+ .location(location)
+ .build();
+ }
+}
diff --git a/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java b/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java
new file mode 100644
index 000000000..3226141d9
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java
@@ -0,0 +1,9 @@
+package spring.backend.activity.domain.repository;
+
+import spring.backend.activity.domain.entity.Activity;
+
+public interface ActivityRepository {
+
+ Activity findById(Long id);
+ Activity save(Activity Activity);
+}
diff --git a/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java b/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java
new file mode 100644
index 000000000..08c0572a8
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java
@@ -0,0 +1,28 @@
+package spring.backend.activity.domain.service;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.infrastructure.queue.FinishActivityMessage;
+import spring.backend.activity.infrastructure.queue.FinishActivityMessageProducer;
+import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty;
+
+@Service
+@RequiredArgsConstructor
+public class FinishActivityAutoService {
+
+ private final FinishActivityQueueProperty finishActivityQueueProperty;
+
+ public void finishActivityAuto(Activity activity) {
+ int spareTime = activity.getSpareTime();
+ FinishActivityMessageProducer finishActivityMessageProducer = new FinishActivityMessageProducer(
+ finishActivityQueueProperty,
+ FinishActivityMessage.builder()
+ .activityId(activity.getId())
+ .spareTime(spareTime)
+ .build()
+ );
+ long delayTime = (long) spareTime * 60 * 1000;
+ finishActivityMessageProducer.publishMessageWithDelay(delayTime);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java b/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java
new file mode 100644
index 000000000..18ce23de5
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java
@@ -0,0 +1,44 @@
+package spring.backend.activity.domain.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.repository.ActivityRepository;
+import spring.backend.activity.dto.response.ActivityInfo;
+import spring.backend.activity.presentation.dto.response.FinishActivityResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.dto.response.HomeMemberInfoResponse;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+@Transactional
+public class FinishActivityService {
+
+ private final ActivityRepository activityRepository;
+
+ public FinishActivityResponse finishActivity(Member member, Long activityId) {
+ Activity activity = activityRepository.findById(activityId);
+ validateActivity(activity, member);
+ activity.finish();
+ activityRepository.save(activity);
+ return toResponse(member, activity);
+ }
+
+ private void validateActivity(Activity activity, Member member) {
+ if (activity == null) {
+ log.error("[FinishActivityService.validateActivity] activity is null");
+ throw ActivityErrorCode.NOT_EXIST_ACTIVITY.toException();
+ }
+ activity.validateActivityOwner(member.getId());
+ }
+
+ private FinishActivityResponse toResponse(Member member, Activity activity) {
+ HomeMemberInfoResponse memberInfo = HomeMemberInfoResponse.from(member);
+ ActivityInfo activityInfo = ActivityInfo.from(activity);
+ return new FinishActivityResponse(memberInfo, activityInfo);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/domain/value/Keyword.java b/src/main/java/spring/backend/activity/domain/value/Keyword.java
new file mode 100644
index 000000000..72f9bf248
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/value/Keyword.java
@@ -0,0 +1,44 @@
+package spring.backend.activity.domain.value;
+
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+@EqualsAndHashCode
+public class Keyword {
+
+ private final Category category;
+
+ private final String image;
+
+ @Getter
+ @RequiredArgsConstructor
+ public enum Category {
+ SELF_DEVELOPMENT("자기개발"),
+ HEALTH("건강"),
+ CULTURE_ART("문화/예술"),
+ ENTERTAINMENT("엔터테인먼트"),
+ RELAXATION("휴식"),
+ SOCIAL("소셜");
+
+ private final String description;
+
+ private static final Map DESCRIPTION_TO_CATEGORY_MAP = Arrays.stream(Category.values())
+ .collect(Collectors.toMap(Category::getDescription, category -> category));
+
+ public static Category from(String description) {
+ return DESCRIPTION_TO_CATEGORY_MAP.get(description);
+ }
+ }
+
+ public static Keyword create(Category category, String image) {
+ return new Keyword(category, image);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/domain/value/Type.java b/src/main/java/spring/backend/activity/domain/value/Type.java
new file mode 100644
index 000000000..71d742a37
--- /dev/null
+++ b/src/main/java/spring/backend/activity/domain/value/Type.java
@@ -0,0 +1,22 @@
+package spring.backend.activity.domain.value;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum Type {
+ ONLINE("온라인"),
+ OFFLINE("오프라인"),
+ ONLINE_AND_OFFLINE("온라인과 오프라인 모두");
+
+ private final String description;
+
+ public boolean includesOffline() {
+ return this == OFFLINE || this == ONLINE_AND_OFFLINE;
+ }
+
+ public boolean includesOnline() {
+ return this == ONLINE || this == ONLINE_AND_OFFLINE;
+ }
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java b/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java
new file mode 100644
index 000000000..38e7eb586
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java
@@ -0,0 +1,27 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.value.Keyword;
+
+public record ActivityInfo(
+
+ @Schema(description = "활동 ID", example = "1")
+ Long id,
+
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ Keyword keyword,
+
+ @Schema(description = "활동 제목", example = "휴식에는 역시 명상이 최고!")
+ String title,
+
+ @Schema(description = "활동 내용", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String content,
+
+ @Schema(description = "모은 시간", example = "20")
+ Integer savedTime
+) {
+ public static ActivityInfo from(Activity activity) {
+ return new ActivityInfo(activity.getId(), activity.getKeyword(), activity.getTitle(), activity.getContent(), activity.getSavedTime());
+ }
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java b/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java
new file mode 100644
index 000000000..a04c3574f
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java
@@ -0,0 +1,17 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+
+public record ActivityWithTitleAndSavedTimeResponse(
+ @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String title,
+
+ @Schema(description = "모은 시간", example = "60")
+ long savedTime,
+
+ @Schema(description = "활동 날짜", example = "2021-07-01T00:00:00")
+ LocalDateTime dateOfActivity
+) {
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java b/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java
new file mode 100644
index 000000000..7e356239d
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java
@@ -0,0 +1,20 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue;
+
+public record HomeActivityInfoResponse(
+
+ @Schema(description = "활동 ID", example = "1")
+ Long id,
+
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ KeywordJpaValue keyword,
+
+ @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String title,
+
+ @Schema(description = "모은 시간", example = "60")
+ Integer savedTime
+) {
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java b/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java
new file mode 100644
index 000000000..367b27f8c
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java
@@ -0,0 +1,13 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue;
+
+public record MonthlyActivityCountByKeywordResponse(
+ @Schema(description = "활동의 Keyword" , example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ KeywordJpaValue keyword,
+
+ @Schema(description = "Keyword별 활동 횟수" , example = "2")
+ long activityCount
+) {
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java b/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java
new file mode 100644
index 000000000..ff02a316f
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java
@@ -0,0 +1,12 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record MonthlySavedTimeAndActivityCountResponse(
+ @Schema(description = "이번 달 총 모은 자투리 시간(분단위)", example = "120")
+ long monthlyTotalSavedTime,
+
+ @Schema(description = "이번 달 총 활동 횟수", example = "2")
+ long monthlyTotalActivityCount
+) {
+}
diff --git a/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java b/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java
new file mode 100644
index 000000000..dd1ef6927
--- /dev/null
+++ b/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java
@@ -0,0 +1,12 @@
+package spring.backend.activity.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record TotalSavedTimeAndActivityCountByKeywordInMonth(
+ @Schema(description = "이번 달 해당 키워드 활동을 통해 모은 자투리 시간(분단위)", example = "120")
+ long totalSavedTimeByKeywordInMonth,
+
+ @Schema(description = "활동 키워드별 활동 총 개수", example = "12")
+ long totalActivityCountByKeywordInMonth
+) {
+}
diff --git a/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java b/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java
new file mode 100644
index 000000000..e5756d5e5
--- /dev/null
+++ b/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java
@@ -0,0 +1,30 @@
+package spring.backend.activity.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import spring.backend.core.exception.DomainException;
+import spring.backend.core.exception.error.BaseErrorCode;
+
+@Getter
+@RequiredArgsConstructor
+public enum ActivityErrorCode implements BaseErrorCode {
+
+ NOT_EXIST_LOCATION_WHEN_OFFLINE(HttpStatus.BAD_REQUEST, "오프라인의 경우 위치 정보가 필수입니다."),
+ EXIST_LOCATION_WHEN_ONLINE(HttpStatus.BAD_REQUEST, "온라인의 경우 위치 정보를 포함하지 않습니다."),
+ ACTIVITY_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "활동을 저장하는데 실패하였습니다."),
+ NOT_EXIST_ACTIVITY(HttpStatus.BAD_REQUEST, "활동이 존재하지 않습니다."),
+ MEMBER_ID_MISMATCH(HttpStatus.FORBIDDEN, "활동과 멤버 ID가 일치하지 않습니다."),
+ INVALID_ACTIVITY_DURATION(HttpStatus.BAD_REQUEST, "활동 지속 시간이 허용된 범위를 초과했습니다."),
+ ALREADY_FINISHED_ACTIVITY(HttpStatus.BAD_REQUEST, "이미 종료된 활동입니다."),
+ NOT_EXIST_ACTIVITY_CONDITION(HttpStatus.BAD_REQUEST, "요청이 비어있습니다.");
+
+ private final HttpStatus httpStatus;
+
+ private final String message;
+
+ @Override
+ public DomainException toException() {
+ return new DomainException(httpStatus, this);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java b/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java
new file mode 100644
index 000000000..04df3dae6
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java
@@ -0,0 +1,61 @@
+package spring.backend.activity.infrastructure.mapper;
+
+import org.springframework.stereotype.Component;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity;
+import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue;
+
+import java.util.Optional;
+
+@Component
+public class ActivityMapper {
+
+ public Activity toDomainEntity(ActivityJpaEntity activity) {
+ return Activity.builder()
+ .id(activity.getId())
+ .memberId(activity.getMemberId())
+ .quickStartId(activity.getQuickStartId())
+ .spareTime(activity.getSpareTime())
+ .type(activity.getType())
+ .keyword(toDomainValue(activity.getKeyword()))
+ .title(activity.getTitle())
+ .content(activity.getContent())
+ .location(activity.getLocation())
+ .finished(activity.getFinished())
+ .finishedAt(activity.getFinishedAt())
+ .savedTime(activity.getSavedTime())
+ .createdAt(activity.getCreatedAt())
+ .updatedAt(activity.getUpdatedAt())
+ .deleted(activity.getDeleted())
+ .build();
+ }
+
+ public ActivityJpaEntity toJpaEntity(Activity activity) {
+ return ActivityJpaEntity.builder()
+ .id(activity.getId())
+ .memberId(activity.getMemberId())
+ .quickStartId(activity.getQuickStartId())
+ .spareTime(activity.getSpareTime())
+ .type(activity.getType())
+ .keyword(toJpaValue(activity.getKeyword()))
+ .title(activity.getTitle())
+ .content(activity.getContent())
+ .location(activity.getLocation())
+ .finished(activity.getFinished())
+ .finishedAt(activity.getFinishedAt())
+ .savedTime(activity.getSavedTime())
+ .createdAt(activity.getCreatedAt())
+ .updatedAt(activity.getUpdatedAt())
+ .deleted(Optional.ofNullable(activity.getDeleted()).orElse(false))
+ .build();
+ }
+
+ private Keyword toDomainValue(KeywordJpaValue keywordJpaValue) {
+ return Keyword.create(keywordJpaValue.getCategory(), keywordJpaValue.getImage());
+ }
+
+ private KeywordJpaValue toJpaValue(Keyword keyword) {
+ return KeywordJpaValue.create(keyword.getCategory(), keyword.getImage());
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java
new file mode 100644
index 000000000..884b64298
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java
@@ -0,0 +1,41 @@
+package spring.backend.activity.infrastructure.persistence.jpa.adapter;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Repository;
+import spring.backend.activity.domain.entity.Activity;
+import spring.backend.activity.domain.repository.ActivityRepository;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.activity.infrastructure.mapper.ActivityMapper;
+import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity;
+import spring.backend.activity.infrastructure.persistence.jpa.repository.ActivityJpaRepository;
+
+@Repository
+@RequiredArgsConstructor
+@Log4j2
+public class ActivityRepositoryImpl implements ActivityRepository {
+
+ private final ActivityMapper activityMapper;
+ private final ActivityJpaRepository activityJpaRepository;
+
+ @Override
+ public Activity findById(Long id) {
+ ActivityJpaEntity activityJpaEntity = activityJpaRepository.findById(id).orElse(null);
+ if (activityJpaEntity == null) {
+ return null;
+ }
+ return activityMapper.toDomainEntity(activityJpaEntity);
+ }
+
+ @Override
+ public Activity save(Activity activity) {
+ try {
+ ActivityJpaEntity activityJpaEntity = activityMapper.toJpaEntity(activity);
+ activityJpaRepository.save(activityJpaEntity);
+ return activityMapper.toDomainEntity(activityJpaEntity);
+ } catch (Exception e) {
+ log.error("[ActivityRepositoryImpl] Failed to save activity", e);
+ throw ActivityErrorCode.ACTIVITY_SAVE_FAILED.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java
new file mode 100644
index 000000000..65b79d208
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java
@@ -0,0 +1,127 @@
+package spring.backend.activity.infrastructure.persistence.jpa.dao;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.dto.response.*;
+import spring.backend.activity.dto.response.HomeActivityInfoResponse;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary;
+import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity;
+import spring.backend.activity.query.dao.ActivityDao;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+public interface ActivityJpaDao extends JpaRepository, ActivityDao {
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.dto.response.HomeActivityInfoResponse(
+ a.id,
+ a.keyword,
+ a.title,
+ a.savedTime
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.createdAt between :startDateTime and :endDateTime
+ and a.finished = true
+ order by a.createdAt ASC
+ """)
+ List findTodayActivities(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse(
+ coalesce(sum(a.savedTime), 0),
+ coalesce(count(a), 0)
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.createdAt between :startDateTime and :endDateTime
+ and a.finished = true
+ """)
+ MonthlySavedTimeAndActivityCountResponse findMonthlyTotalSavedTimeAndTotalCount(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse(
+ a.keyword,
+ coalesce(count(a), 0)
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.createdAt between :startDateTime and :endDateTime
+ and a.finished = true
+ group by a.keyword
+ order by count (a) desc
+ """)
+ List findMonthlyActivitiesByKeywordSummary(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary(
+ m.createdAt,
+ coalesce(sum(a.savedTime), 0),
+ count(a)
+ )
+ from MemberJpaEntity m
+ left join ActivityJpaEntity a on a.memberId = m.id
+ and a.finished = true
+ and function('year', a.createdAt) = :year
+ and function('month', a.createdAt) = :month
+ where m.id = :memberId
+ """)
+ UserMonthlyActivitySummary findActivitySummaryByYearAndMonth(UUID memberId, int year, int month);
+
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail(
+ a.keyword.category,
+ a.title,
+ a.savedTime,
+ a.createdAt
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.finished = true
+ and function('year', a.createdAt) = :year
+ and function('month', a.createdAt) = :month
+ order by a.createdAt desc
+ """)
+ List findActivityDetailsByYearAndMonth(UUID memberId, int year, int month);
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse(
+ a.title,
+ coalesce(sum(a.savedTime), 0),
+ a.createdAt
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.createdAt between :startDateTime and :endDateTime
+ and a.finished = true
+ and a.keyword.category = :keywordCategory
+ order by a.createdAt DESC
+ """)
+ List findActivitiesByMemberAndKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory);
+
+ @Override
+ @Query("""
+ select new spring.backend.activity.dto.response.TotalSavedTimeAndActivityCountByKeywordInMonth(
+ coalesce(sum(a.savedTime), 0),
+ coalesce(count(a), 0)
+ )
+ from ActivityJpaEntity a
+ where a.memberId = :memberId
+ and a.createdAt between :startDateTime and :endDateTime
+ and a.finished = true
+ and a.keyword.category = :keywordCategory
+ """)
+ TotalSavedTimeAndActivityCountByKeywordInMonth findTotalSavedTimeAndActivityCountByKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory);
+
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java
new file mode 100644
index 000000000..65d5b3ad1
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java
@@ -0,0 +1,59 @@
+package spring.backend.activity.infrastructure.persistence.jpa.entity;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue;
+import spring.backend.activity.domain.value.Type;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.core.infrastructure.jpa.shared.BaseLongIdEntity;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "activity")
+@Getter
+@SuperBuilder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ActivityJpaEntity extends BaseLongIdEntity {
+
+ private UUID memberId;
+
+ private Long quickStartId;
+
+ private Integer spareTime;
+
+ @Enumerated(EnumType.STRING)
+ private Type type;
+
+ @Embedded
+ private KeywordJpaValue keyword;
+
+ private String title;
+
+ private String content;
+
+ private String location;
+
+ private Boolean finished;
+
+ private LocalDateTime finishedAt;
+
+ private Integer savedTime;
+
+ public boolean isFinished() {
+ return finished != null && finished;
+ }
+
+ public void finish() {
+ if (isFinished()) {
+ throw ActivityErrorCode.ALREADY_FINISHED_ACTIVITY.toException();
+ }
+ finished = true;
+ finishedAt = LocalDateTime.now();
+ savedTime = spareTime;
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java
new file mode 100644
index 000000000..e882aab1d
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java
@@ -0,0 +1,7 @@
+package spring.backend.activity.infrastructure.persistence.jpa.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity;
+
+public interface ActivityJpaRepository extends JpaRepository {
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java
new file mode 100644
index 000000000..8b20cd2ed
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java
@@ -0,0 +1,24 @@
+package spring.backend.activity.infrastructure.persistence.jpa.value;
+
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import lombok.*;
+import spring.backend.activity.domain.value.Keyword.Category;
+
+@Embeddable
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@EqualsAndHashCode
+public class KeywordJpaValue {
+
+ @Enumerated(EnumType.STRING)
+ private Category category;
+
+ private String image;
+
+ public static KeywordJpaValue create(Category category, String image) {
+ return new KeywordJpaValue(category, image);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java
new file mode 100644
index 000000000..56b927d80
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java
@@ -0,0 +1,10 @@
+package spring.backend.activity.infrastructure.queue;
+
+import lombok.Builder;
+
+@Builder
+public record FinishActivityMessage(
+ long activityId,
+ int spareTime
+) {
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java
new file mode 100644
index 000000000..cf97284ce
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java
@@ -0,0 +1,35 @@
+package spring.backend.activity.infrastructure.queue;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity;
+import spring.backend.activity.infrastructure.persistence.jpa.repository.ActivityJpaRepository;
+import spring.backend.core.infrastructure.queue.MessageConsumer;
+
+@Component
+@RequiredArgsConstructor
+@Transactional
+@Log4j2
+public class FinishActivityMessageConsumer implements MessageConsumer {
+
+ private final ActivityJpaRepository activityJpaRepository;
+
+ @Override
+ @RabbitListener(queues = "${finish-activity-queue.queue}")
+ public void consumeMessage(FinishActivityMessage message) {
+ try {
+ if (message == null) {
+ log.error("[FinishActivityMessageConsumer] Message is null");
+ return;
+ }
+ ActivityJpaEntity activity = activityJpaRepository.findById(message.activityId()).orElseThrow(ActivityErrorCode.NOT_EXIST_ACTIVITY::toException);
+ activity.finish();
+ } catch (Exception e) {
+ log.error("[FinishActivityMessageConsumer] Error processing message", e);
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java
new file mode 100644
index 000000000..26d67fff0
--- /dev/null
+++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java
@@ -0,0 +1,11 @@
+package spring.backend.activity.infrastructure.queue;
+
+import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty;
+import spring.backend.core.infrastructure.queue.MessageProducer;
+
+public class FinishActivityMessageProducer extends MessageProducer {
+
+ public FinishActivityMessageProducer(FinishActivityQueueProperty queueProperty, FinishActivityMessage message) {
+ super(queueProperty, message);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/FinishActivityController.java b/src/main/java/spring/backend/activity/presentation/FinishActivityController.java
new file mode 100644
index 000000000..a4728d98e
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/FinishActivityController.java
@@ -0,0 +1,27 @@
+package spring.backend.activity.presentation;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.domain.service.FinishActivityService;
+import spring.backend.activity.presentation.dto.response.FinishActivityResponse;
+import spring.backend.activity.presentation.swagger.FinishActivitySwagger;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class FinishActivityController implements FinishActivitySwagger {
+
+ private final FinishActivityService finishActivityService;
+
+ @Authorization
+ @PatchMapping("/v1/activities/{activityId}/finish")
+ public ResponseEntity> finishActivity(@AuthorizedMember Member member, @PathVariable Long activityId) {
+ return ResponseEntity.ok(new RestResponse<>(finishActivityService.finishActivity(member, activityId)));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java b/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java
new file mode 100644
index 000000000..bd5198f41
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java
@@ -0,0 +1,32 @@
+package spring.backend.activity.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.application.QuickStartActivitySelectService;
+import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse;
+import spring.backend.activity.presentation.swagger.QuickStartActivitySelectSwagger;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class QuickStartActivitySelectController implements QuickStartActivitySelectSwagger {
+
+ private final QuickStartActivitySelectService quickStartActivitySelectService;
+
+ @Override
+ @Authorization
+ @PostMapping("/v1/quick-starts/{quickStartId}/activities")
+ public ResponseEntity> quickStartUserActivitySelect(@AuthorizedMember Member member, @PathVariable Long quickStartId, @Valid @RequestBody QuickStartActivitySelectRequest quickStartActivitySelectRequest) {
+ QuickStartActivitySelectResponse savedActivityIdCreatedByQuickStartResponse = quickStartActivitySelectService.quickStartUserActivitySelect(member, quickStartId, quickStartActivitySelectRequest);
+ return ResponseEntity.ok(new RestResponse<>(savedActivityIdCreatedByQuickStartResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java b/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java
new file mode 100644
index 000000000..02eb1a424
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java
@@ -0,0 +1,37 @@
+package spring.backend.activity.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.application.ReadActivitiesByMemberAndKeywordInMonthService;
+import spring.backend.activity.presentation.dto.request.ActivitiesByMemberAndKeywordInMonthRequest;
+import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse;
+import spring.backend.activity.presentation.swagger.ReadActivitiesByMemberAndKeywordInMonthSwagger;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class ReadActivitiesByMemberAndKeywordInMonthController implements ReadActivitiesByMemberAndKeywordInMonthSwagger {
+ private final ReadActivitiesByMemberAndKeywordInMonthService readActivitiesByMemberAndKeywordInMonthService;
+
+ @Authorization
+ @GetMapping("/v1/activities")
+ public ResponseEntity> readActivitiesByMemberAndKeywordInMonth(
+ @AuthorizedMember Member member,
+ @Valid ActivitiesByMemberAndKeywordInMonthRequest activitiesByMemberAndKeywordInMonthRequest
+ ) {
+ ActivitiesByMemberAndKeywordInMonthResponse activitiesByMemberAndKeywordInMonthResponse = readActivitiesByMemberAndKeywordInMonthService.readActivitiesByMemberAndKeywordInMonth(
+ member,
+ activitiesByMemberAndKeywordInMonthRequest.year(),
+ activitiesByMemberAndKeywordInMonthRequest.month(),
+ activitiesByMemberAndKeywordInMonthRequest.keywordCategory()
+ );
+
+ return ResponseEntity.ok(new RestResponse<>(activitiesByMemberAndKeywordInMonthResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java b/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java
new file mode 100644
index 000000000..b786b8b19
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java
@@ -0,0 +1,34 @@
+package spring.backend.activity.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.presentation.dto.request.ReadActivityCalendarRequest;
+import spring.backend.activity.presentation.dto.response.ActivityCalendarResponse;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary;
+import spring.backend.activity.presentation.swagger.ReadActivityCalendarSwagger;
+import spring.backend.activity.query.dao.ActivityDao;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class ReadActivityCalendarController implements ReadActivityCalendarSwagger {
+
+ private final ActivityDao activityDao;
+
+ @Authorization
+ @GetMapping("/v1/activity-calendar")
+ public ResponseEntity> readActivityCalendar(@AuthorizedMember Member member, @Valid ReadActivityCalendarRequest request) {
+ UserMonthlyActivitySummary summary = activityDao.findActivitySummaryByYearAndMonth(member.getId(), request.year(), request.month());
+ List details = activityDao.findActivityDetailsByYearAndMonth(member.getId(), request.year(), request.month());
+ return ResponseEntity.ok(new RestResponse<>(ActivityCalendarResponse.of(summary, details)));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java b/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java
new file mode 100644
index 000000000..289340df6
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java
@@ -0,0 +1,31 @@
+package spring.backend.activity.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.application.ReadMonthlyActivityOverviewService;
+import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest;
+import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse;
+import spring.backend.activity.presentation.swagger.ReadMonthlyActivityOverviewSwagger;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class ReadMonthlyActivityOverviewController implements ReadMonthlyActivityOverviewSwagger {
+ private final ReadMonthlyActivityOverviewService readMonthlyActivityOverviewService;
+
+ @Authorization
+ @GetMapping("/v1/activities/overview")
+ public ResponseEntity> readMonthlyActivityOverview(
+ @AuthorizedMember Member member,
+ @Valid MonthlyActivityOverviewRequest monthlyActivityOverviewRequest
+ ) {
+ MonthlyActivityOverviewResponse monthlyActivityOverviewResponse = readMonthlyActivityOverviewService.readMonthlyActivityOverview(member, monthlyActivityOverviewRequest);
+ return ResponseEntity.ok(new RestResponse<>(monthlyActivityOverviewResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java b/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java
new file mode 100644
index 000000000..028781c62
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java
@@ -0,0 +1,31 @@
+package spring.backend.activity.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.activity.application.UserActivitySelectService;
+import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse;
+import spring.backend.activity.presentation.swagger.UserActivitySelectSwagger;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class UserActivitySelectController implements UserActivitySelectSwagger {
+
+ private final UserActivitySelectService userActivitySelectService;
+
+ @Authorization
+ @PostMapping("/v1/activities")
+ @Override
+ public ResponseEntity> userActivitySelect(@AuthorizedMember Member member, @Valid @RequestBody UserActivitySelectRequest userActivitySelectRequest) {
+ UserActivitySelectResponse userActivitySelectResponse = userActivitySelectService.userActivitySelect(member, userActivitySelectRequest);
+ return ResponseEntity.ok(new RestResponse<>(userActivitySelectResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java
new file mode 100644
index 000000000..931a8e7a2
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java
@@ -0,0 +1,24 @@
+package spring.backend.activity.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import spring.backend.activity.domain.value.Keyword;
+
+public record ActivitiesByMemberAndKeywordInMonthRequest(
+ @Min(value = 2024, message = "년도는 2024년 이후 값이어야 합니다.")
+ int year,
+
+ @Min(value = 1, message = "월은 1월과 12월 사이 값이어야 합니다.")
+ @Max(value = 12, message = "월은 1월과 12월 사이 값이어야 합니다.")
+ int month,
+
+ @NotNull(message = "키워드 카테고리는 필수 값입니다.")
+ @Schema(description = "키워드 카테고리 (예: HEALTH, SELF_DEVELOPMENT, CULTURE_ART, ENTERTAINMENT, RELAXATION, SOCIAL)", example = "RELAXATION", allowableValues = {
+ "SELF_DEVELOPMENT", "HEALTH", "CULTURE_ART",
+ "ENTERTAINMENT", "RELAXATION", "SOCIAL"
+ })
+ Keyword.Category keywordCategory
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java
new file mode 100644
index 000000000..902e0803c
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java
@@ -0,0 +1,14 @@
+package spring.backend.activity.presentation.dto.request;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+
+public record MonthlyActivityOverviewRequest(
+ @Min(value = 2024, message = "년도는 2024년 이후 값이어야 합니다.")
+ int year,
+
+ @Min(value = 1, message = "월은 1월과 12월 사이 값이어야 합니다.")
+ @Max(value = 12, message = "월은 1월과 12월 사이 값이어야 합니다.")
+ int month
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java
new file mode 100644
index 000000000..d38de3560
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java
@@ -0,0 +1,36 @@
+package spring.backend.activity.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.domain.value.Type;
+
+public record QuickStartActivitySelectRequest(
+ @NotNull(message = "활동 유형은 필수 입력 항목입니다.")
+ @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE")
+ Type type,
+
+ @NotNull(message = "자투리 시간은 필수 입력 항목입니다.")
+ @Min(value = 10, message = "자투리 시간은 최소 10이어야 합니다.")
+ @Max(value = 300, message = "자투리 시간은 최대 300이어야 합니다.")
+ @Schema(description = "자투리 시간", example = "300")
+ Integer spareTime,
+
+ @NotNull(message = "키워드는 필수 입력 항목입니다.")
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ Keyword keyword,
+
+ @NotNull(message = "타이틀은 필수 입력 항목입니다.")
+ @Schema(description = "타이틀", example = "카페에서 커피 마시며 책 읽기")
+ String title,
+
+ @NotNull(message = "내용은 필수 입력 항목입니다.")
+ @Schema(description = "내용", example = "조용한 카페에서 좋아하는 책을 읽으며 여유로운 시간을 즐길 수 있습니다.")
+ String content,
+
+ @Schema(description = "장소", example = "서울시 강남구 역삼동")
+ String location
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java
new file mode 100644
index 000000000..4ec0026a6
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java
@@ -0,0 +1,18 @@
+package spring.backend.activity.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+
+public record ReadActivityCalendarRequest(
+
+ @Min(value = 2024, message = "년도는 2024년 이상이어야 합니다.")
+ @Schema(description = "캘린더 조회 년도", example = "2024")
+ int year,
+
+ @Min(value = 1, message = "월은 1~12 사이여야 합니다.")
+ @Max(value = 12, message = "월은 1~12 사이여야 합니다.")
+ @Schema(description = "캘린더 조회 월", example = "11")
+ int month
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java
new file mode 100644
index 000000000..3e4829c54
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java
@@ -0,0 +1,36 @@
+package spring.backend.activity.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.domain.value.Type;
+
+public record UserActivitySelectRequest(
+ @NotNull(message = "활동 유형은 필수 입력 항목입니다.")
+ @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE")
+ Type type,
+
+ @NotNull(message = "자투리 시간은 필수 입력 항목입니다.")
+ @Min(value = 10, message = "자투리 시간은 최소 10이어야 합니다.")
+ @Max(value = 300, message = "자투리 시간은 최대 300이어야 합니다.")
+ @Schema(description = "자투리 시간", example = "300")
+ Integer spareTime,
+
+ @NotNull(message = "키워드는 필수 입력 항목입니다.")
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ Keyword keyword,
+
+ @NotNull(message = "타이틀은 필수 입력 항목입니다.")
+ @Schema(description = "타이틀", example = "카페에서 커피 마시며 책 읽기")
+ String title,
+
+ @NotNull(message = "내용은 필수 입력 항목입니다.")
+ @Schema(description = "내용", example = "조용한 카페에서 좋아하는 책을 읽으며 여유로운 시간을 즐길 수 있습니다.")
+ String content,
+
+ @Schema(description = "장소(활동 유형이 OFFLINE, ONLINE_AND_OFFLINE 인 경우에만 입력합니다.)", example = "서울시 강남구 역삼동")
+ String location
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java
new file mode 100644
index 000000000..f2ea209f9
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java
@@ -0,0 +1,22 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse;
+
+import java.util.List;
+
+public record ActivitiesByMemberAndKeywordInMonthResponse(
+ @Schema(description = "이번 달 해당 키워드 활동을 통해 모은 자투리 시간(분단위)", example = "120")
+ long totalSavedTimeByKeywordInMonth,
+
+ @Schema(description = "활동 키워드별 활동 총 개수")
+ long totalActivityCountByKeywordInMonth,
+
+ @Schema(description = "활동 키워드별 활동 목록")
+ List activities,
+
+ @Schema(description = "키워드", example = "{\"category\":\"RELAXATION\",\"image\":\"https://d1zjcuqflbd5k.cloudfront.net/files/acc_1160/0/1160-2019-07-02-14-07-52-0000.jpg\"}")
+ Keyword keyword
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java
new file mode 100644
index 000000000..83b98c6cc
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java
@@ -0,0 +1,20 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.List;
+
+public record ActivityCalendarResponse(
+
+ @Schema(description = "사용자의 월별 활동 요약",
+ example = "{ \"registrationDate\": \"2024-11-14T06:10:55.091954\", \"totalSavedTime\": 120, \"monthlyActivityCount\": 10 }")
+ UserMonthlyActivitySummary summary,
+
+ @Schema(description = "사용자의 월별 활동 상세 정보 리스트",
+ example = "{ \"category\": \"SELF_DEVELOPMENT\", \"title\": \"마음의 편안을 가져다주는 명상음악 20분 듣기\", \"savedTime\": 20, \"activityCreatedAt\": \"2024-11-16T14:24:08.548712\" }")
+ List monthlyActivities
+) {
+ public static ActivityCalendarResponse of(UserMonthlyActivitySummary summary, List details) {
+ return new ActivityCalendarResponse(summary, details);
+ }
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java
new file mode 100644
index 000000000..d3a23a962
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java
@@ -0,0 +1,15 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.dto.response.ActivityInfo;
+import spring.backend.member.dto.response.HomeMemberInfoResponse;
+
+public record FinishActivityResponse(
+
+ @Schema(description = "회원 정보")
+ HomeMemberInfoResponse member,
+
+ @Schema(description = "활동 정보")
+ ActivityInfo activity
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java
new file mode 100644
index 000000000..e5bdd2974
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java
@@ -0,0 +1,23 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse;
+import spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse;
+
+import java.time.Month;
+import java.util.List;
+
+public record MonthlyActivityOverviewResponse(
+ @Schema(description = "유저의 가입년도", example = "2024")
+ int joinedYear,
+
+ @Schema(description = "유저의 가입월", example = "JANUARY")
+ Month joinedMonth,
+
+ @Schema(description = "이번 달 총 모은 자투리 시간(분단위)과 활동횟수")
+ MonthlySavedTimeAndActivityCountResponse monthlySavedTimeAndActivityCount,
+
+ @Schema(description = "Keyword 별 자투리 시간 및 활동 횟수 요약")
+ List activitiesByKeywordSummary
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java
new file mode 100644
index 000000000..41c38d36e
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java
@@ -0,0 +1,16 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.domain.value.Keyword;
+
+public record QuickStartActivitySelectResponse(
+ @Schema(description = "활동 ID", example = "1")
+ Long id,
+
+ @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String title,
+
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ Keyword keyword
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java
new file mode 100644
index 000000000..e1d09e8cf
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java
@@ -0,0 +1,16 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.domain.value.Keyword;
+
+public record UserActivitySelectResponse(
+ @Schema(description = "활동 ID", example = "1")
+ Long id,
+
+ @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String title,
+
+ @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}")
+ Keyword keyword
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java
new file mode 100644
index 000000000..60df3d07e
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java
@@ -0,0 +1,23 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.activity.domain.value.Keyword.Category;
+
+import java.time.LocalDateTime;
+
+public record UserMonthlyActivityDetail(
+
+ @Schema(description = "활동 카테고리 \n\n SELF_DEVELOPMENT(자기개발), HEALTH(건강), CULTURE_ART(문화/예술), ENTERTAINMENT(엔터테인먼트), RELAXATION(휴식), SOCIAL(소셜)",
+ example = "SELF_DEVELOPMENT")
+ Category category,
+
+ @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기")
+ String title,
+
+ @Schema(description = "모은 시간", example = "20")
+ int savedTime,
+
+ @Schema(description = "활동 생성 시간", example = "2024-11-16T14:24:08.548712")
+ LocalDateTime activityCreatedAt
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java
new file mode 100644
index 000000000..c3e5d08d7
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java
@@ -0,0 +1,18 @@
+package spring.backend.activity.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+
+public record UserMonthlyActivitySummary(
+
+ @Schema(description = "사용자 최초 가입 시간", example = "2024-11-14T06:10:55.091954")
+ LocalDateTime registrationDate,
+
+ @Schema(description = "해당 달에 모은 시간 조각의 합 (분 단위)", example = "120")
+ long totalSavedTime,
+
+ @Schema(description = "사용자가 해당 달에 한 활동의 총 횟수", example = "10")
+ long monthlyActivityCount
+) {
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java
new file mode 100644
index 000000000..e2ec09c61
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java
@@ -0,0 +1,24 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.response.FinishActivityResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface FinishActivitySwagger {
+
+ @Operation(
+ summary = "활동 종료 API",
+ description = "진행 중인 활동을 종료합니다. \n\n 이 API는 활동이 자투리 시간 내에서만 종료될 수 있습니다.",
+ operationId = "/v1/activities/{activityId}/finish"
+ )
+ @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class})
+ ResponseEntity> finishActivity(@Parameter(hidden = true) Member member, Long activityId);
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java
new file mode 100644
index 000000000..4db151932
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java
@@ -0,0 +1,28 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.quickstart.exception.QuickStartErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface QuickStartActivitySelectSwagger {
+
+ @Operation(
+ summary = "빠른 시작 활동 선택 API",
+ description = "빠른 시작 활동을 선택합니다.",
+ operationId = "/v1/quick-starts/{quickStartId}/activities"
+ )
+ @ApiErrorCode({
+ GlobalErrorCode.class, ActivityErrorCode.class, QuickStartErrorCode.class
+ })
+ ResponseEntity> quickStartUserActivitySelect(@Parameter(hidden = true) Member member, Long quickStartId, QuickStartActivitySelectRequest quickStartActivitySelectRequest);
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java
new file mode 100644
index 000000000..18beb784e
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java
@@ -0,0 +1,22 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.request.ActivitiesByMemberAndKeywordInMonthRequest;
+import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface ReadActivitiesByMemberAndKeywordInMonthSwagger {
+ @Operation(
+ summary = "특정 달 선택한 키워드 활동 조회 API",
+ description = "특정 달 선택한 키워드 활동을 조회합니다.",
+ operationId = "/v1/activities"
+ )
+ ResponseEntity> readActivitiesByMemberAndKeywordInMonth(
+ @Parameter(hidden = true) Member member, ActivitiesByMemberAndKeywordInMonthRequest activitiesByMemberAndKeywordInMonthRequest
+ );
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java
new file mode 100644
index 000000000..476d4b387
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java
@@ -0,0 +1,26 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.request.ReadActivityCalendarRequest;
+import spring.backend.activity.presentation.dto.response.ActivityCalendarResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface ReadActivityCalendarSwagger {
+
+ @Operation(
+ summary = "활동 캘린더 조회 API",
+ description = "사용자가 연월을 선택하여 월별 활동 요약과 월별 활동 상세 정보 리스트를 반환합니다.",
+ operationId = "/v1/activity-calendar"
+ )
+ @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class})
+ ResponseEntity> readActivityCalendar(@Parameter(hidden = true) Member member, @ParameterObject ReadActivityCalendarRequest request);
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java
new file mode 100644
index 000000000..633937659
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java
@@ -0,0 +1,20 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest;
+import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface ReadMonthlyActivityOverviewSwagger {
+ @Operation(
+ summary = "월간 활동 개요 조회 API",
+ description = "사용자의 월간 활동 개요를 조회합니다.",
+ operationId = "/v1/activities/overview"
+ )
+ ResponseEntity> readMonthlyActivityOverview(@Parameter(hidden = true) Member member, MonthlyActivityOverviewRequest monthlyActivityOverviewRequest);
+}
diff --git a/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java
new file mode 100644
index 000000000..fe9455b84
--- /dev/null
+++ b/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java
@@ -0,0 +1,25 @@
+package spring.backend.activity.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest;
+import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse;
+import spring.backend.activity.exception.ActivityErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@Tag(name = "Activity", description = "활동")
+public interface UserActivitySelectSwagger {
+
+ @Operation(
+ summary = "사용자 활동 선택 API",
+ description = "사용자가 추천받은 활동 중 한가지 활동을 선택합니다.",
+ operationId = "/v1/activities"
+ )
+ @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class})
+ ResponseEntity> userActivitySelect(@Parameter(hidden = true) Member member, UserActivitySelectRequest userActivitySelectRequest);
+}
diff --git a/src/main/java/spring/backend/activity/query/dao/ActivityDao.java b/src/main/java/spring/backend/activity/query/dao/ActivityDao.java
new file mode 100644
index 000000000..8eb2c52d8
--- /dev/null
+++ b/src/main/java/spring/backend/activity/query/dao/ActivityDao.java
@@ -0,0 +1,28 @@
+package spring.backend.activity.query.dao;
+
+import spring.backend.activity.domain.value.Keyword;
+import spring.backend.activity.dto.response.*;
+import spring.backend.activity.dto.response.HomeActivityInfoResponse;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail;
+import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+public interface ActivityDao {
+
+ List findTodayActivities(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ UserMonthlyActivitySummary findActivitySummaryByYearAndMonth(UUID memberId, int year, int month);
+
+ List findActivityDetailsByYearAndMonth(UUID memberId, int year, int month);
+
+ MonthlySavedTimeAndActivityCountResponse findMonthlyTotalSavedTimeAndTotalCount(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ List findMonthlyActivitiesByKeywordSummary(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
+
+ List findActivitiesByMemberAndKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory);
+
+ TotalSavedTimeAndActivityCountByKeywordInMonth findTotalSavedTimeAndActivityCountByKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory);
+}
diff --git a/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java b/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java
new file mode 100644
index 000000000..289a96f2d
--- /dev/null
+++ b/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java
@@ -0,0 +1,29 @@
+package spring.backend.auth.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.OAuthRestClient;
+import spring.backend.auth.infrastructure.OAuthRestClientFactory;
+import spring.backend.member.domain.value.Provider;
+
+import java.net.URI;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+public class AuthorizeOAuthService {
+
+ private final OAuthRestClientFactory oAuthRestClientFactory;
+
+ public URI getAuthorizeUrl(String providerName) {
+ if (providerName == null || providerName.isEmpty()) {
+ log.error("[AuthorizeOAuthService] Invalid provider name");
+ throw AuthenticationErrorCode.NOT_EXIST_PROVIDER.toException();
+ }
+ Provider provider = Provider.valueOf(providerName.toUpperCase());
+ OAuthRestClient oAuthRestClient = oAuthRestClientFactory.getOAuthRestClient(provider);
+ return oAuthRestClient.getAuthUrl();
+ }
+}
diff --git a/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java b/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java
new file mode 100644
index 000000000..917127b64
--- /dev/null
+++ b/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java
@@ -0,0 +1,63 @@
+package spring.backend.auth.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.OAuthRestClient;
+import spring.backend.auth.infrastructure.OAuthRestClientFactory;
+import spring.backend.auth.presentation.dto.response.LoginResponse;
+import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse;
+import spring.backend.auth.presentation.dto.response.OAuthResourceResponse;
+import spring.backend.core.application.JwtService;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.domain.service.CreateMemberWithOAuthService;
+import spring.backend.member.domain.value.Provider;
+import spring.backend.member.presentation.dto.request.CreateMemberWithOAuthRequest;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+public class HandleOAuthLoginService {
+
+ private final OAuthRestClientFactory oAuthRestClientFactory;
+
+ private final CreateMemberWithOAuthService createMemberWithOAuthService;
+
+ private final JwtService jwtService;
+
+ private final RefreshTokenService refreshTokenService;
+
+ public LoginResponse handleOAuthLogin(String providerName, String code, String state, String ip) {
+ if (providerName == null || providerName.isEmpty()) {
+ throw AuthenticationErrorCode.NOT_EXIST_PROVIDER.toException();
+ }
+ Provider provider = Provider.valueOf(providerName.toUpperCase());
+ OAuthRestClient oAuthRestClient = oAuthRestClientFactory.getOAuthRestClient(provider);
+
+ OAuthAccessTokenResponse oAuthAccessTokenResponse = oAuthRestClient.getAccessToken(code, state);
+
+ if (oAuthAccessTokenResponse == null) {
+ log.error("[HandleOAuthLoginService] OAuth access token could not be retrieved.");
+ throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException();
+ }
+
+ OAuthResourceResponse oAuthResourceResponse = oAuthRestClient.getResource(oAuthAccessTokenResponse.getAccessToken());
+
+ if (oAuthResourceResponse == null) {
+ log.error("[HandleOAuthLoginService] OAuth resource could not be retrieved.");
+ throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException();
+ }
+
+ CreateMemberWithOAuthRequest createMemberWithOAuthRequest = CreateMemberWithOAuthRequest.builder()
+ .provider(provider)
+ .email(oAuthResourceResponse.getEmail())
+ .build();
+
+ Member member = createMemberWithOAuthService.createMemberWithOAuth(createMemberWithOAuthRequest);
+ String accessToken = jwtService.provideAccessToken(member);
+ String refreshToken = jwtService.provideRefreshToken(member, ip);
+ refreshTokenService.saveRefreshToken(refreshToken, member);
+ return LoginResponse.of(accessToken, refreshToken, member);
+ }
+}
diff --git a/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java b/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java
new file mode 100644
index 000000000..08849d5df
--- /dev/null
+++ b/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java
@@ -0,0 +1,56 @@
+package spring.backend.auth.application;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest;
+import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.domain.repository.MemberRepository;
+
+import java.time.Year;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+@Log4j2
+public class OnboardingSignUpService {
+
+ private static final int BIRTH_YEAR_RANGE = 100;
+
+ private final MemberRepository memberRepository;
+
+ public OnboardingSignUpResponse onboardingSignUp(Member member, OnboardingSignUpRequest request) {
+ validateMember(member);
+ validateRequest(request);
+ validateBirthYear(request);
+ member.convertGuestToMember(request.nickname(), request.birthYear(), request.gender(), request.profileImage());
+ memberRepository.save(member);
+ return OnboardingSignUpResponse.of(member.getEmail(), member.getNickname(), member.getBirthYear(), member.getGender(), member.getProfileImage(), member.getCreatedAt());
+ }
+
+ private void validateMember(Member member) {
+ if (member == null || member.isMember()) {
+ log.error("[OnboardingSignUpService] Invalid member condition for sign-up.");
+ throw AuthenticationErrorCode.INVALID_MEMBER_SIGN_UP_CONDITION.toException();
+ }
+ }
+
+ private void validateRequest(OnboardingSignUpRequest request) {
+ if (request == null) {
+ log.error("[OnboardingSignUpService] Invalid request.");
+ throw AuthenticationErrorCode.NOT_EXIST_SIGN_UP_CONDITION.toException();
+ }
+ }
+
+ private void validateBirthYear(OnboardingSignUpRequest request) {
+ int birthYear = request.birthYear();
+ int currentYear = Year.now().getValue();
+ if (birthYear < (currentYear - BIRTH_YEAR_RANGE) || birthYear > currentYear) {
+ log.error("[OnboardingSignUpService] Invalid request birth year.");
+ throw AuthenticationErrorCode.INVALID_BIRTH_YEAR.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/auth/application/RefreshTokenService.java b/src/main/java/spring/backend/auth/application/RefreshTokenService.java
new file mode 100644
index 000000000..b80d3fe1f
--- /dev/null
+++ b/src/main/java/spring/backend/auth/application/RefreshTokenService.java
@@ -0,0 +1,67 @@
+package spring.backend.auth.application;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import spring.backend.auth.domain.repository.RefreshTokenRepository;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.core.application.JwtService;
+import spring.backend.member.domain.entity.Member;
+
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class RefreshTokenService {
+ private final JwtService jwtService;
+ private final long REFRESH_TOKEN_EXPIRATION;
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ public RefreshTokenService(JwtService jwtService, @Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry, RefreshTokenRepository refreshTokenRepository) {
+ this.jwtService = jwtService;
+ this.REFRESH_TOKEN_EXPIRATION = refreshTokenExpiry;
+ this.refreshTokenRepository = refreshTokenRepository;
+ }
+
+ public void saveRefreshToken(String refreshToken, Member member) {
+ refreshTokenRepository.save(refreshToken, member.getId(), REFRESH_TOKEN_EXPIRATION, convertChronoUnitToTimeUnit(ChronoUnit.DAYS));
+ }
+
+ public void validateRefreshToken(String refreshToken) {
+ String savedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken);
+ if (savedRefreshToken == null || savedRefreshToken.isEmpty()) {
+ throw AuthenticationErrorCode.NOT_EXIST_REFRESH_TOKEN.toException();
+ }
+ jwtService.getPayload(refreshToken);
+ }
+
+ public void deleteRefreshToken(String refreshToken) {
+ if (refreshTokenRepository.findByRefreshToken(refreshToken) == null) {
+ log.error("리프레시 토큰이 저장소에 존재하지 않습니다.");
+ throw AuthenticationErrorCode.NOT_EXIST_REFRESH_TOKEN.toException();
+ }
+ refreshTokenRepository.deleteByRefreshToken(refreshToken);
+ }
+
+ private TimeUnit convertChronoUnitToTimeUnit(ChronoUnit chronoUnit) {
+ switch (chronoUnit) {
+ case NANOS:
+ return TimeUnit.NANOSECONDS;
+ case MICROS:
+ return TimeUnit.MICROSECONDS;
+ case MILLIS:
+ return TimeUnit.MILLISECONDS;
+ case SECONDS:
+ return TimeUnit.SECONDS;
+ case MINUTES:
+ return TimeUnit.MINUTES;
+ case HOURS:
+ return TimeUnit.HOURS;
+ case DAYS:
+ return TimeUnit.DAYS;
+ default:
+ throw AuthenticationErrorCode.UNSUPPORTED_REDIS_TIME_TYPE.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java b/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java
new file mode 100644
index 000000000..d2905304b
--- /dev/null
+++ b/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java
@@ -0,0 +1,52 @@
+package spring.backend.auth.application;
+
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.redis.repository.RefreshTokenRedisRepository;
+import spring.backend.auth.presentation.dto.response.RotateTokenResponse;
+import spring.backend.core.application.GeoLocationService;
+import spring.backend.core.application.JwtService;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.domain.repository.MemberRepository;
+
+import java.io.IOException;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+public class RotateAccessTokenService {
+ private final MemberRepository memberRepository;
+ private final JwtService jwtService;
+ private final RefreshTokenService refreshTokenService;
+ private final RefreshTokenRedisRepository refreshTokenRedisRepository;
+ private final GeoLocationService geoLocationService;
+
+ public RotateTokenResponse rotateToken(String refreshToken, String newIp) throws IOException, GeoIp2Exception {
+ if (refreshToken == null) {
+ throw AuthenticationErrorCode.MISSING_COOKIE_VALUE.toException();
+ }
+ refreshTokenService.validateRefreshToken(refreshToken);
+
+ String savedIp = jwtService.getPayload(refreshToken).get("ip", String.class);
+
+ if (geoLocationService.checkUserLocation(newIp, savedIp)) {
+ refreshTokenService.deleteRefreshToken(refreshToken);
+ log.error("유효하지 않은 위치에서 토큰 재발급을 시도했습니다.");
+ throw AuthenticationErrorCode.TOKEN_ROTATE_ATTEMPT_FROM_INVALID_LOCATION.toException();
+ }
+
+ UUID memberId = UUID.fromString(refreshTokenRedisRepository.findByRefreshToken(refreshToken));
+ Member member = memberRepository.findById(memberId);
+ String newAccessToken = jwtService.provideAccessToken(member);
+ String newRefreshToken = jwtService.provideRefreshToken(member, newIp);
+ refreshTokenService.saveRefreshToken(newRefreshToken, member);
+ return new RotateTokenResponse(
+ newAccessToken,
+ newRefreshToken
+ );
+ }
+}
diff --git a/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java
new file mode 100644
index 000000000..b34abf16b
--- /dev/null
+++ b/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java
@@ -0,0 +1,11 @@
+package spring.backend.auth.domain.repository;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+public interface RefreshTokenRepository {
+ void save(String refreshToken, UUID memberId, Long expireTime, TimeUnit timeUnit);
+ String findByRefreshToken(String refreshToken);
+ void deleteByRefreshToken(String refreshToken);
+ void deleteAll();
+}
diff --git a/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java b/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java
new file mode 100644
index 000000000..a422fac27
--- /dev/null
+++ b/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java
@@ -0,0 +1,43 @@
+package spring.backend.auth.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import spring.backend.core.exception.DomainException;
+import spring.backend.core.exception.error.BaseErrorCode;
+
+@Getter
+@RequiredArgsConstructor
+public enum AuthenticationErrorCode implements BaseErrorCode {
+
+ NOT_EXIST_HEADER(HttpStatus.UNAUTHORIZED, "Authorization Header가 존재하지 않습니다."),
+ NOT_EXIST_TOKEN_In_COOKIE(HttpStatus.UNAUTHORIZED, "쿠키에 Token이 존재하지 않습니다."),
+ NOT_MATCH_TOKEN_FORMAT(HttpStatus.UNAUTHORIZED, "토큰의 형식이 맞지 않습니다."),
+ INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "토큰의 서명이 올바르지 않습니다."),
+ NOT_DEFINE_TOKEN(HttpStatus.UNAUTHORIZED, "정의되지 않은 토큰입니다."),
+ EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
+ INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "유효한 OAuth 써드파티 제공자가 아닙니다."),
+ NOT_EXIST_PROVIDER(HttpStatus.BAD_REQUEST, "OAuth 써드파티 제공자가 존재하지 않습니다."),
+ NOT_EXIST_AUTH_CODE(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 제공자에서 제공받은 인증 코드가 존재하지 않습니다."),
+ ACCESS_TOKEN_NOT_ISSUED(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 제공자에서 액세스 토큰이 발급되지 않았습니다."),
+ NOT_EXIST_RESOURCE_RESPONSE(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 리소스 서버에서 자원이 존재하지 않습니다."),
+ RESOURCE_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "OAuth Resource Server에 접근할 수 없습니다."),
+ UNSUPPORTED_REDIS_TIME_TYPE(HttpStatus.BAD_REQUEST, "Redis 만료시간은 ChronoUnit 타입이어야 합니다."),
+ MISMATCH_TOKEN_MEMBER(HttpStatus.UNAUTHORIZED, "토큰의 회원 ID와 요청한 회원 ID가 일치하지 않습니다."),
+ NOT_EXIST_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 저장소에 존재하지 않습니다."),
+ MISSING_COOKIE_VALUE(HttpStatus.BAD_REQUEST, "쿠키값이 존재하지 않습니다."),
+ INVALID_MEMBER_SIGN_UP_CONDITION(HttpStatus.BAD_REQUEST, "회원가입을 위한 사용자 조건이 유효하지 않습니다."),
+ NOT_EXIST_SIGN_UP_CONDITION(HttpStatus.BAD_REQUEST, "회원가입 요청이 유효하지 않습니다."),
+ INVALID_BIRTH_YEAR(HttpStatus.BAD_REQUEST, "출생년도는 현재 연도와 100년 전 사이여야 합니다."),
+ TOKEN_ROTATE_ATTEMPT_FROM_INVALID_LOCATION(HttpStatus.UNAUTHORIZED, "유효하지 않은 위치에서 토큰 재발급을 시도했습니다."),
+ FAILED_TO_EXTRACT_MEMBER_ID_FROM_EXPIRED_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "만료된 액세스 토큰에서 회원 ID를 추출하는데 실패했습니다.");
+
+ private final HttpStatus httpStatus;
+
+ private final String message;
+
+ @Override
+ public DomainException toException() {
+ return new DomainException(httpStatus, this);
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java
new file mode 100644
index 000000000..113f4ff31
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java
@@ -0,0 +1,15 @@
+package spring.backend.auth.infrastructure;
+
+import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse;
+import spring.backend.auth.presentation.dto.response.OAuthResourceResponse;
+
+import java.net.URI;
+
+public interface OAuthRestClient {
+
+ URI getAuthUrl();
+
+ OAuthAccessTokenResponse getAccessToken(String authCode, String state);
+
+ OAuthResourceResponse getResource(String oauthToken);
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java
new file mode 100644
index 000000000..4ae00826b
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java
@@ -0,0 +1,35 @@
+package spring.backend.auth.infrastructure;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.google.GoogleOAuthRestClient;
+import spring.backend.auth.infrastructure.kakao.KakaoOAuthRestClient;
+import spring.backend.auth.infrastructure.naver.NaverOAuthRestClient;
+import spring.backend.member.domain.value.Provider;
+
+@Component
+@RequiredArgsConstructor
+public class OAuthRestClientFactory {
+
+ private final GoogleOAuthRestClient googleOAuthRestClient;
+
+ private final NaverOAuthRestClient naverOAuthRestClient;
+
+ private final KakaoOAuthRestClient kakaoOAuthRestClient;
+
+ public OAuthRestClient getOAuthRestClient(Provider provider) {
+ switch (provider) {
+ case GOOGLE -> {
+ return googleOAuthRestClient;
+ }
+ case NAVER -> {
+ return naverOAuthRestClient;
+ }
+ case KAKAO -> {
+ return kakaoOAuthRestClient;
+ }
+ default -> throw AuthenticationErrorCode.INVALID_PROVIDER.toException();
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java
new file mode 100644
index 000000000..c8a395c44
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java
@@ -0,0 +1,89 @@
+package spring.backend.auth.infrastructure.google;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.util.UriComponentsBuilder;
+import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse;
+import spring.backend.auth.presentation.dto.response.OAuthResourceResponse;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.OAuthRestClient;
+import spring.backend.core.configuration.property.oauth.GoogleOAuthProperty;
+
+import java.net.URI;
+
+@Component
+@RequiredArgsConstructor
+@Log4j2
+public class GoogleOAuthRestClient implements OAuthRestClient {
+
+ private static final String GRANT_TYPE = "authorization_code";
+
+ private final GoogleOAuthProperty googleOAuthProperty;
+
+ @Override
+ public URI getAuthUrl() {
+ return UriComponentsBuilder.fromUriString(googleOAuthProperty.getAuthUri())
+ .queryParam("client_id", googleOAuthProperty.getClientId())
+ .queryParam("redirect_uri", googleOAuthProperty.getRedirectUri())
+ .queryParam("response_type", "code")
+ .queryParam("scope", String.join(" ", googleOAuthProperty.getScope()))
+ .build()
+ .toUri();
+ }
+
+ @Override
+ public OAuthAccessTokenResponse getAccessToken(String authCode, String state) {
+ if (authCode == null || authCode.isEmpty()) {
+ log.error("[GoogleOAuthRestClient] authCode is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ return RestClient.create()
+ .post()
+ .uri(googleOAuthProperty.getTokenUri())
+ .headers(header -> header.setContentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .body(createAccessTokenRequestBody(authCode))
+ .retrieve()
+ .body(OAuthAccessTokenResponse.class);
+ } catch (Exception e) {
+ log.error("[GoogleOAuthRestClient] error", e);
+ throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException();
+ }
+ }
+
+ @Override
+ public OAuthResourceResponse getResource(String oauthToken) {
+ if (oauthToken == null || oauthToken.isEmpty()) {
+ log.error("[GoogleOAuthRestClient] oauthToken is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ return RestClient.create()
+ .get()
+ .uri(googleOAuthProperty.getResourceUri())
+ .headers(header -> {
+ header.setBearerAuth(oauthToken);
+ header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ })
+ .retrieve()
+ .body(OAuthResourceResponse.class);
+ } catch (Exception e) {
+ throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException();
+ }
+ }
+
+ private MultiValueMap createAccessTokenRequestBody(String authCode) {
+ MultiValueMap parameters = new LinkedMultiValueMap<>();
+ parameters.add("client_id", googleOAuthProperty.getClientId());
+ parameters.add("client_secret", googleOAuthProperty.getClientSecret());
+ parameters.add("code", authCode);
+ parameters.add("grant_type", GRANT_TYPE);
+ parameters.add("redirect_uri", googleOAuthProperty.getRedirectUri());
+ return parameters;
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java
new file mode 100644
index 000000000..55997800a
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java
@@ -0,0 +1,112 @@
+package spring.backend.auth.infrastructure.kakao;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.util.UriComponentsBuilder;
+import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse;
+import spring.backend.auth.presentation.dto.response.OAuthResourceResponse;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.OAuthRestClient;
+import spring.backend.auth.infrastructure.kakao.dto.KakaoResourceResponse;
+import spring.backend.core.configuration.property.oauth.KakaoOAuthProperty;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Optional;
+
+@Component
+@RequiredArgsConstructor
+@Log4j2
+public class KakaoOAuthRestClient implements OAuthRestClient {
+
+ private static final String GRANT_TYPE = "authorization_code";
+
+ private final KakaoOAuthProperty kakaoOAuthProperty;
+
+ @Override
+ public URI getAuthUrl() {
+ return UriComponentsBuilder.fromUriString(kakaoOAuthProperty.getAuthUri())
+ .queryParam("client_id", kakaoOAuthProperty.getClientId())
+ .queryParam("redirect_uri", kakaoOAuthProperty.getRedirectUri())
+ .queryParam("response_type", "code")
+ .queryParam("scope", String.join(" ", kakaoOAuthProperty.getScope()))
+ .build()
+ .toUri();
+ }
+
+ @Override
+ public OAuthAccessTokenResponse getAccessToken(String authCode, String state) {
+ if (authCode == null || authCode.isEmpty()) {
+ log.error("[KakaoOAuthRestClient] authCode is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ return RestClient.create()
+ .post()
+ .uri(kakaoOAuthProperty.getTokenUri())
+ .headers(header -> {
+ header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
+ })
+ .body(createAccessTokenRequestBody(authCode))
+ .retrieve()
+ .body(OAuthAccessTokenResponse.class);
+ } catch (Exception e) {
+ log.error("[GoogleOAuthRestClient] error", e);
+ throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException();
+ }
+ }
+
+ @Override
+ public OAuthResourceResponse getResource(String oauthToken) {
+ if (oauthToken == null || oauthToken.isEmpty()) {
+ log.error("[KakaoOAuthRestClient] oauthToken is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ KakaoResourceResponse kakaoResourceResponse = RestClient.create()
+ .get()
+ .uri(kakaoOAuthProperty.getResourceUri())
+ .headers(header -> {
+ header.setBearerAuth(oauthToken);
+ header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
+ })
+ .retrieve()
+ .body(KakaoResourceResponse.class);
+ Long id = Optional.ofNullable(kakaoResourceResponse)
+ .map(KakaoResourceResponse::getId)
+ .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException);
+ KakaoResourceResponse.Response kakaoAccount = Optional.ofNullable(kakaoResourceResponse.getKakaoAccount())
+ .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException);
+ String email = Optional.ofNullable(kakaoAccount.getEmail())
+ .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException);
+ String nickname = Optional.ofNullable(kakaoAccount.getProfile())
+ .map(KakaoResourceResponse.Response.Profile::getNickname)
+ .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException);
+ return OAuthResourceResponse.builder()
+ .id(String.valueOf(id))
+ .name(nickname)
+ .email(email)
+ .build();
+ } catch (Exception e) {
+ throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException();
+ }
+ }
+
+ private MultiValueMap createAccessTokenRequestBody(String authCode) {
+ MultiValueMap parameters = new LinkedMultiValueMap<>();
+ parameters.add("client_id", kakaoOAuthProperty.getClientId());
+ parameters.add("client_secret", kakaoOAuthProperty.getClientSecret());
+ parameters.add("code", authCode);
+ parameters.add("grant_type", GRANT_TYPE);
+ parameters.add("redirect_uri", kakaoOAuthProperty.getRedirectUri());
+ return parameters;
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java b/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java
new file mode 100644
index 000000000..ff61703a8
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java
@@ -0,0 +1,26 @@
+package spring.backend.auth.infrastructure.kakao.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+
+@Getter
+public class KakaoResourceResponse {
+
+ private Long id;
+
+ @JsonProperty("kakao_account")
+ private Response kakaoAccount;
+
+ @Getter
+ public static class Response {
+
+ private String email;
+
+ private Profile profile;
+
+ @Getter
+ public static class Profile {
+ private String nickname;
+ }
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java
new file mode 100644
index 000000000..c5a1cb535
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java
@@ -0,0 +1,110 @@
+package spring.backend.auth.infrastructure.naver;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.util.UriComponentsBuilder;
+import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse;
+import spring.backend.auth.presentation.dto.response.OAuthResourceResponse;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.infrastructure.OAuthRestClient;
+import spring.backend.auth.infrastructure.naver.dto.NaverResourceResponse;
+import spring.backend.core.configuration.property.oauth.NaverOAuthProperty;
+
+import java.math.BigInteger;
+import java.net.URI;
+import java.security.SecureRandom;
+
+@Component
+@RequiredArgsConstructor
+@Log4j2
+public class NaverOAuthRestClient implements OAuthRestClient {
+
+ private static final String GRANT_TYPE = "authorization_code";
+
+ private final NaverOAuthProperty naverOAuthProperty;
+
+ @Override
+ public URI getAuthUrl() {
+ return UriComponentsBuilder.fromUriString(naverOAuthProperty.getAuthUri())
+ .queryParam("client_id", naverOAuthProperty.getClientId())
+ .queryParam("redirect_uri", naverOAuthProperty.getRedirectUri())
+ .queryParam("response_type", "code")
+ .queryParam("state", generateState())
+ .build()
+ .toUri();
+ }
+
+ @Override
+ public OAuthAccessTokenResponse getAccessToken(String authCode, String state) {
+ if (authCode == null || authCode.isEmpty() || state == null || state.isEmpty()) {
+ log.error("[NaverOAuthProperty] authCode is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ return RestClient.create()
+ .post()
+ .uri(naverOAuthProperty.getTokenUri())
+ .headers(header -> header.setContentType(MediaType.APPLICATION_FORM_URLENCODED))
+ .body(createAccessTokenRequestBody(authCode, state))
+ .retrieve()
+ .body(OAuthAccessTokenResponse.class);
+ } catch (Exception e) {
+ log.error("[NaverOAuthProperty] error", e);
+ throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException();
+ }
+ }
+
+ @Override
+ public OAuthResourceResponse getResource(String oauthToken) {
+ if (oauthToken == null || oauthToken.isEmpty()) {
+ log.error("[NaverOAuthProperty] oauthToken is null");
+ throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException();
+ }
+ try {
+ NaverResourceResponse naverResourceResponse = RestClient.create()
+ .get()
+ .uri(naverOAuthProperty.getResourceUri())
+ .headers(header -> {
+ header.setBearerAuth(oauthToken);
+ header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ })
+ .retrieve()
+ .body(NaverResourceResponse.class);
+
+ if (naverResourceResponse == null) {
+ throw AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE.toException();
+ }
+ NaverResourceResponse.Response resourceResponse = naverResourceResponse.getResponse();
+ if (resourceResponse == null || resourceResponse.getId() == null || resourceResponse.getName() == null || resourceResponse.getEmail() == null) {
+ throw AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE.toException();
+ }
+ return OAuthResourceResponse.builder()
+ .id(resourceResponse.getId())
+ .name(resourceResponse.getName())
+ .email(resourceResponse.getEmail())
+ .build();
+ } catch (Exception e) {
+ throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException();
+ }
+ }
+
+ private String generateState() {
+ SecureRandom random = new SecureRandom();
+ return new BigInteger(130, random).toString(32);
+ }
+
+ private MultiValueMap createAccessTokenRequestBody(String authCode, String state) {
+ MultiValueMap parameters = new LinkedMultiValueMap<>();
+ parameters.add("client_id", naverOAuthProperty.getClientId());
+ parameters.add("client_secret", naverOAuthProperty.getClientSecret());
+ parameters.add("grant_type", GRANT_TYPE);
+ parameters.add("state", state);
+ parameters.add("code", authCode);
+ return parameters;
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java b/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java
new file mode 100644
index 000000000..2759b2027
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java
@@ -0,0 +1,16 @@
+package spring.backend.auth.infrastructure.naver.dto;
+
+import lombok.Getter;
+
+@Getter
+public class NaverResourceResponse {
+
+ private Response response;
+
+ @Getter
+ public static class Response {
+ private String id;
+ private String name;
+ private String email;
+ }
+}
diff --git a/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java b/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java
new file mode 100644
index 000000000..0beed659c
--- /dev/null
+++ b/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java
@@ -0,0 +1,71 @@
+package spring.backend.auth.infrastructure.redis.repository;
+
+import io.lettuce.core.RedisConnectionException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.stereotype.Repository;
+import spring.backend.auth.domain.repository.RefreshTokenRepository;
+import spring.backend.core.exception.error.GlobalErrorCode;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+@Repository
+@RequiredArgsConstructor
+@Log4j2
+public class RefreshTokenRedisRepository implements RefreshTokenRepository {
+ private final RedisTemplate redisTemplate;
+
+ @Override
+ public void save(String refreshToken, UUID memberId, Long expireTime, TimeUnit timeUnit) {
+ try {
+ ValueOperations valueOperations = redisTemplate.opsForValue();
+ valueOperations.set(refreshToken, memberId.toString(), expireTime, timeUnit);
+ } catch (RedisConnectionException e) {
+ log.error("Redis 연결 오류 : {}", e.getMessage());
+ throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException();
+ } catch (Exception e) {
+ throw GlobalErrorCode.INTERNAL_ERROR.toException();
+ }
+ }
+
+ @Override
+ public String findByRefreshToken(String refreshToken) {
+ try {
+ ValueOperations valueOperations = redisTemplate.opsForValue();
+ return valueOperations.get(refreshToken);
+ } catch (RedisConnectionException e) {
+ log.error("Redis 연결 오류 : {}", e.getMessage());
+ throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException();
+ } catch (Exception e) {
+ throw GlobalErrorCode.INTERNAL_ERROR.toException();
+ }
+ }
+
+ @Override
+ public void deleteByRefreshToken(String refreshToken) {
+ try {
+ redisTemplate.delete(refreshToken);
+ } catch (RedisConnectionException e) {
+ log.error("Redis 연결 오류 : {}", e.getMessage());
+ throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException();
+ } catch (Exception e) {
+ throw GlobalErrorCode.INTERNAL_ERROR.toException();
+ }
+ }
+
+ @Override
+ public void deleteAll() {
+ try {
+ redisTemplate.getConnectionFactory().getConnection().flushDb();
+ } catch (RedisConnectionException e) {
+ log.error("Redis 연결 오류 : {}", e.getMessage());
+ throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException();
+ } catch (Exception e) {
+ throw GlobalErrorCode.INTERNAL_ERROR.toException();
+ }
+ }
+
+}
diff --git a/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java b/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java
new file mode 100644
index 000000000..cde03957b
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java
@@ -0,0 +1,25 @@
+package spring.backend.auth.presentation;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.servlet.view.RedirectView;
+import spring.backend.auth.application.AuthorizeOAuthService;
+
+import java.net.URI;
+
+@Controller
+@RequestMapping("/v1/oauth")
+@RequiredArgsConstructor
+public class AuthorizeOAuthController {
+
+ private final AuthorizeOAuthService authorizeOAuthService;
+
+ @GetMapping("/{providerName}")
+ public RedirectView authorizeOAuth(@PathVariable String providerName) {
+ URI authUrl = authorizeOAuthService.getAuthorizeUrl(providerName);
+ return new RedirectView(authUrl.toASCIIString());
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java b/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java
new file mode 100644
index 000000000..0b9c3ffbd
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java
@@ -0,0 +1,47 @@
+package spring.backend.auth.presentation;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import spring.backend.auth.application.HandleOAuthLoginService;
+import spring.backend.auth.presentation.dto.response.LoginResponse;
+import spring.backend.auth.presentation.dto.response.LoginUserInfoResponse;
+import spring.backend.core.configuration.argumentresolver.ClientIp;
+import spring.backend.core.presentation.RestResponse;
+
+@RestController
+@RequestMapping("/v1/oauth/login")
+@RequiredArgsConstructor
+public class HandleOAuthLoginController {
+
+ private final HandleOAuthLoginService handleOAuthLoginService;
+
+ @GetMapping("/{providerName}")
+ public ResponseEntity> handleOAuthLogin(@RequestParam(value = "code", required = false) String code,
+ @RequestParam(value = "state", required = false) String state, @PathVariable String providerName, @ClientIp String ip) {
+ LoginResponse loginResponse = handleOAuthLoginService.handleOAuthLogin(providerName, code, state, ip);
+ ResponseCookie accessTokenCookie = ResponseCookie.from("access_token", loginResponse.accessToken())
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .path("/")
+ .build();
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", loginResponse.refreshToken())
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .path("/")
+ .build();
+
+ LoginUserInfoResponse loginUserInfoResponse = LoginUserInfoResponse.from(loginResponse);
+
+ return ResponseEntity.ok()
+ .headers(header -> {
+ header.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
+ header.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
+ })
+ .body(new RestResponse<>(loginUserInfoResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/LogoutController.java b/src/main/java/spring/backend/auth/presentation/LogoutController.java
new file mode 100644
index 000000000..b28711ae6
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/LogoutController.java
@@ -0,0 +1,40 @@
+package spring.backend.auth.presentation;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.auth.application.RefreshTokenService;
+import spring.backend.auth.presentation.swagger.LogoutSwagger;
+import spring.backend.core.presentation.RestResponse;
+
+@RestController
+@RequiredArgsConstructor
+public class LogoutController implements LogoutSwagger {
+ private final RefreshTokenService refreshTokenService;
+
+ @PostMapping("/v1/logout")
+ public ResponseEntity> logout(
+ @CookieValue(name = "access_token", required = false) String accessToken,
+ @CookieValue(name = "refresh_token", required = false) String refreshToken
+ ) {
+ refreshTokenService.deleteRefreshToken(refreshToken);
+ ResponseCookie accessTokenCookie = ResponseCookie.from("access_token", "")
+ .httpOnly(true)
+ .path("/")
+ .maxAge(0)
+ .build();
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "")
+ .httpOnly(true)
+ .path("/")
+ .maxAge(0)
+ .build();
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
+ .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString())
+ .build();
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java b/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java
new file mode 100644
index 000000000..3dca0039e
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java
@@ -0,0 +1,30 @@
+package spring.backend.auth.presentation;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.auth.application.OnboardingSignUpService;
+import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest;
+import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse;
+import spring.backend.auth.presentation.swagger.OnboardingSignUpSwagger;
+import spring.backend.core.configuration.argumentresolver.LoginMember;
+import spring.backend.core.configuration.interceptor.Authorization;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+
+@RestController
+@RequiredArgsConstructor
+public class OnboardingSignUpController implements OnboardingSignUpSwagger {
+
+ private final OnboardingSignUpService onboardingSignUpService;
+
+ @Authorization
+ @PostMapping("/v1/members/onboard")
+ public ResponseEntity> onboardingSignUp(@LoginMember Member member, @Valid @RequestBody OnboardingSignUpRequest request) {
+ OnboardingSignUpResponse onboardingSignUpResponse = onboardingSignUpService.onboardingSignUp(member, request);
+ return ResponseEntity.ok(new RestResponse<>(onboardingSignUpResponse));
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java b/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java
new file mode 100644
index 000000000..28a7202a4
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java
@@ -0,0 +1,55 @@
+package spring.backend.auth.presentation;
+
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import spring.backend.auth.application.RotateAccessTokenService;
+import spring.backend.auth.presentation.dto.response.RotateTokenResponse;
+import spring.backend.auth.presentation.swagger.RotateTokenSwagger;
+import spring.backend.core.configuration.argumentresolver.ClientIp;
+import spring.backend.core.presentation.RestResponse;
+
+import java.io.IOException;
+
+import static org.springframework.http.ResponseCookie.from;
+
+@RestController
+@RequestMapping("/v1/token/rotate")
+@RequiredArgsConstructor
+@Log4j2
+public class RotateAccessTokenController implements RotateTokenSwagger {
+ private final RotateAccessTokenService rotateTokenService;
+
+ @PostMapping
+ public ResponseEntity> rotateToken(
+ @CookieValue(name = "refresh_token", required = false) String refreshToken,
+ @ClientIp String ip
+ ) throws IOException, GeoIp2Exception {
+ RotateTokenResponse rotateTokenResponse = rotateTokenService.rotateToken(refreshToken, ip);
+ ResponseCookie newAccessToken = from("access_token", rotateTokenResponse.accessToken())
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .path("/")
+ .build();
+
+ ResponseCookie newRefreshToken = from("refresh_token", rotateTokenResponse.refreshToken())
+ .httpOnly(true)
+ .secure(true)
+ .sameSite("None")
+ .path("/")
+ .build();
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, newAccessToken.toString())
+ .header(HttpHeaders.SET_COOKIE, newRefreshToken.toString())
+ .build();
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java b/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java
new file mode 100644
index 000000000..d37e3936d
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java
@@ -0,0 +1,24 @@
+package spring.backend.auth.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import spring.backend.member.domain.value.Gender;
+
+public record OnboardingSignUpRequest(
+
+ @Pattern(regexp = "^[a-zA-Z0-9가-힣ㄱ-ㅎ]{1,6}$", message = "닉네임은 한글, 영문, 숫자 조합 6자 이내로 입력해주세요.")
+ @Schema(description = "닉네임", example = "조각조각")
+ String nickname,
+
+ @Schema(description = "출생년도", example = "2001")
+ int birthYear,
+
+ @NotNull(message = "성별을 입력해주세요.")
+ @Schema(description = "성별 (MALE, FEMALE, NONE)", example = "FEMALE")
+ Gender gender,
+
+ @NotBlank(message = "프로필 이미지를 선택해주세요.")
+ @Schema(description = "프로필 이미지", example = "http://test.jpg")
+ String profileImage
+) {
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java
new file mode 100644
index 000000000..f84c17f0e
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java
@@ -0,0 +1,52 @@
+package spring.backend.auth.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.domain.value.Gender;
+import spring.backend.member.domain.value.Role;
+
+import java.time.LocalDateTime;
+
+public record LoginResponse(
+
+ @Schema(description = "액세스 토큰")
+ String accessToken,
+
+ @Schema(description = "리프레시 토큰")
+ String refreshToken,
+
+ @Schema(description = "사용자 정보")
+ UserInfo userInfo
+) {
+ public record UserInfo(
+
+ @Schema(description = "사용자 유형(MEMBER, GUEST)", example = "MEMBER")
+ Role role,
+
+ @Schema(description = "사용자 이메일", example = "example@example.com")
+ String email,
+
+ @Schema(description = "사용자 닉네임", example = "john_doe")
+ String nickname,
+
+ @Schema(description = "사용자 출생 연도", example = "1990")
+ int birthYear,
+
+ @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE")
+ Gender gender,
+
+ @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg")
+ String profileImage,
+
+ @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954")
+ LocalDateTime registrationDate
+ ) {
+ public static UserInfo from(Member member) {
+ return new UserInfo(member.getRole(), member.getEmail(), member.getNickname(), member.getBirthYear(), member.getGender(), member.getProfileImage(), member.getCreatedAt());
+ }
+ }
+
+ public static LoginResponse of(String accessToken, String refreshToken, Member member) {
+ return new LoginResponse(accessToken, refreshToken, UserInfo.from(member));
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java
new file mode 100644
index 000000000..82d0f3550
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java
@@ -0,0 +1,42 @@
+package spring.backend.auth.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.member.domain.value.Gender;
+import spring.backend.member.domain.value.Role;
+
+import java.time.LocalDateTime;
+
+public record LoginUserInfoResponse(
+ @Schema(description = "사용자 유형(MEMBER, GUEST)", example = "MEMBER")
+ Role role,
+
+ @Schema(description = "사용자 이메일", example = "example@example.com")
+ String email,
+
+ @Schema(description = "사용자 닉네임", example = "john_doe")
+ String nickname,
+
+ @Schema(description = "사용자 출생 연도", example = "1990")
+ int birthYear,
+
+ @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE")
+ Gender gender,
+
+ @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg")
+ String profileImage,
+
+ @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954")
+ LocalDateTime registrationDate
+) {
+ public static LoginUserInfoResponse from(LoginResponse loginResponse) {
+ return new LoginUserInfoResponse(
+ loginResponse.userInfo().role(),
+ loginResponse.userInfo().email(),
+ loginResponse.userInfo().nickname(),
+ loginResponse.userInfo().birthYear(),
+ loginResponse.userInfo().gender(),
+ loginResponse.userInfo().profileImage(),
+ loginResponse.userInfo().registrationDate()
+ );
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java
new file mode 100644
index 000000000..b6327a6f3
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java
@@ -0,0 +1,20 @@
+package spring.backend.auth.presentation.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+
+@Getter
+public class OAuthAccessTokenResponse {
+
+ @JsonProperty("access_token")
+ private String accessToken;
+
+ @JsonProperty("expires_in")
+ private long expiresIn;
+
+ @JsonProperty("refresh_token")
+ private String refreshToken;
+
+ @JsonProperty("token_type")
+ private String tokenType;
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java
new file mode 100644
index 000000000..2da417378
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java
@@ -0,0 +1,19 @@
+package spring.backend.auth.presentation.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class OAuthResourceResponse {
+
+ private String id;
+
+ private String email;
+
+ @JsonProperty("verified_email")
+ private boolean verifiedEmail;
+
+ private String name;
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java
new file mode 100644
index 000000000..490b9d43d
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java
@@ -0,0 +1,31 @@
+package spring.backend.auth.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import spring.backend.member.domain.value.Gender;
+
+import java.time.LocalDateTime;
+
+public record OnboardingSignUpResponse(
+
+ @Schema(description = "사용자 이메일", example = "example@example.com")
+ String email,
+
+ @Schema(description = "사용자 닉네임", example = "john_doe")
+ String nickname,
+
+ @Schema(description = "사용자 출생 연도", example = "1990")
+ int birthYear,
+
+ @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE")
+ Gender gender,
+
+ @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg")
+ String profileImage,
+
+ @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954")
+ LocalDateTime registrationDate
+){
+ public static OnboardingSignUpResponse of(String email, String nickname, int birthYear, Gender gender, String profileImage, LocalDateTime registrationDate) {
+ return new OnboardingSignUpResponse(email, nickname, birthYear, gender, profileImage, registrationDate);
+ }
+}
diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java
new file mode 100644
index 000000000..b1902eae0
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java
@@ -0,0 +1,4 @@
+package spring.backend.auth.presentation.dto.response;
+
+public record RotateTokenResponse(String accessToken, String refreshToken) {
+}
diff --git a/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java
new file mode 100644
index 000000000..11c75ec5e
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java
@@ -0,0 +1,29 @@
+package spring.backend.auth.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+
+@Tag(name = "Auth", description = "인증/인가")
+public interface LogoutSwagger {
+
+ @Operation(
+ summary = "로그아웃 API",
+ description = "사용자의 로그아웃을 진행합니다. \n\n 로그아웃 시, 사용자의 토큰이 무효화되어, 다시 로그인을 진행해야 합니다.",
+ operationId = "/v1/logout"
+ )
+ @ApiErrorCode({
+ GlobalErrorCode.class, AuthenticationErrorCode.class
+ })
+ ResponseEntity> logout(
+ @Parameter(description = "쿠키에 있는 access_token", required = false)
+ String accessToken,
+ @Parameter(description = "쿠키에 있는 refresh_token", required = false)
+ String refreshToken
+ );
+}
diff --git a/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java
new file mode 100644
index 000000000..0e5138c62
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java
@@ -0,0 +1,26 @@
+package spring.backend.auth.presentation.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest;
+import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.exception.MemberErrorCode;
+
+@Tag(name = "Auth", description = "인증/인가")
+public interface OnboardingSignUpSwagger {
+
+ @Operation(
+ summary = "가입 온보딩 API",
+ description = "사용자가 닉네임, 나이, 성별, 프로필사진을 입력하여 회원가입을 진행합니다",
+ operationId = "/v1/members/onboard"
+ )
+ @ApiErrorCode({GlobalErrorCode.class, AuthenticationErrorCode.class, MemberErrorCode.class})
+ ResponseEntity> onboardingSignUp(@Parameter(hidden = true) Member member, OnboardingSignUpRequest request);
+}
diff --git a/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java
new file mode 100644
index 000000000..406be4c1e
--- /dev/null
+++ b/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java
@@ -0,0 +1,29 @@
+package spring.backend.auth.presentation.swagger;
+
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.auth.presentation.dto.response.RotateTokenResponse;
+import spring.backend.core.configuration.swagger.ApiErrorCode;
+import spring.backend.core.exception.error.GlobalErrorCode;
+import spring.backend.core.presentation.RestResponse;
+
+import java.io.IOException;
+
+@Tag(name = "Auth", description = "인증/인가")
+public interface RotateTokenSwagger {
+ @Operation(
+ summary = "토큰 재발급 API",
+ description = "Access Token이 만료된 경우, Refresh Token을 이용하여 새로운 Access Token을 발급합니다",
+ operationId = "/v1/token/rotate"
+ )
+ @ApiErrorCode({GlobalErrorCode.class, AuthenticationErrorCode.class})
+ ResponseEntity> rotateToken(
+ @Parameter(description = "쿠키에 있는 refresh_token", required = false)
+ String refreshToken,
+ @Parameter(hidden = true) String ip
+ ) throws IOException, GeoIp2Exception;
+}
diff --git a/src/main/java/spring/backend/core/application/GeoLocationService.java b/src/main/java/spring/backend/core/application/GeoLocationService.java
new file mode 100644
index 000000000..72595637e
--- /dev/null
+++ b/src/main/java/spring/backend/core/application/GeoLocationService.java
@@ -0,0 +1,51 @@
+package spring.backend.core.application;
+
+import com.maxmind.geoip2.DatabaseReader;
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import com.maxmind.geoip2.model.CityResponse;
+import com.maxmind.geoip2.record.Location;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import spring.backend.core.util.geo.dto.response.Coordinate;
+
+import java.io.IOException;
+import java.net.InetAddress;
+
+import static spring.backend.core.util.geo.GeoUtil.calculateDistanceBetweenTwoCoordinate;
+
+@Service
+@Log4j2
+public class GeoLocationService {
+ private final DatabaseReader reader;
+ private final int MAX_DISTANCE;
+
+ public GeoLocationService(@Value("${geo.max-distance}") int maxDistance, DatabaseReader reader) {
+ this.reader = reader;
+ this.MAX_DISTANCE = maxDistance;
+ }
+
+ public boolean checkUserLocation(String newIp, String savedIp) throws IOException, GeoIp2Exception {
+ Coordinate newIpCoordinate = getCoordinate(newIp);
+ Coordinate savedIpCoordinate = getCoordinate(savedIp);
+
+ double distanceBetweenIp = calculateDistanceBetweenTwoCoordinate(
+ savedIpCoordinate.latitude(),
+ savedIpCoordinate.longitude(),
+ newIpCoordinate.latitude(),
+ newIpCoordinate.longitude()
+ );
+
+ return distanceBetweenIp > MAX_DISTANCE;
+ }
+
+ private Coordinate getCoordinate(String ip) throws GeoIp2Exception, IOException {
+ InetAddress ipAddress = InetAddress.getByName(ip);
+ CityResponse response = reader.city(ipAddress);
+ Location location = response.getLocation();
+ return Coordinate.of(
+ location.getLatitude(),
+ location.getLongitude()
+ );
+ }
+}
diff --git a/src/main/java/spring/backend/core/application/JwtService.java b/src/main/java/spring/backend/core/application/JwtService.java
new file mode 100644
index 000000000..8f1886a49
--- /dev/null
+++ b/src/main/java/spring/backend/core/application/JwtService.java
@@ -0,0 +1,124 @@
+package spring.backend.core.application;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SignatureException;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.member.domain.entity.Member;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Map;
+import java.util.UUID;
+
+
+@Service
+@Log4j2
+public class JwtService {
+
+ @Getter
+ @RequiredArgsConstructor
+ public enum Type {
+ ACCESS("access"), REFRESH("refresh");
+ private final String type;
+ }
+
+ private final SecretKey SECRET_KEY;
+ private final long ACCESS_EXPIRATION;
+ private final long REFRESH_EXPIRATION;
+
+ public JwtService(@Value("${jwt.secret}") String secret, @Value("${jwt.access-token-expiry}") long accessTokenExpiry, @Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry) {
+ this.SECRET_KEY = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ this.ACCESS_EXPIRATION = accessTokenExpiry;
+ this.REFRESH_EXPIRATION = refreshTokenExpiry;
+ }
+
+ public String provideAccessToken(Member member) {
+ return provideToken(
+ member.getEmail(),
+ member.getId(),
+ Type.ACCESS,
+ ACCESS_EXPIRATION,
+ ""
+ );
+ }
+
+ public String provideRefreshToken(Member member, String ip) {
+ return provideToken(
+ member.getEmail(),
+ member.getId(),
+ Type.REFRESH,
+ REFRESH_EXPIRATION,
+ ip
+ );
+ }
+
+ public Claims getPayload(String token) {
+ try {
+ return Jwts.parser()
+ .verifyWith(SECRET_KEY)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ } catch (SignatureException e) {
+ log.error("Invalid signature", e);
+ throw AuthenticationErrorCode.INVALID_SIGNATURE.toException();
+ } catch (ExpiredJwtException e) {
+ log.error("Expired token", e);
+ throw AuthenticationErrorCode.EXPIRED_TOKEN.toException();
+ } catch (MalformedJwtException e) {
+ log.error("Invalid token format", e);
+ throw AuthenticationErrorCode.NOT_MATCH_TOKEN_FORMAT.toException();
+ } catch (Exception e) {
+ log.error("Failed to parse token", e);
+ throw AuthenticationErrorCode.NOT_DEFINE_TOKEN.toException();
+ }
+ }
+
+ public UUID extractMemberId(String token) {
+ Claims claims = getPayload(token);
+ return UUID.fromString(claims.get("memberId", String.class));
+ }
+
+ public void validateTokenExpiration(String token) {
+ Claims claims = getPayload(token);
+ if (claims.getExpiration().before(new Date())) {
+ log.error("Token has expired, token: {}", token);
+ throw AuthenticationErrorCode.EXPIRED_TOKEN.toException();
+ }
+ }
+
+ private String provideToken(String email, UUID id, Type type, long expiration, String ip) {
+ Date expiryDate;
+ Map claims;
+ if (type == Type.ACCESS) {
+ expiryDate = Date.from(Instant.now().plus(expiration, ChronoUnit.SECONDS));
+ claims = Map.of(
+ "memberId", id.toString(),
+ "email", email
+ );
+ } else {
+ expiryDate = Date.from(Instant.now().plus(expiration, ChronoUnit.DAYS));
+ claims = Map.of(
+ "ip", ip
+ );
+ }
+ return Jwts.builder()
+ .claims(claims)
+ .issuedAt(new Date())
+ .expiration(expiryDate)
+ .signWith(SECRET_KEY)
+ .compact();
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java b/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java
new file mode 100644
index 000000000..d7de49c2a
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java
@@ -0,0 +1,21 @@
+package spring.backend.core.configuration;
+
+import org.springframework.beans.BeansException;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ApplicationContextProvider implements ApplicationContextAware {
+
+ private static ApplicationContext applicationContext;
+
+ public static T getBean(String name, Class requiredType) {
+ return applicationContext.getBean(name, requiredType);
+ }
+
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ ApplicationContextProvider.applicationContext = applicationContext;
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java b/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java
new file mode 100644
index 000000000..aed540bb8
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java
@@ -0,0 +1,24 @@
+package spring.backend.core.configuration;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfiguration {
+
+ @Bean
+ public Executor mailExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(10);
+ executor.setMaxPoolSize(30);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("MailExecutor-");
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java b/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java
new file mode 100644
index 000000000..cc68c664c
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java
@@ -0,0 +1,21 @@
+package spring.backend.core.configuration;
+
+import com.maxmind.db.CHMCache;
+import com.maxmind.geoip2.DatabaseReader;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+
+@Configuration
+public class GeoIpConfiguration {
+
+ @Bean
+ public DatabaseReader databaseReader() throws IOException {
+ ClassPathResource resource = new ClassPathResource("maxmind/GeoLite2-City.mmdb");
+ return new DatabaseReader.Builder(resource.getInputStream())
+ .withCache(new CHMCache())
+ .build();
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java b/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java
new file mode 100644
index 000000000..9c6c32c2f
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java
@@ -0,0 +1,71 @@
+package spring.backend.core.configuration;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.amqp.core.Binding;
+import org.springframework.amqp.core.BindingBuilder;
+import org.springframework.amqp.core.CustomExchange;
+import org.springframework.amqp.core.Queue;
+import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
+import org.springframework.amqp.support.converter.MessageConverter;
+import org.springframework.boot.autoconfigure.amqp.RabbitProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+@RequiredArgsConstructor
+public class RabbitMQConfiguration {
+
+ private final RabbitProperties rabbitProperties;
+
+ private final FinishActivityQueueProperty finishActivityQueueProperty;
+
+ @Bean
+ RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
+ RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
+ rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter());
+ return rabbitTemplate;
+ }
+
+ @Bean
+ public MessageConverter rabbitMessageConverter() {
+ return new Jackson2JsonMessageConverter();
+ }
+
+ @Bean
+ public ConnectionFactory rabbitConnectionFactory() {
+ CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
+ connectionFactory.setHost(rabbitProperties.getHost());
+ connectionFactory.setPort(rabbitProperties.getPort());
+ connectionFactory.setUsername(rabbitProperties.getUsername());
+ connectionFactory.setPassword(rabbitProperties.getPassword());
+ connectionFactory.setCacheMode(CachingConnectionFactory.CacheMode.CHANNEL);
+ return connectionFactory;
+ }
+
+ @Bean
+ public CustomExchange finishActivityExchange() {
+ Map args = new HashMap<>();
+ args.put("x-delayed-type", "direct");
+ return new CustomExchange(finishActivityQueueProperty.getExchange(), "x-delayed-message", true, false, args);
+ }
+
+ @Bean
+ Queue finishActivityQueue() {
+ return new Queue(finishActivityQueueProperty.getQueue(), false);
+ }
+
+ @Bean
+ Binding bindingFinishActivityQueue(CustomExchange finishActivityExchange) {
+ return BindingBuilder.bind(finishActivityQueue())
+ .to(finishActivityExchange)
+ .with(finishActivityQueueProperty.getRoutingKey())
+ .noargs();
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java b/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java
new file mode 100644
index 000000000..e8a06297d
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java
@@ -0,0 +1,48 @@
+package spring.backend.core.configuration;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import spring.backend.core.configuration.argumentresolver.AuthorizedMemberArgumentResolver;
+import spring.backend.core.configuration.argumentresolver.ClientIpArgumentResolver;
+import spring.backend.core.configuration.argumentresolver.LoginMemberArgumentResolver;
+import spring.backend.core.configuration.interceptor.AuthorizationInterceptor;
+
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebMvcConfiguration implements WebMvcConfigurer {
+
+ private final AuthorizationInterceptor authorizationInterceptor;
+
+ private final LoginMemberArgumentResolver loginMemberArgumentResolver;
+
+ private final AuthorizedMemberArgumentResolver authorizedMemberArgumentResolver;
+
+ private final ClientIpArgumentResolver clientIpArgumentResolver;
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins("http://localhost:3000", "https://cnergy.kro.kr", "https://cnergy.p-e.kr", "http://localhost:5173")
+ .allowedMethods("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
+ .allowCredentials(true)
+ .maxAge(3000);
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(authorizationInterceptor);
+ }
+
+ @Override
+ public void addArgumentResolvers(List resolvers) {
+ resolvers.add(clientIpArgumentResolver);
+ resolvers.add(loginMemberArgumentResolver);
+ resolvers.add(authorizedMemberArgumentResolver);
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java
new file mode 100644
index 000000000..2d3ea6494
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java
@@ -0,0 +1,11 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AuthorizedMember {
+}
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java
new file mode 100644
index 000000000..1cbb7e397
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java
@@ -0,0 +1,32 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.exception.MemberErrorCode;
+
+@Component
+@RequiredArgsConstructor
+public class AuthorizedMemberArgumentResolver implements HandlerMethodArgumentResolver {
+
+ private final LoginMemberArgumentResolver loginMemberArgumentResolver;
+
+ @Override
+ public boolean supportsParameter(MethodParameter parameter) {
+ return parameter.hasParameterAnnotation(AuthorizedMember.class);
+ }
+
+ @Override
+ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
+ Member authorizedMember = (Member) loginMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
+ if (authorizedMember == null || !authorizedMember.isMember()) {
+ throw MemberErrorCode.NOT_AUTHORIZED_MEMBER.toException();
+ }
+ return authorizedMember;
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java
new file mode 100644
index 000000000..47e720434
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java
@@ -0,0 +1,11 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ClientIp {
+}
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java
new file mode 100644
index 000000000..d04f6d43a
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java
@@ -0,0 +1,69 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.context.request.ServletWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+import java.util.stream.Stream;
+
+@Component
+@Log4j2
+public class ClientIpArgumentResolver implements HandlerMethodArgumentResolver {
+
+ private static final String[] IP_HEADER_CANDIDATES = {
+ "X-Forwarded-For",
+ "Proxy-Client-IP",
+ "WL-Proxy-Client-IP",
+ "HTTP_CLIENT_IP",
+ "HTTP_X_FORWARDED_FOR",
+ "X-Real-IP"
+ };
+
+ private static final String LOCAL_HOST_IPV6 = "0:0:0:0:0:0:0:1";
+
+ @Override
+ public boolean supportsParameter(MethodParameter parameter) {
+ return parameter.hasParameterAnnotation(ClientIp.class);
+ }
+
+ @Override
+ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
+ HttpServletRequest request = ((ServletWebRequest)webRequest).getRequest();
+ return extractIpFromServlet(request);
+ }
+
+ private String extractIpFromServlet(HttpServletRequest request) {
+ String ipList = Stream.of( IP_HEADER_CANDIDATES)
+ .map(request::getHeader)
+ .filter(header -> header != null && !header.isEmpty())
+ .findFirst()
+ .orElseGet(request::getRemoteAddr);
+ String ip = getIpFromIpList(ipList);
+
+ return ip;
+ }
+
+ private String getIpFromIpList(String ipList) {
+ if (ipList.contains(",")) {
+ return Stream.of(ipList.split(","))
+ .map(String::trim)
+ .filter(ip -> !ip.equalsIgnoreCase("unknown"))
+ .findFirst()
+ .orElse("");
+
+ }
+
+ if (ipList.equals(LOCAL_HOST_IPV6)) {
+ return "127.0.0.1";
+ }
+
+ return ipList;
+ }
+}
+
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java
new file mode 100644
index 000000000..4b180cc29
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java
@@ -0,0 +1,11 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LoginMember {
+}
diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java
new file mode 100644
index 000000000..16ccd064a
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java
@@ -0,0 +1,80 @@
+package spring.backend.core.configuration.argumentresolver;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+import spring.backend.core.application.JwtService;
+import spring.backend.member.domain.entity.Member;
+import spring.backend.member.domain.repository.MemberRepository;
+import spring.backend.member.exception.MemberErrorCode;
+
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.UUID;
+
+@Component
+@RequiredArgsConstructor
+@Log4j2
+public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
+
+ public static final String AUTHORIZATION_HEADER = "Authorization";
+
+ public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer ";
+
+ private final JwtService jwtService;
+
+ private final MemberRepository memberRepository;
+
+ @Override
+ public boolean supportsParameter(MethodParameter parameter) {
+ return parameter.hasParameterAnnotation(LoginMember.class);
+ }
+
+ @Override
+ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
+ String token = extractToken(webRequest);
+ if (token == null) {
+ log.error("토큰이 존재하지 않습니다.");
+ throw new IllegalArgumentException("토큰이 존재하지 않습니다.");
+ }
+
+ UUID memberId = jwtService.extractMemberId(token);
+ Member member = memberRepository.findById(memberId);
+ return Optional.ofNullable(member).orElseThrow(MemberErrorCode.NOT_EXIST_MEMBER::toException);
+ }
+
+ private String extractToken(NativeWebRequest request) {
+ HttpServletRequest httpRequest = request.getNativeRequest(HttpServletRequest.class);
+
+ if (httpRequest != null) {
+ if (isSwaggerRequest(httpRequest)) {
+ String authHeader = httpRequest.getHeader(AUTHORIZATION_HEADER);
+ if (authHeader != null && authHeader.startsWith(AUTHORIZATION_BEARER_PREFIX)) {
+ return authHeader.substring(7);
+ }
+ }
+
+ Cookie[] cookies = httpRequest.getCookies();
+ if (cookies != null) {
+ return Arrays.stream(cookies)
+ .filter(cookie -> "access_token".equals(cookie.getName()))
+ .findFirst()
+ .map(Cookie::getValue)
+ .orElse(null);
+ }
+ }
+ return null;
+ }
+
+ private boolean isSwaggerRequest(HttpServletRequest request) {
+ String referer = request.getHeader("Referer");
+ return referer != null && referer.contains("/swagger-ui");
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java b/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java
new file mode 100644
index 000000000..74d5fe0a9
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java
@@ -0,0 +1,50 @@
+package spring.backend.core.configuration.email;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import java.util.Properties;
+
+@Configuration
+@RequiredArgsConstructor
+public class MailConfiguration {
+
+ @Value("${spring.mail.host}")
+ private String host;
+
+ @Value("${spring.mail.username}")
+ private String username;
+
+ @Value("${spring.mail.password}")
+ private String password;
+
+ @Value("${spring.mail.port}")
+ private int port;
+
+ @Value("${spring.mail.properties.mail.smtp.auth}")
+ private String smtpAuth;
+
+ @Value("${spring.mail.properties.mail.debug}")
+ private String debug;
+
+ @Bean
+ public JavaMailSender javaMailSender() {
+ JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+ mailSender.setHost(host);
+ mailSender.setPort(port);
+ mailSender.setUsername(username);
+ mailSender.setPassword(password);
+
+ Properties props = mailSender.getJavaMailProperties();
+ props.put("mail.transport.protocol", "smtp");
+ props.put("mail.smtp.auth", smtpAuth);
+ props.put("mail.smtp.starttls.enable", smtpAuth);
+ props.put("mail.debug", debug);
+
+ return mailSender;
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java b/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java
new file mode 100644
index 000000000..1fc418e04
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java
@@ -0,0 +1,11 @@
+package spring.backend.core.configuration.interceptor;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Authorization {
+}
diff --git a/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java b/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java
new file mode 100644
index 000000000..8aa052f28
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java
@@ -0,0 +1,75 @@
+package spring.backend.core.configuration.interceptor;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+import spring.backend.auth.exception.AuthenticationErrorCode;
+import spring.backend.core.application.JwtService;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+@Log4j2
+public class AuthorizationInterceptor implements HandlerInterceptor {
+
+ public static final String AUTHORIZATION_HEADER = "Authorization";
+
+ public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer ";
+
+ private final JwtService jwtService;
+
+ private static final List PASS_THROUGH_PATTERNS = Arrays.asList(
+ "/swagger-ui", "/v3/api-docs", "/v1/oauth", "/v1/token/rotate"
+ );
+
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+
+ if (isPassThroughRequest(request.getRequestURI())) {
+ return true;
+ }
+ String accessToken = extractToken(request);
+ log.info("accessToken: {}", accessToken);
+ if (accessToken == null) {
+ log.error("쿠키에 토큰이 존재하지 않습니다.");
+ throw AuthenticationErrorCode.NOT_EXIST_TOKEN_In_COOKIE.toException();
+ }
+ jwtService.validateTokenExpiration(accessToken);
+ return true;
+ }
+
+ private boolean isPassThroughRequest(String uri) {
+ return PASS_THROUGH_PATTERNS.stream().anyMatch(uri::contains);
+ }
+
+ private String extractToken(HttpServletRequest request) {
+ if (isSwaggerRequest(request)) {
+ String authHeader = request.getHeader(AUTHORIZATION_HEADER);
+ if (authHeader != null && authHeader.startsWith(AUTHORIZATION_BEARER_PREFIX)) {
+ return authHeader.substring(7);
+ }
+ }
+
+ Cookie[] cookies = request.getCookies();
+ if (cookies != null) {
+ for (Cookie cookie : cookies) {
+ if ("access_token".equals(cookie.getName())) {
+ return cookie.getValue();
+ }
+ }
+ }
+ return null;
+ }
+
+ private boolean isSwaggerRequest(HttpServletRequest request) {
+ String referer = request.getHeader("Referer");
+ return referer != null && referer.contains("/swagger-ui");
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/ImageProperty.java b/src/main/java/spring/backend/core/configuration/property/ImageProperty.java
new file mode 100644
index 000000000..3b679c6f5
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/ImageProperty.java
@@ -0,0 +1,27 @@
+package spring.backend.core.configuration.property;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@Getter
+@Setter
+@ConfigurationProperties("image")
+public class ImageProperty {
+
+ private String selfDevelopmentImageUrl;
+ private String healthImageUrl;
+ private String cultureArtImageUrl;
+ private String entertainmentImageUrl;
+ private String relaxationImageUrl;
+ private String socialImageUrl;
+
+ private String transparent30SelfDevelopmentImageUrl;
+ private String transparent30HealthImageUrl;
+ private String transparent30CultureArtImageUrl;
+ private String transparent30EntertainmentImageUrl;
+ private String transparent30RelaxationImageUrl;
+ private String transparent30SocialImageUrl;
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java
new file mode 100644
index 000000000..29a35f815
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java
@@ -0,0 +1,14 @@
+package spring.backend.core.configuration.property.oauth;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty;
+
+@Component
+@Getter
+@Setter
+@ConfigurationProperties("oauth2.google")
+public class GoogleOAuthProperty extends BaseOAuthProperty {
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java
new file mode 100644
index 000000000..e316cd3fa
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java
@@ -0,0 +1,14 @@
+package spring.backend.core.configuration.property.oauth;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty;
+
+@Component
+@Getter
+@Setter
+@ConfigurationProperties("oauth2.kakao")
+public class KakaoOAuthProperty extends BaseOAuthProperty {
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java
new file mode 100644
index 000000000..c7445eb41
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java
@@ -0,0 +1,14 @@
+package spring.backend.core.configuration.property.oauth;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty;
+
+@Component
+@Getter
+@Setter
+@ConfigurationProperties("oauth2.naver")
+public class NaverOAuthProperty extends BaseOAuthProperty {
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java
new file mode 100644
index 000000000..ce10e3901
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java
@@ -0,0 +1,25 @@
+package spring.backend.core.configuration.property.oauth.shared;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Set;
+
+@Getter
+@Setter
+public class BaseOAuthProperty {
+
+ protected String clientId;
+
+ protected String clientSecret;
+
+ protected String redirectUri;
+
+ protected Set scope;
+
+ protected String tokenUri;
+
+ protected String resourceUri;
+
+ protected String authUri;
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java b/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java
new file mode 100644
index 000000000..7fdd12d79
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java
@@ -0,0 +1,10 @@
+package spring.backend.core.configuration.property.queue;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import spring.backend.core.configuration.property.queue.shared.BaseQueueProperty;
+
+@Component
+@ConfigurationProperties("finish-activity-queue")
+public class FinishActivityQueueProperty extends BaseQueueProperty {
+}
diff --git a/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java b/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java
new file mode 100644
index 000000000..352ecede3
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java
@@ -0,0 +1,15 @@
+package spring.backend.core.configuration.property.queue.shared;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class BaseQueueProperty {
+
+ protected String exchange;
+
+ protected String queue;
+
+ protected String routingKey;
+}
diff --git a/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java b/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java
new file mode 100644
index 000000000..f195716a4
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java
@@ -0,0 +1,29 @@
+package spring.backend.core.configuration.redis;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+
+@Configuration
+public class RedisConfiguration {
+ @Value("${spring.data.redis.host}")
+ private String host;
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Bean
+ public LettuceConnectionFactory redisConnectionFactory() {
+ return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
+ }
+
+ @Bean
+ public RedisTemplate, ?> redisTemplate() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory());
+ return redisTemplate;
+ }
+}
diff --git a/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java b/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java
new file mode 100644
index 000000000..d34b2faab
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java
@@ -0,0 +1,14 @@
+package spring.backend.core.configuration.swagger;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import spring.backend.core.exception.error.BaseErrorCode;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ApiErrorCode {
+
+ Class extends BaseErrorCode>[] value();
+}
\ No newline at end of file
diff --git a/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java b/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java
new file mode 100644
index 000000000..78d454c67
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java
@@ -0,0 +1,16 @@
+package spring.backend.core.configuration.swagger;
+
+import io.swagger.v3.oas.models.examples.Example;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class ExampleHolder {
+
+ private Example holder;
+
+ private int code;
+
+ private String name;
+}
diff --git a/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java b/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java
new file mode 100644
index 000000000..e7f9c56a4
--- /dev/null
+++ b/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java
@@ -0,0 +1,101 @@
+package spring.backend.core.configuration.swagger;
+
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.info.Info;
+import io.swagger.v3.oas.annotations.servers.Server;
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.examples.Example;
+import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.MediaType;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.security.SecurityScheme.In;
+import io.swagger.v3.oas.models.security.SecurityScheme.Type;
+import org.springdoc.core.customizers.OperationCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.HandlerMethod;
+import spring.backend.core.exception.error.BaseErrorCode;
+import spring.backend.core.presentation.ErrorResponse;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Configuration
+@OpenAPIDefinition(info = @Info(title = "조각조각 API", description = "조각조각 : API 명세서", version = "v1.0.0"),
+ servers = {@Server(url = "${springdoc.server-url}", description = "Https Server URL")})
+public class SwaggerConfiguration {
+
+ @Bean
+ public OpenAPI openAPI(){
+ SecurityScheme securityScheme = new SecurityScheme()
+ .type(Type.HTTP).scheme("bearer").bearerFormat("JWT")
+ .in(In.HEADER).name("Authorization");
+ SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth");
+
+ return new OpenAPI()
+ .components(new Components().addSecuritySchemes("bearerAuth", securityScheme))
+ .security(Arrays.asList(securityRequirement));
+ }
+
+ @Bean
+ public OperationCustomizer operationCustomizer() {
+ return (Operation operation, HandlerMethod handlerMethod) -> {
+ ApiErrorCode apiErrorCode = handlerMethod.getMethodAnnotation(ApiErrorCode.class);
+ if (apiErrorCode != null) {
+ generateErrorCodeResponseExample(operation, apiErrorCode.value());
+ }
+ return operation;
+ };
+ }
+
+ private void generateErrorCodeResponseExample(Operation operation, Class extends BaseErrorCode>[] types) {
+ ApiResponses responses = operation.getResponses();
+ List exampleHolders = new ArrayList<>();
+
+ for (Class extends BaseErrorCode> type : types) {
+ BaseErrorCode[] errorCodes = type.getEnumConstants();
+ Arrays.stream(errorCodes).map(
+ baseErrorCode -> ExampleHolder.builder()
+ .holder(getSwaggerExample(baseErrorCode))
+ .code(baseErrorCode.getHttpStatus().value())
+ .name(baseErrorCode.name())
+ .build()
+ ).forEach(exampleHolders::add);
+ }
+
+ Map> statusWithExampleHolders = new HashMap<>(
+ exampleHolders.stream()
+ .collect(Collectors.groupingBy(ExampleHolder::getCode)));
+
+ addExamplesToResponses(responses, statusWithExampleHolders);
+ }
+
+ private Example getSwaggerExample(BaseErrorCode baseErrorCode) {
+ ErrorResponse errorResponse = ErrorResponse.createSwaggerErrorResponse()
+ .baseErrorCode(baseErrorCode)
+ .build();
+ Example example = new Example();
+ example.setValue(errorResponse);
+ return example;
+ }
+
+ private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) {
+ statusWithExampleHolders.forEach(
+ (status, value) -> {
+ Content content = new Content();
+ MediaType mediaType = new MediaType();
+ ApiResponse apiResponse = new ApiResponse();
+ value.forEach(exampleHolder -> mediaType.addExamples(exampleHolder.getName(),
+ exampleHolder.getHolder()));
+ content.addMediaType("application/json", mediaType);
+ apiResponse.setContent(content);
+ responses.addApiResponse(status.toString(), apiResponse);
+ }
+ );
+ }
+}
diff --git a/src/main/java/spring/backend/core/converter/ImageConverter.java b/src/main/java/spring/backend/core/converter/ImageConverter.java
new file mode 100644
index 000000000..cf8a8be29
--- /dev/null
+++ b/src/main/java/spring/backend/core/converter/ImageConverter.java
@@ -0,0 +1,53 @@
+package spring.backend.core.converter;
+
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import spring.backend.activity.domain.value.Keyword.Category;
+import spring.backend.core.configuration.property.ImageProperty;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class ImageConverter {
+
+ private final ImageProperty imageProperty;
+ private final Map categoryImageMap = new HashMap<>();
+ private final Map categoryTransparent30ImageMap = new HashMap<>();
+
+ @PostConstruct
+ private void initializeImageMap() {
+ categoryImageMap.put(Category.SELF_DEVELOPMENT, imageProperty.getSelfDevelopmentImageUrl());
+ categoryImageMap.put(Category.HEALTH, imageProperty.getHealthImageUrl());
+ categoryImageMap.put(Category.CULTURE_ART, imageProperty.getCultureArtImageUrl());
+ categoryImageMap.put(Category.ENTERTAINMENT, imageProperty.getEntertainmentImageUrl());
+ categoryImageMap.put(Category.RELAXATION, imageProperty.getRelaxationImageUrl());
+ categoryImageMap.put(Category.SOCIAL, imageProperty.getSocialImageUrl());
+ }
+
+ @PostConstruct
+ private void initializeTransparent30ImageMap() {
+ categoryTransparent30ImageMap.put(Category.SELF_DEVELOPMENT, imageProperty.getTransparent30SelfDevelopmentImageUrl());
+ categoryTransparent30ImageMap.put(Category.HEALTH, imageProperty.getTransparent30HealthImageUrl());
+ categoryTransparent30ImageMap.put(Category.CULTURE_ART, imageProperty.getTransparent30CultureArtImageUrl());
+ categoryTransparent30ImageMap.put(Category.ENTERTAINMENT, imageProperty.getTransparent30EntertainmentImageUrl());
+ categoryTransparent30ImageMap.put(Category.RELAXATION, imageProperty.getTransparent30RelaxationImageUrl());
+ categoryTransparent30ImageMap.put(Category.SOCIAL, imageProperty.getTransparent30SocialImageUrl());
+ }
+
+ public String convertToImageUrl(Category category) {
+ if (category == null) {
+ return null;
+ }
+ return categoryImageMap.get(category);
+ }
+
+ public String convertToTransparent30ImageUrl(Category category) {
+ if (category == null) {
+ return null;
+ }
+ return categoryTransparent30ImageMap.get(category);
+ }
+}
diff --git a/src/main/java/spring/backend/core/exception/DomainException.java b/src/main/java/spring/backend/core/exception/DomainException.java
new file mode 100644
index 000000000..a1689ec0b
--- /dev/null
+++ b/src/main/java/spring/backend/core/exception/DomainException.java
@@ -0,0 +1,28 @@
+package spring.backend.core.exception;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+import spring.backend.core.exception.error.BaseErrorCode;
+
+@Getter
+public class DomainException extends RuntimeException {
+
+ private HttpStatus httpStatus;
+
+ private String code;
+
+ public DomainException(String message) {
+ super(message);
+ }
+
+ public DomainException(HttpStatus httpStatus, String message) {
+ super(message);
+ this.httpStatus = httpStatus;
+ }
+
+ public DomainException(HttpStatus httpStatus, BaseErrorCode> errorCode) {
+ super(errorCode.getMessage());
+ this.httpStatus = httpStatus;
+ this.code = errorCode.name();
+ }
+}
diff --git a/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java b/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java
new file mode 100644
index 000000000..3d2d07013
--- /dev/null
+++ b/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java
@@ -0,0 +1,46 @@
+package spring.backend.core.exception;
+
+import io.sentry.Sentry;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+import spring.backend.core.presentation.ErrorResponse;
+
+import java.util.Optional;
+
+@RestControllerAdvice
+@Log4j2
+public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
+
+ @ExceptionHandler(Exception.class)
+ public final ResponseEntity handleAllExceptions(Exception ex, WebRequest request) {
+ log.error("ERROR ::: [AllException] ", ex);
+ Sentry.captureException(ex);
+ ErrorResponse errorResponse = ErrorResponse.createErrorResponse().statusCode(500).exception(ex).build();
+ return ResponseEntity.internalServerError().body(errorResponse);
+ }
+
+ @ExceptionHandler(DomainException.class)
+ public final ResponseEntity handleDomainException(DomainException ex) {
+ HttpStatus httpStatus = Optional.ofNullable(ex.getHttpStatus()).orElse(HttpStatus.INTERNAL_SERVER_ERROR);
+ log.error("ERROR ::: [DomainException] ", ex);
+ Sentry.captureException(ex);
+ ErrorResponse errorResponse = ErrorResponse.createDomainErrorResponse().statusCode(httpStatus.value()).exception(ex).build();
+ return ResponseEntity.status(httpStatus).body(errorResponse);
+ }
+
+ @Override
+ protected ResponseEntity