diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..99c2d677
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,90 @@
+language: "ko-KR"
+early_access: false
+tone_instructions: |
+ 당신은 '예비 / 초기 창업가를 위한 사업계획서 플랫폼 StarLight'의 도메인 전문가 겸 코드 리뷰어입니다.
+ 목표는 StarLight 벡엔드 개발자들이 Spring Boot/Java 21 기반으로 헥사고날 아키텍처를 통해서 API 서버를 개발하는데, 코드 품질을 개선하며 기능구현을 하도록 돕는 것입니다.
+
+reviews:
+ ## 리뷰 스타일
+ profile: chill
+
+ ## 시 생성 비활성화
+ poem: false
+
+ ## 자동으로 “Changes requested” 상태로 바꾸지 않음
+ request_changes_workflow: false
+
+ ## 상단에 리뷰 요약을 자동 삽입하도록 설정
+ high_level_summary: true
+
+ ## 리뷰 요약이 들어갈 PR 본문 내 플레이스홀더 지정
+ high_level_summary_placeholder: "@coderabbit summary"
+
+ ## 자동 추천 리뷰어 기능 비활성화
+ suggested_reviewers: false
+
+ ## 코드의 시퀀스 다이어그램
+ sequence_diagrams: true
+
+ ## 자동 리뷰 기능 설정
+ auto_review:
+ ## PR이 생성될 때 자동으로 리뷰 수행
+ enabled: true
+
+ ## 변경된 부분만 재리뷰
+ auto_incremental_review: true
+
+ ## PR 제목에 아래 키워드가 포함되면 리뷰 제외
+ ignore_title_keywords: [
+ "Docs",
+ "Merge branch",
+ "Revert"
+ ]
+
+## 코드 리뷰 시 참조할 지식 베이스 설정
+knowledge_base:
+ ## 웹 검색을 통한 외부 자료 참조 허용
+ web_search:
+ enabled: true
+
+ ## 프로젝트 내부 문서를 기반으로 리뷰
+ code_guidelines:
+ enabled: true
+ filePatterns:
+ - 개발가이드.md
+ - 도메인모델.md
+ - 용어사전.md
+
+## 정적 분석 / 린트 도구
+tools:
+ ## Java 코드 정적 분석 (PMD)
+ pmd:
+ enabled: true
+
+ ## SQL 문법 검사
+ sqlfluff:
+ enabled: true
+
+ ## 보안 취약점 검사 도구
+ gitleaks:
+ enabled: true
+
+ ## 코드 패턴 기반 취약점 탐지
+ semgrep:
+ enabled: true
+
+ ## GitHub Actions 워크플로우 YAML 문법 검사
+ actionlint:
+ enabled: true
+
+ ## Dockerfile 린트 검사
+ hadolint:
+ enabled: true
+
+ ## YAML 전체 문법 검사
+ yamllint:
+ enabled: true
+
+## ChatGPT 스타일의 대화형 응답을 자동으로 활성화
+chat:
+ auto_reply: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..94c2a073
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,18 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: 'FEAT: '
+labels: "✨ Feature"
+assignees: ''
+
+---
+
+## 📝 Description
+> 이슈 설명
+
+## 📝 Todo
+- [ ]
+
+## 📝 참고 사항
+>
+
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..90659791
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,14 @@
+## #️⃣연관된 이슈
+> close #
+
+## 📝작업 내용
+> 작업한 내용을 작성해주세요.
+
+## 🔎코드 설명
+> 코드에 대한 설명을 작성해주세요.
+
+## 💬고민사항 및 리뷰 요구사항
+> 고민사항 및 의견 받고 싶은 부분 있으면 적어두기
+
+## 비고 (Optional)
+> 참고했던 링크 등 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다.
diff --git a/.github/workflows/ci-prd.yaml b/.github/workflows/ci-prd.yaml
new file mode 100644
index 00000000..cae027c1
--- /dev/null
+++ b/.github/workflows/ci-prd.yaml
@@ -0,0 +1,79 @@
+name: Deployment Workflow
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ env:
+ SPRING_PROFILES_ACTIVE: test
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT }}
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build with Gradle Wrapper
+ run: ./gradlew clean build --info --stacktrace --no-daemon
+
+ - name: Docker login
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+ - name: Set image tag
+ id: vars
+ run: echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV
+
+ - name: Build Docker image
+ run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} -f deploy/Dockerfile .
+
+ - name: Docker Hub push
+ run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }}
+
+ - name: Checkout manifest repository
+ uses: actions/checkout@v4
+ with:
+ repository: 'StartUpLight/STARLIGHT_MANIFEST'
+ token: ${{ secrets.PAT }}
+ path: 'manifest'
+
+ - name: Update deployment.yml
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ IMAGE_TAG: ${{ env.IMAGE_TAG }}
+ run: |
+ sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" manifest/production/deployment.yml
+
+ # 변경사항 확인
+ echo "Updated deployment.yml:"
+ cat manifest/production/deployment.yml
+
+ - name: Commit and push changes
+ env:
+ IMAGE_TAG: ${{ env.IMAGE_TAG }}
+ run: |
+ cd manifest
+ git config --local user.email "kjeng7897@gmail.com"
+ git config --local user.name "SeongHo5356"
+ git add production/deployment.yml
+ git commit -m "Update image tag to $IMAGE_TAG" || exit 0
+ git push
\ No newline at end of file
diff --git a/.github/workflows/ci-stg.yaml b/.github/workflows/ci-stg.yaml
new file mode 100644
index 00000000..36655ffb
--- /dev/null
+++ b/.github/workflows/ci-stg.yaml
@@ -0,0 +1,79 @@
+name: Deployment Workflow
+
+on:
+ push:
+ branches: [ "develop" ]
+ pull_request:
+ branches: [ "develop" ]
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ env:
+ SPRING_PROFILES_ACTIVE: test
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT }}
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build with Gradle Wrapper
+ run: ./gradlew clean build --info --stacktrace --no-daemon
+
+ - name: Docker login
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+ - name: Set image tag
+ id: vars
+ run: echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV
+
+ - name: Build Docker image
+ run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} -f deploy/Dockerfile .
+
+ - name: Docker Hub push
+ run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }}
+
+ - name: Checkout manifest repository
+ uses: actions/checkout@v4
+ with:
+ repository: 'StartUpLight/STARLIGHT_MANIFEST'
+ token: ${{ secrets.PAT }}
+ path: 'manifest'
+
+ - name: Update deployment.yml
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ IMAGE_TAG: ${{ env.IMAGE_TAG }}
+ run: |
+ sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" manifest/staging/deployment.yml
+
+ # 변경사항 확인
+ echo "Updated deployment.yml:"
+ cat manifest/staging/deployment.yml
+
+ - name: Commit and push changes
+ env:
+ IMAGE_TAG: ${{ env.IMAGE_TAG }}
+ run: |
+ cd manifest
+ git config --local user.email "kjeng7897@gmail.com"
+ git config --local user.name "SeongHo5356"
+ git add staging/deployment.yml
+ git commit -m "Update image tag to $IMAGE_TAG" || exit 0
+ git push
\ No newline at end of file
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 00000000..6b9bea16
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,40 @@
+name: PR Test
+
+permissions:
+ contents: read # checkout, 빌드 스크립트 읽기 권한
+ checks: write # Test Results 액션이 check-run 생성/수정 권한
+ issues: write # 이슈에 코멘트 작성 권한
+ pull-requests: write # PR에 코멘트 작성 권한
+
+on:
+ pull_request:
+ branches: [ develop, main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code with submodules
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT }}
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: temurin
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Test with Gradle
+ run: ./gradlew --info test
+
+ - name: Publish Test Results
+ uses: EnricoMi/publish-unit-test-result-action@v2
+ if: ${{ always() }}
+ with:
+ files: build/test-results/**/*.xml
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b5ceafdf..e4857ae4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,10 +36,6 @@ out/
### VS Code ###
.vscode/
-src/main/resources/application.properties
-src/main/resources/application-dev.properties
-src/main/resources/application-stage.properties
-src/main/resources/application-prod.properties
.env
.DS_Store
*.p8
@@ -49,5 +45,3 @@ src/main/resources/application-prod.properties
node_modules/
dist/
*.log
-
-deploy/docker-compose.yaml
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..b9a602de
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "config"]
+ path = config
+ url = https://github.com/StartUpLight/config.git
diff --git a/README.md b/README.md
index 16385451..0ac83b82 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,168 @@
-# STARLIGHT_BE
+# Starlight Server
+대학생 IT경영학회 큐시즘 32기 밋업 프로젝트 4조 Starlight 백엔드 레포지토리
+
+
+
+
+## 👬 Member
+| 정성호 | 이호근 |
+| :------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------: |
+|
|
|
+| [@SeongHo5356](https://github.com/SeongHo5356) | [@2ghrms](https://github.com/2ghrms) |
+
+
+
+## 📝 Technology Stack
+| Category | Technology |
+|----------------------|---------------------------------------------------------------------------|
+| **Language** | Java 21 |
+| **Framework** | Spring Boot 3.3.10 |
+| **Databases** | Postgresql, Redis |
+| **Authentication** | JWT, Spring Security, OAuth2.0 |
+| **Development Tools**| Lombok |
+| **API Documentation**| Swagger UI (SpringDoc) |
+| **Storage** | AWS S3, Naver Object Storage |
+| **Infrastructure** | Terraform, NCP Server, HashiCorp Vault |
+| **Build Tools** | Gradle |
+| **Monitoring** | Prometheus, Grafana, Loki, Promtail |
+
+
+
+## 📅 ERD
+https://www.erdcloud.com/d/bEeEkcvDoau3kf7W5
+
+
+
+
+## 🔨 Project Architecture
+
+
+
+
+## ⭐️ 기술스택/선정이유
+
+**1️⃣ Java 21**
+
+- Java 21은 최신 언어 기능(예: 패턴 매칭, 레코드, 가상 스레드 등)을 제공하여 코드의 가독성과 유지보수성을 높이며, 개발 생산성을 향상시킵니다.
+- 최신 버전의 자바는 성능 최적화와 효율적인 메모리 관리 기능이 개선되어, 대규모 애플리케이션에서도 안정적이고 빠른 실행이 가능합니다.
+- 장기 지원 버전이므로, 앞으로의 유지보수와 안정성 측면에서 신뢰할 수 있는 기반을 제공합니다.
+
+**2️⃣ SpringBoot 3.4.9**
+- 클라우드 네이티브 최적화: Jakarta EE 기반의 높은 성숙도를 갖추고 있으며, 헬스체크·Actuator·Micrometer 등 컨테이너 및 클라우드 운영에 필수적인 관측성 기능을 기본으로 제공합니다.
+- 성능 및 보안 강화: 개선된 AOT/Native 이미지 지원을 통해 콜드스타트를 최적화할 수 있으며, 신속한 보안 패치로 안정적인 서비스 운영을 돕습니다.
+- 생산성 및 유지보수: 표준화된 스타터와 강력한 자동 설정(Auto Configuration)으로 개발 복잡도를 낮추고, 팀 온보딩 및 유지보수 비용을 최소화합니다.
+
+**3️⃣ SpringData JPA**
+
+- Spring Data JPA는 데이터베이스와의 인터랙션을 단순화하고, 불필요한 보일러플레이트 코드를 줄여 개발 효율성을 높여줍니다.
+
+**4️⃣ Spring AI**
+- LLM 통합 단순화: Spring 생태계에 통합된 AI 프레임워크로, 복잡한 LLM 연동 과정을 간소화합니다.
+- 유연한 추상화 레이어: OpenAI, Pinecone 등 다양한 제공업체에 대한 추상화 레이어를 제공하여 벤더 교체가 용이합니다.
+- 높은 생산성: Spring Boot 자동 설정 및 WebFlux를 지원하며, 벡터 검색·프롬프트 템플릿·체이닝 등 RAG 패턴을 쉽게 구현할 수 있습니다.
+
+**5️⃣ MySQL**
+- 검증된 안정성: 성숙한 InnoDB 엔진과 풍부한 운영 레퍼런스를 보유하고 있으며, TCO(총 소유 비용)가 낮습니다.
+- 기능 및 생태계: 8.x 버전의 CTE와 윈도우 함수로 복잡한 쿼리에 대응 가능하며, 리플리케이션·백업·모니터링 도구가 잘 갖춰져 있습니다.
+- OLTP 최적화: HikariCP 커넥션 풀과 적절한 인덱싱을 통해 대규모 트래픽에서도 안정적인 운영이 가능합니다.
+
+**6️⃣ NCP (CLOVA Studio, Server, Object Storage)**
+- 안정적 인프라: 네이버 클라우드 플랫폼(NCP)의 서버 인프라와 Object Storage를 활용하여 안정적이고 보안성이 뛰어난 클라우드 환경을 제공합니다.
+- AI 서비스 연동: CLOVA Studio와의 연동을 통해 프로젝트의 AI 기능을 효율적으로 지원하고 운영 효율성을 높입니다.
+
+**7️⃣ ArgoCD, K3s**
+- ArgoCD (GitOps): Pull 기반 배포로 'Git 상태=배포 상태'를 보장하며, 자동 동기화 및 자가 치유(Self-healing)로 운영 복잡도를 줄입니다.
+- K3s (경량 K8s): 리소스가 제한된 환경이나 스테이징/소규모 서비스에 최적화된 가벼운 쿠버네티스입니다.
+- 표준 호환성: Helm, Ingress 등 표준 K8s 도구를 그대로 사용할 수 있어 진입 장벽이 낮고, 추후 매니지드 서비스로의 확장이 쉽습니다.
+
+**8️⃣ Promtail, Loki, Prometheus, Grafana**
+- 로그 수집 및 검색 (Loki): Promtail이 수집한 애플리케이션 및 시스템 로그를 Loki로 전송하여 대용량 로그를 효율적으로 인덱싱하고 검색합니다.
+- 메트릭 모니터링 (Prometheus): Pull 방식을 통해 API 응답 시간, CPU, 메모리 등의 시계열 데이터를 수집합니다.
+- 데이터 시각화 (Grafana): 수집된 로그와 메트릭 데이터를 통합 대시보드로 시각화하여 시스템 상태를 한눈에 파악합니다.
+
+**9️⃣ Flannel + WireGuard VPN**
+- 네트워크 연결성: 서로 다른 네트워크(VPC, IDC, 공인망)에 분산된 노드 간의 통신을 지원합니다.
+- 보안 통신 보장: K3s 클러스터 내에서 WireGuard VPN을 통해 빠르고 안전한 Pod-to-Pod 보안 통신을 보장합니다.
+
+**🔟 Nginx**
+- 고성능 리버스 프록시: 경량화된 구조로 TLS 종료, 정적 자산 서빙, 라우팅 및 리라이트 처리에 강점을 가집니다.
+- 백엔드 보호: 헬스체크, 타임아웃, 버퍼링 등의 세밀한 튜닝을 통해 백엔드 애플리케이션의 부하를 줄이고 성능을 보장합니다.
+- 유연한 배치: 쿠버네티스 환경에서 Ingress Controller 또는 엣지 프록시로 활용하여 트래픽을 효율적으로 관리합니다.
+
+**1️⃣1️⃣ K6**
+- 사용자 흐름 테스트: JavaScript를 사용하여 실제 사용자 행동(로그인 → API 호출 → 스케줄링)을 스크립트화하고 REST API 성능을 측정합니다.
+- 커스텀 시나리오: vus(가상 사용자), stages 등의 옵션으로 스파이크 테스트나 지속성 테스트 등 다양한 부하 시나리오를 검증합니다.
+- 결과 시각화: 응답 시간, 처리량, 에러율 등의 메트릭을 수집하고 Grafana와 연동하여 테스트 결과를 시각적으로 분석합니다.
+
+
+
+
+## 💬 Convention
+
+**commit convention**
+`#이슈번호 conventionType: 구현한 내용`
+
+
+**convention Type**
+| convention type | description |
+| --- | --- |
+| `feat` | 새로운 기능 구현 |
+| `chore` | 부수적인 코드 수정 및 기타 변경사항 |
+| `docs` | 문서 추가 및 수정, 삭제 |
+| `fix` | 버그 수정 |
+| `test` | 테스트 코드 추가 및 수정, 삭제 |
+| `refactor` | 코드 리팩토링 |
+
+
+
+## 🪵 Branch
+###
+- `컨벤션명/#이슈번호-작업내용`
+- pull request를 통해 develop branch에 merge 후, branch delete
+- 부득이하게 develop branch에 직접 commit 해야 할 경우, `!hotfix:` 사용
+
+
+
+## 📁 Directory
+
+```PlainText
+src/
+├── main/
+│ ├── domain/
+│ │ ├── entity/
+│ │ ├── controller/
+│ │ ├── service/
+│ │ ├── repository/
+│ │ └── dto/
+ ├── request/
+ └── response/
+│ ├── global/
+│ │ ├── apiPayload/
+│ │ ├── config/
+│ │ ├── security/
+
+```
+
+
+
+## 📈 부하테스트
+각 플랫폼(Instagram, Facebook, Threads) API에는 계정/시간 당 발행 가능한 게시물 수에 제한이 있어, 부하 테스트에는 제약이 존재합니다. 이에 따라 저희는 즉시 발행이 아닌 **"예약 발행" API**를 활용한 부하 시뮬레이션 방식을 구성하였습니다.
+
+|시나리오 ① 10명이 1초 동안 최대한의 요청을 보낸다.| 시나리오 ② 2000명이 1초 동안 최대한의 요청을 보낸다.|
+| :-------| :-------|
+|||
+|✅ 총 120개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 82.09 ms
- 최소 요청 처리 시간 : 22.52ms
- 최대 요청 처리 시간 : 164.64ms |✅ 총 4002개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 7.74s
- 최소 요청 처리 시간 : 21.9s
- 최대 요청 처리 시간 : 18.28s
- 95th 퍼센타일 : 14.95s|
+
+
+
+| 시나리오 ③ 사용자 수 변동 시나리오 | 시나리오 ④ 응답 시간이 5초 이내인 최대 요청 수 파악|
+| :-------|:----|
+|||
+|0초 ~ 2초 : `50명`, 2초 ~ 12초 : `300명`, 12초 ~ 17초 : `1000명`, 17초 ~ 18초 : `500명`| 5초가 지날 경우 사용자 이탈이 늘어날 것이라고 판단하여 1초 동안 `1000명`의 사용자가 요청을 보내 `요청 처리 시간이 5초 이내`인 요청 개수를 파악 |
+|✅ 총 3789개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 1.94s
- 최소 요청 처리 시간 : 20.53ms
- 최대 요청 처리 시간 : 7.88s |✅ 총 2002개의 요청이 시간 내 처리됨 |
+
+### 테스트 결과 분석
+- 현재 시스템은 동시 약 `1,000건` 수준까지는 안정적으로 요청을 처리할 수 있는 것으로 보입니다. **시나리오 ③**처럼 사용자 수가 점차 증가하는 상황에서도 평균 응답 시간은 `1.94초`, 최대 응답 시간은 `7.88초`로, 대부분의 요청이 정상적으로 처리되었습니다.
+- 하지만 **시나리오 ②**처럼 `2,000명`의 동시 요청이 들어오면 평균 응답 시간이 `7.74초`, 최대 `18.28초`까지 증가하면서 응답 지연이 발생하였습니다. 이 결과는 대규모 트래픽에 대한 성능 한계가 있음을 보여주며, 추후 이를 개선할 필요가 있습니다.
+- **시나리오 ④**에서는 `1000명`의 사용자가 동시에 요청을 보낸 경우, 총 `2,002건`의 요청이 `5초` 이내에 처리되었습니다. 이는 현재 시스템이 실시간 대응보다는 예약 처리에 더 적합한 구조임을 보여줍니다.
+- 일반적으로 사용자 이탈이 늘기 시작하는 5초 이내 응답을 기준으로 예상 접속자 수 약 `1,000명` 정도에 대해서는 충분히 안정적인 성능을 제공할 수 있다고 판단됩니다.
diff --git a/build.gradle b/build.gradle
index 4a42dceb..d8afb991 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,12 @@
plugins {
id 'java'
+ id 'jacoco'
id 'org.springframework.boot' version '3.4.10'
id 'io.spring.dependency-management' version '1.1.7'
}
+springBoot { buildInfo() }
+
group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'Light up the Start Up Eco System'
@@ -14,28 +17,16 @@ java {
}
}
-configurations {
- compileOnly {
- extendsFrom annotationProcessor
- }
-}
-
repositories {
mavenCentral()
+ maven { url 'https://jitpack.io' }
}
-dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- implementation 'org.springframework.boot:spring-boot-starter-security'
- implementation 'org.springframework.boot:spring-boot-starter-web'
- compileOnly 'org.projectlombok:lombok'
- runtimeOnly 'com.h2database:h2'
- annotationProcessor 'org.projectlombok:lombok'
- 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()
-}
+apply from: 'gradle/config.gradle'
+apply from: 'gradle/database.gradle'
+apply from: 'gradle/docs.gradle'
+apply from: 'gradle/jwt.gradle'
+apply from: 'gradle/spring.gradle'
+apply from: 'gradle/test.gradle'
+apply from: 'gradle/util.gradle'
+apply from: 'gradle/ai.gradle'
diff --git a/config b/config
new file mode 160000
index 00000000..3a581e52
--- /dev/null
+++ b/config
@@ -0,0 +1 @@
+Subproject commit 3a581e527b6117d5929ba57a0e2c8c8b90d9d14a
diff --git a/data/feedback_test.py b/data/feedback_test.py
new file mode 100644
index 00000000..5b922604
--- /dev/null
+++ b/data/feedback_test.py
@@ -0,0 +1,248 @@
+"""
+피드백 요청 API 성능 테스트 스크립트 (병렬 버전)
+- 파일 크기별로 병렬 호출
+- 스레드별 requests.Session 재사용(커넥션 풀)
+- 결과 CSV 저장 및 요약 출력
+"""
+
+import os
+import time
+import csv
+import threading
+import statistics
+from datetime import datetime
+from io import BytesIO
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+import requests
+from requests.adapters import HTTPAdapter
+
+# ============== 설정 ==============
+BASE_URL = "http://localhost:8080/v1/expert-applications"
+EXPERT_ID = 6 # Path Variable
+BUSINESS_PLAN_ID = 2 # Request Param
+
+# 인증 토큰 (JWT) - 환경변수 권장: export AUTH_TOKEN="xxxxx"
+AUTH_TOKEN = ""
+
+# 테스트할 파일 크기 (MB)
+FILE_SIZES_MB = [5, 10, 15, 20]
+
+# 각 크기별 반복 횟수
+ITERATIONS_PER_SIZE = 10
+
+# 크기별 동시 실행 스레드 수 (예: 5). 과도하면 서버를 과부하시킬 수 있음.
+CONCURRENCY_PER_SIZE = 5
+
+# 요청 타임아웃(초)
+REQUEST_TIMEOUT = None
+
+# (선택) 워밍업 요청 개수: 첫 연결/템플릿 로딩 등 콜드스타트 흡수
+WARMUP_REQUESTS = 0
+# ===================================
+
+# 스레드별 세션 보관소
+_thread_local = threading.local()
+# 출력 동기화용
+_print_lock = threading.Lock()
+
+
+def get_session() -> requests.Session:
+ """
+ 스레드별 Session을 생성/재사용.
+ 커넥션 풀을 늘려 다중 연결 병렬성을 확보.
+ """
+ sess = getattr(_thread_local, "session", None)
+ if sess is None:
+ sess = requests.Session()
+ # 풀 크기는 동시성보다 여유 있게
+ pool_size = max(10, CONCURRENCY_PER_SIZE * 2)
+ adapter = HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size)
+ sess.mount("http://", adapter)
+ sess.mount("https://", adapter)
+ _thread_local.session = sess
+ return sess
+
+
+def create_dummy_pdf_bytes(size_mb: int) -> bytes:
+ """
+ 더미 PDF 바이트 생성 (헤더 + 패딩)
+ 매 호출 시 BytesIO를 새로 감싸 쓰되 원본 바이트는 캐시해 재사용.
+ """
+ pdf_header = b"%PDF-1.4\n"
+ size_bytes = size_mb * 1024 * 1024
+ if size_bytes < len(pdf_header):
+ size_bytes = len(pdf_header)
+ return pdf_header + (b"0" * (size_bytes - len(pdf_header)))
+
+
+def send_feedback_request(file_bytes: bytes, file_size_mb: int):
+ """
+ 피드백 요청 API 호출 (단일 요청)
+ """
+ try:
+ # 각 요청마다 새로운 BytesIO(스트림 포지션 충돌 방지)
+ file_obj = BytesIO(file_bytes)
+
+ url = f"{BASE_URL}/{EXPERT_ID}/request"
+ files = {"file": (f"test_{file_size_mb}MB.pdf", file_obj, "application/pdf")}
+ params = {"businessPlanId": BUSINESS_PLAN_ID}
+ headers = {}
+ if AUTH_TOKEN:
+ headers["Authorization"] = f"Bearer {AUTH_TOKEN}"
+
+ sess = get_session()
+ start = time.perf_counter()
+ resp = sess.post(url, files=files, params=params, headers=headers, timeout=REQUEST_TIMEOUT)
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
+
+ success = (resp.status_code == 200)
+ error_msg = None if success else (resp.text[:200] if resp.text else f"HTTP {resp.status_code}")
+ return success, elapsed_ms, resp.status_code, error_msg
+
+ except requests.exceptions.Timeout:
+ return False, None, None, "Timeout"
+ except Exception as e:
+ return False, None, None, str(e)[:200]
+
+
+def run_test_parallel():
+ print("=" * 70)
+ print("📊 피드백 요청 API 성능 테스트 (병렬)")
+ print("=" * 70)
+ print(f"API URL: {BASE_URL}/{EXPERT_ID}/request")
+ print(f"Expert ID: {EXPERT_ID} (Path Variable)")
+ print(f"Business Plan ID: {BUSINESS_PLAN_ID} (Request Param)")
+ print(f"테스트 파일 크기: {FILE_SIZES_MB} MB")
+ print(f"각 크기별 반복 횟수: {ITERATIONS_PER_SIZE}")
+ print(f"크기별 동시성: {CONCURRENCY_PER_SIZE}")
+ print("=" * 70)
+
+ # 결과 저장
+ all_results = []
+
+ # 크기별 워밍업 (옵션)
+ if WARMUP_REQUESTS > 0:
+ print("\n🔥 워밍업 시작...")
+ fb = create_dummy_pdf_bytes(FILE_SIZES_MB[0])
+ for _ in range(WARMUP_REQUESTS):
+ send_feedback_request(fb, FILE_SIZES_MB[0])
+ print("🔥 워밍업 종료")
+
+ for size_mb in FILE_SIZES_MB:
+ print(f"\n🔍 [{size_mb}MB 파일] 병렬 테스트 시작")
+ print("-" * 70)
+
+ file_bytes = create_dummy_pdf_bytes(size_mb)
+ size_results_times = []
+ success_count = 0
+
+ futures = []
+ with ThreadPoolExecutor(max_workers=CONCURRENCY_PER_SIZE) as ex:
+ for i in range(1, ITERATIONS_PER_SIZE + 1):
+ futures.append(ex.submit(send_feedback_request, file_bytes, size_mb))
+
+ for idx, fut in enumerate(as_completed(futures), start=1):
+ success, elapsed_ms, status_code, err = fut.result()
+
+ # 결과 집계
+ if success and elapsed_ms is not None:
+ success_count += 1
+ size_results_times.append(elapsed_ms)
+
+ # 결과 저장(행 단위)
+ all_results.append({
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "file_size_mb": size_mb,
+ "iteration": idx,
+ "success": success,
+ "response_time_ms": round(elapsed_ms, 2) if elapsed_ms is not None else None,
+ "status_code": status_code,
+ "error": err
+ })
+
+ # 진행 출력은 락으로 깔끔하게
+ with _print_lock:
+ if success:
+ print(f" ✅ [{idx}/{ITERATIONS_PER_SIZE}] {elapsed_ms:.2f} ms (HTTP {status_code})")
+ else:
+ print(f" ❌ [{idx}/{ITERATIONS_PER_SIZE}] {err}")
+
+ # 크기별 통계
+ if size_results_times:
+ avg_time = statistics.mean(size_results_times)
+ min_time = min(size_results_times)
+ max_time = max(size_results_times)
+ success_rate = (success_count / ITERATIONS_PER_SIZE) * 100
+ print(f"\n📈 [{size_mb}MB] 결과")
+ print(f" 성공률: {success_rate:.1f}% ({success_count}/{ITERATIONS_PER_SIZE})")
+ print(f" 평균: {avg_time:.2f} ms | 최소: {min_time:.2f} ms | 최대: {max_time:.2f} ms")
+ else:
+ print(f"\n❌ [{size_mb}MB] 모든 요청 실패")
+
+ save_results_to_csv(all_results)
+ print_summary(all_results)
+
+
+def save_results_to_csv(results):
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"feedback_test_results_{timestamp}.csv"
+ with open(filename, "w", newline="", encoding="utf-8") as f:
+ fieldnames = ["timestamp", "file_size_mb", "iteration", "success",
+ "response_time_ms", "status_code", "error"]
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
+ writer.writeheader()
+ writer.writerows(results)
+ print(f"\n💾 결과가 저장되었습니다: {filename}")
+
+
+def print_summary(all_results):
+ print("\n" + "=" * 70)
+ print("📊 전체 테스트 요약")
+ print("=" * 70)
+
+ # 크기별 그룹핑
+ by_size = {}
+ for r in all_results:
+ size = r["file_size_mb"]
+ if size not in by_size:
+ by_size[size] = []
+ if r["success"] and r["response_time_ms"] is not None:
+ by_size[size].append(r["response_time_ms"])
+
+ # 표 출력
+ print(f"\n{'파일크기':<10} {'성공률':<10} {'평균(ms)':<12} {'최소(ms)':<12} {'최대(ms)':<12}")
+ print("-" * 70)
+ total_success = 0
+ for size_mb in FILE_SIZES_MB:
+ times = by_size.get(size_mb, [])
+ if times:
+ success_rate = (len(times) / ITERATIONS_PER_SIZE) * 100
+ avg_t = statistics.mean(times)
+ min_t = min(times)
+ max_t = max(times)
+ total_success += len(times)
+ print(f"{size_mb}MB{'':<6} {success_rate:.1f}%{'':<5} "
+ f"{avg_t:.2f}{'':<6} {min_t:.2f}{'':<6} {max_t:.2f}")
+ else:
+ print(f"{size_mb}MB{'':<6} {'0.0%':<10} {'-':<12} {'-':<12} {'-':<12}")
+
+ total_requests = len(FILE_SIZES_MB) * ITERATIONS_PER_SIZE
+ if total_success > 0:
+ all_times = [t for v in by_size.values() for t in v]
+ overall_avg = statistics.mean(all_times)
+ print(f"\n전체 성공률: {(total_success/total_requests)*100:.1f}% "
+ f"({total_success}/{total_requests})")
+ print(f"전체 평균 응답시간: {overall_avg:.2f} ms")
+ print()
+
+
+if __name__ == "__main__":
+ try:
+ run_test_parallel()
+ except KeyboardInterrupt:
+ print("\n\n⚠️ 테스트가 중단되었습니다.")
+ except Exception as e:
+ print(f"\n\n❌ 오류 발생: {e}")
+ import traceback
+ traceback.print_exc()
diff --git a/data/feedback_test_results_20251103_000249.csv b/data/feedback_test_results_20251103_000249.csv
new file mode 100644
index 00000000..7292aa6d
--- /dev/null
+++ b/data/feedback_test_results_20251103_000249.csv
@@ -0,0 +1,41 @@
+timestamp,file_size_mb,iteration,success,response_time_ms,status_code,error
+2025-11-02 23:56:45,5,1,True,20668.33,200,
+2025-11-02 23:56:45,5,2,True,20868.55,200,
+2025-11-02 23:56:45,5,3,True,21233.69,200,
+2025-11-02 23:56:46,5,4,True,21680.87,200,
+2025-11-02 23:56:46,5,5,True,22196.0,200,
+2025-11-02 23:57:05,5,6,True,20567.75,200,
+2025-11-02 23:57:06,5,7,True,21385.54,200,
+2025-11-02 23:57:06,5,8,True,21212.89,200,
+2025-11-02 23:57:07,5,9,True,21174.66,200,
+2025-11-02 23:57:07,5,10,True,20765.67,200,
+2025-11-02 23:57:45,10,1,True,37923.18,200,
+2025-11-02 23:57:46,10,2,True,38570.55,200,
+2025-11-02 23:57:46,10,3,True,38669.21,200,
+2025-11-02 23:57:47,10,4,True,39942.81,200,
+2025-11-02 23:57:48,10,5,True,40790.38,200,
+2025-11-02 23:58:23,10,6,True,37937.61,200,
+2025-11-02 23:58:23,10,7,True,37479.75,200,
+2025-11-02 23:58:26,10,8,True,40096.5,200,
+2025-11-02 23:58:26,10,9,True,37929.71,200,
+2025-11-02 23:58:27,10,10,True,39790.88,200,
+2025-11-02 23:59:22,15,1,True,54793.16,200,
+2025-11-02 23:59:22,15,2,True,54836.05,200,
+2025-11-02 23:59:22,15,3,True,55065.84,200,
+2025-11-02 23:59:22,15,4,True,55242.78,200,
+2025-11-02 23:59:23,15,5,True,56406.86,200,
+2025-11-03 00:00:16,15,6,True,54626.29,200,
+2025-11-03 00:00:18,15,7,True,56505.05,200,
+2025-11-03 00:00:18,15,8,True,56737.58,200,
+2025-11-03 00:00:19,15,9,True,55631.56,200,
+2025-11-03 00:00:19,15,10,True,57458.97,200,
+2025-11-03 00:01:34,20,1,False,74583.63,500,"{""result"":""ERROR"",""data"":null,""error"":{""code"":""INTERNAL_ERROR"",""message"":""알 수 없는 내부 오류입니다.""}}"
+2025-11-03 00:01:35,20,2,True,75106.38,200,
+2025-11-03 00:01:36,20,3,False,76066.9,500,"{""result"":""ERROR"",""data"":null,""error"":{""code"":""INTERNAL_ERROR"",""message"":""알 수 없는 내부 오류입니다.""}}"
+2025-11-03 00:01:36,20,4,True,76194.86,200,
+2025-11-03 00:01:36,20,5,True,76408.71,200,
+2025-11-03 00:02:47,20,6,True,73147.09,200,
+2025-11-03 00:02:48,20,7,True,72123.34,200,
+2025-11-03 00:02:48,20,8,True,72701.03,200,
+2025-11-03 00:02:48,20,9,True,73792.32,200,
+2025-11-03 00:02:49,20,10,True,73508.53,200,
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
new file mode 100644
index 00000000..a01896bd
--- /dev/null
+++ b/deploy/Dockerfile
@@ -0,0 +1,12 @@
+# 빌드 단계
+FROM eclipse-temurin:21-jdk AS builder
+WORKDIR /app
+COPY . .
+RUN ./gradlew build -x test
+
+# 실행 단계
+FROM eclipse-temurin:21-jre-alpine AS runtime
+WORKDIR /app
+COPY --from=builder /app/build/libs/*.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
\ No newline at end of file
diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml
new file mode 100644
index 00000000..fc86b377
--- /dev/null
+++ b/deploy/docker-compose.yaml
@@ -0,0 +1,47 @@
+version: "3.9"
+
+name: starLight
+
+services:
+ mysql:
+ image: mysql:8.0
+ container_name: mysql-starLight
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: "root"
+ MYSQL_DATABASE: "starLight"
+ MYSQL_USER: "starLight"
+ MYSQL_PASSWORD: "starLight7897!"
+ TZ: "Asia/Seoul"
+ command:
+ - --default-authentication-plugin=mysql_native_password
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
+ volumes:
+ - mysql_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ container_name: redis-starLight
+ ports:
+ - "6379:6379"
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "PING"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ mysql_data:
+ redis_data:
diff --git a/gradle/ai.gradle b/gradle/ai.gradle
new file mode 100644
index 00000000..734be155
--- /dev/null
+++ b/gradle/ai.gradle
@@ -0,0 +1,17 @@
+ext {
+ set('springAiVersion', "1.0.0")
+}
+
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
+ }
+}
+
+dependencies {
+ implementation 'org.springframework.ai:spring-ai-starter-model-openai'
+ implementation 'org.springframework.ai:spring-ai-starter-vector-store-pinecone'
+ implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
+
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+}
\ No newline at end of file
diff --git a/gradle/config.gradle b/gradle/config.gradle
new file mode 100644
index 00000000..30339b70
--- /dev/null
+++ b/gradle/config.gradle
@@ -0,0 +1,13 @@
+sourceSets {
+ main {
+ resources {
+ srcDir 'config/resources/main'
+ }
+ }
+
+ test {
+ resources {
+ srcDir 'config/resources/test'
+ }
+ }
+}
\ No newline at end of file
diff --git a/gradle/database.gradle b/gradle/database.gradle
new file mode 100644
index 00000000..8ee1d918
--- /dev/null
+++ b/gradle/database.gradle
@@ -0,0 +1,9 @@
+dependencies {
+ // MySQL Driver
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ // JPA, Redis, Spring Session
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.session:spring-session-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+}
\ No newline at end of file
diff --git a/gradle/docs.gradle b/gradle/docs.gradle
new file mode 100644
index 00000000..c25d7526
--- /dev/null
+++ b/gradle/docs.gradle
@@ -0,0 +1,5 @@
+dependencies {
+ // Swagger (OpenAPI)
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
+ implementation 'io.swagger.core.v3:swagger-core-jakarta:2.2.24'
+}
\ No newline at end of file
diff --git a/gradle/jwt.gradle b/gradle/jwt.gradle
new file mode 100644
index 00000000..dcba55f7
--- /dev/null
+++ b/gradle/jwt.gradle
@@ -0,0 +1,7 @@
+dependencies {
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+}
\ No newline at end of file
diff --git a/gradle/spring.gradle b/gradle/spring.gradle
new file mode 100644
index 00000000..dff83b0a
--- /dev/null
+++ b/gradle/spring.gradle
@@ -0,0 +1,25 @@
+jar {
+ enabled = false
+}
+
+bootJar {
+ enabled = true
+}
+
+dependencies {
+ // Spring Boot Starter
+ implementation 'org.springframework.retry:spring-retry'
+ implementation 'org.springframework.boot:spring-boot-starter'
+ implementation 'org.springframework.boot:spring-boot-starter-aop'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+
+ // Emailclient
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+}
diff --git a/gradle/test.gradle b/gradle/test.gradle
new file mode 100644
index 00000000..add1c201
--- /dev/null
+++ b/gradle/test.gradle
@@ -0,0 +1,81 @@
+jacoco {
+ toolVersion = "0.8.11"
+}
+
+dependencies {
+ // DATABASE
+ testRuntimeOnly 'com.h2database:h2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ testImplementation platform("org.junit:junit-bom:5.13.4")
+ testImplementation "org.junit.jupiter:junit-jupiter"
+ testImplementation 'org.assertj:assertj-core:3.26.3'
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ testImplementation "org.mockito:mockito-core:5.19.0"
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
+}
+
+tasks.withType(Test).configureEach {
+ useJUnitPlatform()
+ systemProperty "spring.profiles.active", "test"
+ jvmArgs += ['-Duser.timezone=Asia/Seoul', '-Dfile.encoding=UTF-8']
+
+ finalizedBy jacocoTestReport
+ finalizedBy jacocoTestCoverageVerification
+}
+
+jacocoTestReport {
+ dependsOn test
+
+ reports {
+ xml.required = true
+ html.required = true
+ csv.required = false
+ }
+
+ afterEvaluate {
+ classDirectories.setFrom(files(classDirectories.files.collect {
+ fileTree(dir: it, exclude: [
+ '**/*Application.class',
+ '**/config/**',
+ '**/dto/**',
+ '**/entity/**',
+ '**/exception/**'
+ ])
+ }))
+ }
+}
+
+jacocoTestCoverageVerification {
+ dependsOn jacocoTestReport
+
+ violationRules {
+ rule {
+ element = 'BUNDLE' // 전체 프로젝트 기준
+ limit {
+ counter = 'LINE'
+ value = 'COVEREDRATIO'
+ minimum = 0.0
+ }
+ }
+
+// rule {
+// element = 'CLASS' // 클래스 단위로 검증
+// limit {
+// counter = 'LINE' // 라인 커버리지
+// value = 'COVEREDRATIO' // 커버된 비율
+// minimum = 0.50
+// }
+// }
+//
+// rule {
+// element = 'METHOD' // 메서드 단위로 검증
+// limit {
+// counter = 'LINE'
+// value = 'COVEREDRATIO'
+// minimum = 0.50
+// }
+// }
+ }
+}
\ No newline at end of file
diff --git a/gradle/util.gradle b/gradle/util.gradle
new file mode 100644
index 00000000..ead717f5
--- /dev/null
+++ b/gradle/util.gradle
@@ -0,0 +1,22 @@
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+dependencies {
+ // Lombok
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // HTML Parser
+ implementation("org.jsoup:jsoup:1.18.1")
+
+ // AWS SDK for S3
+ implementation 'software.amazon.awssdk:s3:2.20.40'
+ implementation 'software.amazon.awssdk:auth:2.20.40'
+ implementation 'software.amazon.awssdk:regions:2.20.40'
+
+ // PDF Box
+ implementation 'org.apache.pdfbox:pdfbox:2.0.30'
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/StarlightApplication.java b/src/main/java/starlight/StarlightApplication.java
index ac930b4b..d254238b 100644
--- a/src/main/java/starlight/StarlightApplication.java
+++ b/src/main/java/starlight/StarlightApplication.java
@@ -2,7 +2,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+@EnableJpaAuditing
@SpringBootApplication
public class StarlightApplication {
diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java
new file mode 100644
index 00000000..6ea468e4
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java
@@ -0,0 +1,45 @@
+package starlight.adapter.ai;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import starlight.adapter.ai.infra.OpenAiGenerator;
+import starlight.application.businessplan.required.ChecklistGrader;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.List;
+import java.util.ArrayList;
+import starlight.adapter.ai.util.ChecklistCatalog;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OpenAiChecklistGrader implements ChecklistGrader {
+
+ private final OpenAiGenerator generator;
+ private final ChecklistCatalog checklistCatalog;
+
+ @Override
+ public List check(
+ SubSectionType subSectionType,
+ String content
+ ) {
+ // 1) 서브섹션별 체크리스트 기준 5개 확보
+ List criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType);
+ List detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType);
+
+ // 2) LLM 호출 → Boolean 배열 파싱
+ List result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria);
+
+ // 3) 보정: 항상 길이 5 보장
+ return normalizeToFive(result);
+ }
+
+ private List normalizeToFive(List in) {
+ List out = new ArrayList<>(5);
+ for (int i = 0; i < 5; i++) {
+ out.add(i < in.size() && in.get(i) != null ? in.get(i) : false);
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java
new file mode 100644
index 00000000..510d5181
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java
@@ -0,0 +1,29 @@
+package starlight.adapter.ai;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import starlight.adapter.ai.infra.OpenAiGenerator;
+import starlight.adapter.ai.util.AiReportResponseParser;
+import starlight.application.aireport.provided.dto.AiReportResponse;
+import starlight.application.aireport.required.AiReportGrader;
+
+/**
+ * AI 리포트 채점을 오케스트레이션하는 컴포넌트
+ * 각 단계별 책임을 다른 컴포넌트에 위임하여 단일 책임 원칙을 준수
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OpenAiReportGrader implements AiReportGrader {
+
+ private final OpenAiGenerator chatClientGenerator;
+ private final AiReportResponseParser responseParser;
+
+ @Override
+ public AiReportResponse gradeContent(String content){
+ String llmResponse = chatClientGenerator.generateReport(content);
+
+ return responseParser.parse(llmResponse);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java b/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java
new file mode 100644
index 00000000..61e73481
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java
@@ -0,0 +1,39 @@
+package starlight.adapter.ai.infra;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
+import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
+import org.springframework.ai.vectorstore.SearchRequest;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.core.Ordered;
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class AdvisorProvider {
+
+ private final VectorStore vectorStore;
+
+ public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){
+ SearchRequest.Builder builder = SearchRequest.builder()
+ .similarityThreshold(similarityThreshold)
+ .topK(topK);
+
+ if (filter != null && !filter.trim().isEmpty()) {
+ builder.filterExpression(filter);
+ }
+
+ SearchRequest searchRequest = builder.build();
+
+ return QuestionAnswerAdvisor
+ .builder(vectorStore)
+ .searchRequest(searchRequest)
+ .build();
+ }
+
+ public SimpleLoggerAdvisor getSimpleLoggerAdvisor(){
+ return new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE-1);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java
new file mode 100644
index 00000000..c90ffb21
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java
@@ -0,0 +1,80 @@
+package starlight.adapter.ai.infra;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
+import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.stereotype.Component;
+import starlight.application.infrastructure.provided.LlmGenerator;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OpenAiGenerator implements LlmGenerator {
+
+ private final ChatClient.Builder chatClientBuilder;
+ private final PromptProvider promptProvider;
+ private final AdvisorProvider advisorProvider;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Override
+ public List generateChecklistArray(
+ SubSectionType subSectionType,
+ String content,
+ List criteria,
+ List detailedCriteria
+ ) {
+ Prompt prompt = promptProvider.createChecklistGradingPrompt(
+ subSectionType, content, criteria, detailedCriteria
+ );
+
+ ChatClient chatClient = chatClientBuilder.build();
+
+ SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor();
+
+ String output = chatClient
+ .prompt(prompt)
+ .options(ChatOptions.builder()
+ .temperature(0.1)
+ .topP(0.1)
+ .build())
+ .advisors(slAdvisor)
+ .call()
+ .content();
+
+ try {
+ return objectMapper.readValue(output, new TypeReference>() {
+ });
+ } catch (Exception e) {
+ return List.of(false, false, false, false, false);
+ }
+ }
+
+ @Override
+ public String generateReport(String content) {
+ Prompt prompt = promptProvider.createReportGradingPrompt(content);
+
+ ChatClient chatClient = chatClientBuilder.build();
+ QuestionAnswerAdvisor qaAdvisor = advisorProvider
+ .getQuestionAnswerAdvisor(0.6, 3, null);
+ SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor();
+
+ return chatClient
+ .prompt(prompt)
+ .options(ChatOptions.builder()
+ .temperature(0.0)
+ .topP(0.1)
+ .build())
+ .advisors(qaAdvisor, slAdvisor)
+ .call()
+ .content();
+ }
+}
diff --git a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java
new file mode 100644
index 00000000..9b283363
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java
@@ -0,0 +1,96 @@
+package starlight.adapter.ai.infra;
+
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.prompt.PromptTemplate;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class PromptProvider {
+
+ @Value("${prompt.report.grading.system}")
+ private String reportGradingSystemPrompt;
+
+ @Value("${prompt.report.grading.user}")
+ private String reportGradingUserPromptTemplate;
+
+ @Value("${prompt.checklist.grading.system}")
+ private String checklistGradingSystemPrompt;
+
+ @Value("${prompt.checklist.grading.user.template}")
+ private String checklistGradingUserPromptTemplate;
+
+ /**
+ * 리포트 채점용 Prompt 객체 생성
+ */
+ public Prompt createReportGradingPrompt(String businessPlanContent) {
+ Message systemMessage = new SystemMessage(getReportGradingSystemPrompt());
+ Message userMessage = new UserMessage(buildReportGradingUserPrompt(businessPlanContent));
+ return new Prompt(List.of(systemMessage, userMessage));
+ }
+
+ /**
+ * 체크리스트 채점용 Prompt 객체 생성
+ */
+ public Prompt createChecklistGradingPrompt(
+ SubSectionType subSectionType,
+ String content,
+ List criteria,
+ List detailedCriteria
+ ) {
+ String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria);
+ Message systemMessage = new SystemMessage(checklistGradingSystemPrompt);
+ Message userMessage = new UserMessage(userPrompt);
+ return new Prompt(List.of(systemMessage, userMessage));
+ }
+
+ /**
+ * 리포트 채점용 시스템 프롬프트
+ */
+ private String getReportGradingSystemPrompt() {
+ return reportGradingSystemPrompt;
+ }
+
+ /**
+ * 리포트 채점용 사용자 프롬프트 생성
+ */
+ private String buildReportGradingUserPrompt(String businessPlanContent) {
+ PromptTemplate promptTemplate = new PromptTemplate(reportGradingUserPromptTemplate);
+ Map variables = Map.of("businessPlanContent", businessPlanContent);
+ return promptTemplate.render(variables);
+ }
+
+ /**
+ * 체크리스트 채점용 사용자 프롬프트 생성
+ */
+ private String buildChecklistGradingUserPrompt(
+ SubSectionType subSectionType,
+ String content,
+ List criteria,
+ List detailedCriteria) {
+ // 체크리스트 상세 기준 포맷팅
+ StringBuilder criteriaBuilder = new StringBuilder();
+ for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) {
+ criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n");
+ criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n");
+ }
+ String formattedCriteria = criteriaBuilder.toString().trim();
+
+ Map variables = new HashMap<>();
+ variables.put("subsectionType", subSectionType.getDescription());
+ variables.put("checklistCriteria", formattedCriteria);
+ variables.put("input", content);
+ variables.put("requestLength", criteria.size());
+
+ PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate);
+ return promptTemplate.render(variables);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java
new file mode 100644
index 00000000..7d5c44b8
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java
@@ -0,0 +1,314 @@
+package starlight.adapter.ai.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import starlight.application.aireport.provided.dto.AiReportResponse;
+import starlight.domain.aireport.entity.AiReport;
+import starlight.domain.aireport.exception.AiReportException;
+import starlight.domain.aireport.exception.AiReportErrorType;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AiReportResponseParser {
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * AiReportResponse를 JsonNode로 변환 (저장용)
+ * 또는 JsonNode에서 AiReportResponse로 변환 (조회용)
+ * 통합된 변환 메소드
+ */
+ public JsonNode convertToJsonNode(AiReportResponse response) {
+ ObjectNode rootNode = objectMapper.createObjectNode();
+
+ // 점수 필드
+ rootNode.put("problemRecognitionScore",
+ response.problemRecognitionScore() != null ? response.problemRecognitionScore() : 0);
+ rootNode.put("feasibilityScore",
+ response.feasibilityScore() != null ? response.feasibilityScore() : 0);
+ rootNode.put("growthStrategyScore",
+ response.growthStrategyScore() != null ? response.growthStrategyScore() : 0);
+ rootNode.put("teamCompetenceScore",
+ response.teamCompetenceScore() != null ? response.teamCompetenceScore() : 0);
+
+ // 강점 배열
+ ArrayNode strengthsArray = rootNode.putArray("strengths");
+ if (response.strengths() != null) {
+ for (AiReportResponse.StrengthWeakness strength : response.strengths()) {
+ ObjectNode strengthNode = strengthsArray.addObject();
+ strengthNode.put("title", strength.title() != null ? strength.title() : "");
+ strengthNode.put("content", strength.content() != null ? strength.content() : "");
+ }
+ }
+
+ // 약점 배열
+ ArrayNode weaknessesArray = rootNode.putArray("weaknesses");
+ if (response.weaknesses() != null) {
+ for (AiReportResponse.StrengthWeakness weakness : response.weaknesses()) {
+ ObjectNode weaknessNode = weaknessesArray.addObject();
+ weaknessNode.put("title", weakness.title() != null ? weakness.title() : "");
+ weaknessNode.put("content", weakness.content() != null ? weakness.content() : "");
+ }
+ }
+
+ // 섹션별 점수 배열: sectionType과 gradingListScores
+ ArrayNode sectionScoresArray = rootNode.putArray("sectionScores");
+ if (response.sectionScores() != null) {
+ for (AiReportResponse.SectionScoreDetailResponse sectionScore : response.sectionScores()) {
+ ObjectNode sectionScoreNode = sectionScoresArray.addObject();
+ sectionScoreNode.put("sectionType",
+ sectionScore.sectionType() != null ? sectionScore.sectionType() : "");
+ sectionScoreNode.put("gradingListScores",
+ sectionScore.gradingListScores() != null ? sectionScore.gradingListScores() : "[]");
+ }
+ }
+
+ return rootNode;
+ }
+
+ /**
+ * AiReport에서 AiReportResponse로 변환
+ * 파싱 로직은 AiReportResponseParser를 재사용하고, id와 businessPlanId만 추가
+ */
+ public AiReportResponse toResponse(AiReport aiReport) {
+ JsonNode jsonNode = aiReport.getRawJson().asTree();
+
+ // 공통 파싱 로직 재사용
+ AiReportResponse baseResponse = parseFromJsonNode(jsonNode);
+
+ // totalScore 계산
+ Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() : 0) +
+ (baseResponse.feasibilityScore() != null ? baseResponse.feasibilityScore() : 0) +
+ (baseResponse.growthStrategyScore() != null ? baseResponse.growthStrategyScore() : 0) +
+ (baseResponse.teamCompetenceScore() != null ? baseResponse.teamCompetenceScore() : 0);
+
+ // id와 businessPlanId를 포함하여 새 인스턴스 생성
+ return new AiReportResponse(
+ aiReport.getId(),
+ aiReport.getBusinessPlanId(),
+ totalScore,
+ baseResponse.problemRecognitionScore(),
+ baseResponse.feasibilityScore(),
+ baseResponse.growthStrategyScore(),
+ baseResponse.teamCompetenceScore(),
+ baseResponse.sectionScores(),
+ baseResponse.strengths(),
+ baseResponse.weaknesses()
+ );
+ }
+
+ /**
+ * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인
+ */
+ private boolean isDefaultResponse(AiReportResponse response) {
+ return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) &&
+ (response.feasibilityScore() == null || response.feasibilityScore() == 0) &&
+ (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) &&
+ (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) &&
+ (response.strengths() == null || response.strengths().isEmpty()) &&
+ (response.weaknesses() == null || response.weaknesses().isEmpty()) &&
+ (response.sectionScores() == null || response.sectionScores().isEmpty());
+ }
+
+ /**
+ * LLM 응답 문자열을 AiReportResponse로 파싱
+ * 파싱 실패 시 예외를 던집니다.
+ */
+ public AiReportResponse parse(String llmResponse) {
+ log.debug("Raw LLM response: {}", llmResponse);
+
+ // 1. 기본 검증
+ if (llmResponse == null || llmResponse.trim().isEmpty()) {
+ log.error("LLM response is null or empty");
+ throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
+ }
+
+ try {
+ // 2. JSON 문자열 정리
+ String cleanedJson = cleanJsonResponse(llmResponse);
+ log.debug("Cleaned JSON: {}", cleanedJson);
+
+ // 3. JSON 파싱 시도
+ JsonNode jsonNode = objectMapper.readTree(cleanedJson);
+
+ // 4. 필수 필드 존재 여부 확인
+ if (!jsonNode.has("problemRecognitionScore") ||
+ !jsonNode.has("feasibilityScore") ||
+ !jsonNode.has("growthStrategyScore") ||
+ !jsonNode.has("teamCompetenceScore")) {
+ throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
+ }
+
+ // 5. 파싱 시도
+ AiReportResponse response = parseFromJsonNode(jsonNode);
+
+ // 6. 파싱된 값이 기본값인지 확인
+ if (isDefaultResponse(response)) {
+ log.error("Parsed response is default (all zeros), likely parsing failure");
+ throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
+ }
+
+ return response;
+ } catch (Exception e) {
+ log.error("Failed to parse LLM response. Response: {}", llmResponse, e);
+ throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
+ }
+ }
+
+ /**
+ * JSON 응답 문자열 정리 및 복구
+ */
+ private String cleanJsonResponse(String json) {
+ if (json == null || json.trim().isEmpty()) {
+ return "{}";
+ }
+
+ String cleaned = json.trim();
+
+ // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```)
+ if (cleaned.startsWith("```json")) {
+ cleaned = cleaned.substring(7);
+ } else if (cleaned.startsWith("```")) {
+ cleaned = cleaned.substring(3);
+ }
+ if (cleaned.endsWith("```")) {
+ cleaned = cleaned.substring(0, cleaned.length() - 3);
+ }
+ cleaned = cleaned.trim();
+
+ // 2. "text" 필드에서 JSON 추출 (더 강력한 추출)
+ // 정규식으로 "text" 필드 추출 시도
+ if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) {
+ try {
+ // 먼저 JSON 파싱 시도
+ JsonNode root = objectMapper.readTree(cleaned);
+ if (root.has("text") && root.get("text").isTextual()) {
+ cleaned = root.get("text").asText();
+ }
+ } catch (Exception e) {
+ // JSON 파싱 실패 시 정규식으로 추출 시도
+ try {
+ // "text" : "..." 패턴 찾기
+ java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(
+ "\"text\"\\s*:\\s*\"(.*)\"",
+ java.util.regex.Pattern.DOTALL
+ );
+ java.util.regex.Matcher matcher = pattern.matcher(cleaned);
+ if (matcher.find()) {
+ String extracted = matcher.group(1);
+ // 이스케이프된 문자 처리
+ extracted = extracted.replace("\\n", "\n")
+ .replace("\\\"", "\"")
+ .replace("\\\\", "\\");
+ cleaned = extracted;
+ log.debug("Extracted text field using regex");
+ }
+ } catch (Exception e2) {
+ log.warn("Failed to extract text field using regex: {}", e2.getMessage());
+ }
+ }
+ }
+
+ // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명)
+ cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\"");
+
+ return cleaned;
+ }
+
+ /**
+ * JsonNode를 파싱하여 AiReportResponse로 변환
+ */
+ private AiReportResponse parseFromJsonNode(JsonNode jsonNode) {
+ Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0);
+ Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0);
+ Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0);
+ Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0);
+
+ // 강점 파싱
+ List strengths = parseStrengthWeaknessList(jsonNode.path("strengths"));
+
+ // 약점 파싱
+ List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses"));
+
+ // sectionScores 파싱: sectionType과 gradingListScores만 포함
+ List sectionScores = parseSectionScores(
+ jsonNode.path("sectionScores"));
+
+ return AiReportResponse.fromGradingResult(
+ problemRecognitionScore,
+ feasibilityScore,
+ growthStrategyScore,
+ teamCompetenceScore,
+ sectionScores,
+ strengths,
+ weaknesses);
+ }
+
+ /**
+ * 강점/약점 리스트 파싱
+ */
+ private List parseStrengthWeaknessList(JsonNode node) {
+ List list = new ArrayList<>();
+ if (node.isArray()) {
+ for (JsonNode itemNode : node) {
+ list.add(new AiReportResponse.StrengthWeakness(
+ itemNode.path("title").asText(""),
+ itemNode.path("content").asText("")));
+ }
+ }
+ return list;
+ }
+
+ /**
+ * 섹션 점수 리스트 파싱
+ * 불완전한 항목은 건너뛰거나 기본값으로 대체
+ */
+ private List parseSectionScores(JsonNode node) {
+ List list = new ArrayList<>();
+ if (node.isArray()) {
+ for (JsonNode sectionScoreNode : node) {
+ try {
+ String sectionType = sectionScoreNode.path("sectionType").asText("");
+ String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]");
+
+ // gradingListScores가 유효한 JSON 문자열인지 검증
+ if (!gradingListScores.equals("[]")) {
+ try {
+ // JSON 배열 형식인지 확인
+ if (!gradingListScores.trim().startsWith("[")) {
+ log.warn("Invalid gradingListScores format for sectionType: {}, using default", sectionType);
+ gradingListScores = "[]";
+ } else {
+ // JSON 파싱 가능 여부 확인
+ objectMapper.readTree(gradingListScores);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}",
+ sectionType, gradingListScores);
+ gradingListScores = "[]";
+ }
+ }
+
+ list.add(new AiReportResponse.SectionScoreDetailResponse(sectionType, gradingListScores));
+ } catch (Exception e) {
+ log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage());
+ // 불완전한 항목은 건너뛰기
+ }
+ }
+ }
+ return list;
+ }
+
+}
diff --git a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java
new file mode 100644
index 00000000..86a5236e
--- /dev/null
+++ b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java
@@ -0,0 +1,65 @@
+package starlight.adapter.ai.util;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Component
+@ConfigurationProperties(prefix = "prompt.checklist")
+@Getter
+@Setter
+public class ChecklistCatalog {
+
+ private Map catalog;
+
+ @Getter
+ @Setter
+ public static class CatalogSection {
+ private List items;
+ }
+
+ @Getter
+ @Setter
+ public static class ChecklistItem {
+ private String criteria;
+ private String detailed;
+ }
+
+ // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다
+ public List getCriteriaBySubSectionType(SubSectionType subSectionType) {
+ String tag = subSectionType.getTag();
+ if (catalog == null || !catalog.containsKey(tag)) {
+ return List.of();
+ }
+ CatalogSection section = catalog.get(tag);
+ if (section == null || section.getItems() == null) {
+ return List.of();
+ }
+ return section.getItems().stream()
+ .map(ChecklistItem::getCriteria)
+ .filter(c -> c != null && !c.isEmpty())
+ .collect(Collectors.toList());
+ }
+
+ // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다.
+ public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) {
+ String tag = subSectionType.getTag();
+ if (catalog == null || !catalog.containsKey(tag)) {
+ return List.of();
+ }
+ CatalogSection section = catalog.get(tag);
+ if (section == null || section.getItems() == null) {
+ return List.of();
+ }
+ return section.getItems().stream()
+ .map(ChecklistItem::getDetailed)
+ .filter(d -> d != null && !d.isEmpty())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java
new file mode 100644
index 00000000..0eb8ca06
--- /dev/null
+++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java
@@ -0,0 +1,26 @@
+package starlight.adapter.aireport.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import starlight.application.aireport.required.AiReportQuery;
+import starlight.domain.aireport.entity.AiReport;
+
+import java.util.Optional;
+
+@Component
+@RequiredArgsConstructor
+public class AiReportJpa implements AiReportQuery {
+
+ private final AiReportRepository aiReportRepository;
+
+ @Override
+ public AiReport save(AiReport aiReport) {
+ return aiReportRepository.save(aiReport);
+ }
+
+ @Override
+ public Optional findByBusinessPlanId(Long businessPlanId) {
+ return aiReportRepository.findByBusinessPlanId(businessPlanId);
+ }
+}
+
diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java
new file mode 100644
index 00000000..31c245e4
--- /dev/null
+++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java
@@ -0,0 +1,12 @@
+package starlight.adapter.aireport.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.aireport.entity.AiReport;
+
+import java.util.Optional;
+
+public interface AiReportRepository extends JpaRepository {
+
+ Optional findByBusinessPlanId(Long businessPlanId);
+}
+
diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java
new file mode 100644
index 00000000..b8f0e84c
--- /dev/null
+++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java
@@ -0,0 +1,56 @@
+package starlight.adapter.aireport.webapi;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest;
+import starlight.application.aireport.provided.dto.AiReportResponse;
+import starlight.application.aireport.provided.AiReportService;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@Validated
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/ai-reports")
+@Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API")
+public class AiReportController {
+
+ private final AiReportService aiReportService;
+
+ @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.")
+ @PostMapping("/evaluation/{planId}")
+ public ApiResponse gradeBusinessPlan(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId()));
+ }
+
+ @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.")
+ @PostMapping("/evaluation/pdf")
+ public ApiResponse createAndGradeBusinessPlan(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @Valid @RequestBody BusinessPlanCreateWithPdfRequest request
+ ) {
+ return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan(
+ request.title(),
+ request.pdfUrl(),
+ authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "AI 리포트를 조회합니다.")
+ @GetMapping("/{planId}")
+ public ApiResponse getAiReport(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId()));
+ }
+}
+
diff --git a/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java b/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java
new file mode 100644
index 00000000..e6e69b97
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java
@@ -0,0 +1,79 @@
+package starlight.adapter.auth.redis;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.stereotype.Component;
+import starlight.application.auth.required.KeyValueMap;
+import starlight.shared.apiPayload.exception.GlobalErrorType;
+import starlight.shared.apiPayload.exception.GlobalException;
+
+import java.time.Duration;
+
+@Component
+@RequiredArgsConstructor
+public class RedisKeyValueMap implements KeyValueMap {
+
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * 지정된 키에 값을 저장합니다.
+ *
+ * @param key 저장할 키
+ * @param value 저장할 값
+ * @param timeout (선택 사항) 값의 만료 시간(초 단위). null이면 만료되지 않음.
+ */
+ public void setValue(String key, String value, Long timeout) {
+ try {
+ ValueOperations values = redisTemplate.opsForValue();
+ values.set(key, value, Duration.ofMillis(timeout));
+ } catch (Exception e) {
+ throw new GlobalException(GlobalErrorType.REDIS_SET_ERROR);
+ }
+ }
+
+ /**
+ * 지정된 키에 대한 값을 가져옵니다.
+ *
+ * @param key 값을 가져올 키
+ * @return 키에 해당하는 값, 키가 존재하지 않으면 null
+ */
+ public String getValue(String key) {
+ try {
+ ValueOperations values = redisTemplate.opsForValue();
+ if (values.get(key) == null) {
+ return "";
+ }
+ return values.get(key).toString();
+ } catch (Exception e) {
+ throw new GlobalException(GlobalErrorType.REDIS_GET_ERROR);
+ }
+ }
+
+ /**
+ * 지정된 키에 대한 값을 삭제합니다.
+ *
+ * @param key 삭제할 키
+ */
+ public void deleteValue(String key) {
+ try {
+ redisTemplate.delete(key);
+ } catch (Exception e) {
+ throw new GlobalException(GlobalErrorType.REDIS_DELETE_ERROR);
+ }
+ }
+
+ /**
+ * 지정된 키가 존재하는지 확인합니다.
+ *
+ * @param key 확인할 키
+ * @return 키가 존재하면 true, 그렇지 않으면 false
+ */
+ public boolean checkExistsValue(String key) {
+ try {
+ return redisTemplate.hasKey(key);
+ } catch (Exception e) {
+ throw new GlobalException(GlobalErrorType.REDIS_GET_ERROR);
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java b/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java
new file mode 100644
index 00000000..3f5dc2cd
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java
@@ -0,0 +1,83 @@
+package starlight.adapter.auth.security.auth;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import starlight.domain.member.entity.Member;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+public record AuthDetails(Member member, Map attributes, String nameAttributeKey) implements UserDetails, OAuth2User {
+
+ // 폼 로그인 호환용 보조 생성자
+ public AuthDetails(Member member) {
+ this(member, Collections.emptyMap(), "id");
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ List roles = new ArrayList<>();
+ roles.add(member.getMemberType().toString());
+
+ return roles.stream()
+ .map(SimpleGrantedAuthority::new)
+ .collect(Collectors.toList());
+ }
+
+ public Member getUser() {
+ return member;
+ }
+
+ public Long getMemberId() {
+ return member.getId();
+ }
+
+ @Override
+ public String getPassword() {
+ return "";
+ }
+
+ @Override
+ public String getUsername() {
+ return member.getEmail();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ //OAuth2User
+ @Override public Map getAttributes() {
+ return attributes == null ? Map.of() : Collections.unmodifiableMap(attributes);
+ }
+
+ @Override public String getName() {
+ if (attributes != null && nameAttributeKey != null && attributes.containsKey(nameAttributeKey)) {
+ return String.valueOf(attributes.get(nameAttributeKey));
+ }
+ return member.getId() != null ? String.valueOf(member.getId()) : member.getEmail();
+ }
+
+ public static AuthDetails of(Member member, Map attrs, String nameKey) {
+ return new AuthDetails(member, attrs, nameKey);
+ }
+}
+
diff --git a/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java b/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java
new file mode 100644
index 00000000..4488825d
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java
@@ -0,0 +1,28 @@
+package starlight.adapter.auth.security.auth;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.adapter.member.persistence.MemberRepository;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.exception.MemberErrorType;
+import starlight.domain.member.exception.MemberException;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class AuthDetailsService implements UserDetailsService {
+
+ private final MemberRepository memberRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
+ Member member = memberRepository.findByEmail(email).orElseThrow(()
+ -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND));
+
+ return new AuthDetails(member);
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java b/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java
new file mode 100644
index 00000000..a27f629d
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java
@@ -0,0 +1,49 @@
+package starlight.adapter.auth.security.filter;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import starlight.shared.apiPayload.exception.GlobalException;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+public class ExceptionFilter extends OncePerRequestFilter {
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ try {
+ filterChain.doFilter(request, response);
+ } catch (GlobalException e) { //Todo: Error handling (Core Exception Handling)
+ setResponse(response, e);
+ }
+ }
+
+
+ private void setResponse(HttpServletResponse response, GlobalException e) throws IOException {
+ if (response.isCommitted()) {
+ return;
+ }
+ response.setContentType("application/json;charset=UTF-8");
+
+ int statusCode = e.getErrorType().getStatus().value();
+ response.setStatus(statusCode);
+
+ ApiResponse> errorResponse = ApiResponse.error(e.getErrorType());
+
+ String errorJson = objectMapper.writeValueAsString(errorResponse);
+ response.getWriter().write(errorJson);
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java b/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java
new file mode 100644
index 00000000..f02ee3f1
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java
@@ -0,0 +1,54 @@
+package starlight.adapter.auth.security.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+import starlight.adapter.auth.security.auth.AuthDetailsService;
+import starlight.application.auth.required.TokenProvider;
+
+import java.io.IOException;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtFilter extends OncePerRequestFilter {
+
+ private final TokenProvider tokenProvider;
+ private final AuthDetailsService authDetailsService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String token = getTokenFromRequest(request);
+
+ if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
+ String email = tokenProvider.getEmail(token);
+ UserDetails userDetails = authDetailsService.loadUserByUsername(email);
+
+ if (userDetails != null) {
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ private String getTokenFromRequest(HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
+ return token.substring(7);
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java b/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java
new file mode 100644
index 00000000..00369de6
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java
@@ -0,0 +1,20 @@
+package starlight.adapter.auth.security.handler;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+public class JwtAccessDeniedHandler implements AccessDeniedHandler {
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException{
+ response.sendError(HttpServletResponse.SC_FORBIDDEN);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java b/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java
new file mode 100644
index 00000000..c4ba9344
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java
@@ -0,0 +1,20 @@
+package starlight.adapter.auth.security.handler;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationHandler implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java b/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java
new file mode 100644
index 00000000..3ac31b2f
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java
@@ -0,0 +1,213 @@
+package starlight.adapter.auth.security.jwt;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.application.auth.required.KeyValueMap;
+import starlight.application.auth.required.TokenProvider;
+import starlight.domain.member.entity.Member;
+import starlight.shared.apiPayload.exception.GlobalErrorType;
+import starlight.shared.apiPayload.exception.GlobalException;
+
+import java.security.Key;
+import java.util.Date;
+
+@Component
+@RequiredArgsConstructor
+public class JwtTokenProvider implements TokenProvider {
+
+ private Key key;
+
+ @Value("${jwt.secret}")
+ private String secretKey;
+
+ @Value("${jwt.token.access-expiration-time}")
+ private long accessTokenExpirationTime;
+
+ @Value("${jwt.token.refresh-expiration-time}")
+ private long refreshTokenExpirationTime;
+
+ private final KeyValueMap redisClient;
+
+ @PostConstruct
+ protected void init() {
+ byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey);
+ key = Keys.hmacShaKeyFor(secretKeyBytes);
+ }
+
+ /**
+ * AccessToken을 생성하는 메서드
+ *
+ * @param member
+ * @return String AccessToken
+ */
+ @Override
+ public String createAccessToken(Member member) {
+ Claims claims = getClaims(member);
+ Date now = new Date();
+ return Jwts.builder()
+ .setClaims(claims)
+ .setIssuedAt(now)
+ .setExpiration(new Date(now.getTime() + accessTokenExpirationTime))
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ /**
+ * RefreshToken을 생성하는 메서드
+ *
+ * @param member
+ * @return String RefreshToken
+ */
+ private String createRefreshToken(Member member) {
+ Claims claims = getClaims(member);
+ Date now = new Date();
+ return Jwts.builder()
+ .setClaims(claims)
+ .setIssuedAt(now)
+ .setExpiration(new Date(now.getTime() + refreshTokenExpirationTime))
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ /**
+ * AccessToken과 RefreshToken을 생성하는 메서드
+ *
+ * @param member
+ * @return TokenResponse
+ */
+ @Override
+ public TokenResponse createToken(Member member) {
+ return TokenResponse.of(
+ createAccessToken(member),
+ createRefreshToken(member)
+ );
+ }
+
+ /**
+ * AccessToken과 RefreshToken을 재발급하는 메서드
+ *
+ * @param member
+ * @param refreshToken
+ * @return TokenResponse
+ */
+ @Override
+ public TokenResponse recreate(Member member, String refreshToken) {
+ String accessToken = createAccessToken(member);
+
+ if(getExpirationTime(refreshToken) <= getExpirationTime(accessToken)) {
+ refreshToken = createRefreshToken(member);
+ }
+ return TokenResponse.of(accessToken, refreshToken);
+ }
+
+ /**
+ * 토큰 유효성 검사 메서드
+ *
+ * @param token
+ * @return boolean
+ */
+ @Override
+ public boolean validateToken(String token) {
+ try {
+ Jws claims = Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token);
+ return !claims.getBody().getExpiration().before(new Date());
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Bearer Token에서 이메일을 추출하는 메서드
+ *
+ * @param token
+ * @return String
+ */
+ @Override
+ public String getEmail(String token) {
+ return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
+ }
+
+ /**
+ * AccessToken의 만료 시간을 가져오는 메서드
+ *
+ * @param token
+ * @return Long
+ */
+ @Override
+ public Long getExpirationTime(String token) {
+ return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().getTime();
+ }
+
+ /**
+ * Claims 객체를 생성하는 메서드
+ *
+ * @param member
+ * @return Claims
+ */
+ private Claims getClaims(Member member) {
+ return Jwts.claims().setSubject(member.getEmail());
+ }
+
+
+ /**
+ * Bearer Token에서 RefreshToken을 추출하는 메서드
+ *
+ * @param request
+ * @return String
+ */
+ @Override
+ public String resolveRefreshToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+
+ /**
+ * Bearer Token에서 AccessToken을 추출하는 메서드
+ *
+ * @param request
+ * @return String
+ */
+ @Override
+ public String resolveAccessToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+
+ /**
+ * 토큰을 무효화하는 메서드
+ *
+ * @param refreshToken
+ * @param accessToken
+ * @throws GlobalException
+ */
+ @Override
+ @Transactional
+ public void invalidateTokens(String refreshToken, String accessToken) {
+ if (!validateToken(refreshToken)) {
+ throw new GlobalException(GlobalErrorType.INVALID_TOKEN);
+ }
+ redisClient.deleteValue(getEmail(refreshToken));
+ redisClient.setValue(accessToken, "logout", getExpirationTime(accessToken));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java b/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java
new file mode 100644
index 00000000..3e7948fb
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java
@@ -0,0 +1,11 @@
+package starlight.adapter.auth.security.jwt.dto;
+
+public record TokenResponse(
+ String accessToken,
+
+ String refreshToken
+) {
+ public static TokenResponse of(String accessToken, String refreshToken) {
+ return new TokenResponse(accessToken, refreshToken);
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java
new file mode 100644
index 00000000..99fa1cc3
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java
@@ -0,0 +1,52 @@
+package starlight.adapter.auth.security.oauth2;
+
+import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.member.persistence.MemberRepository;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.enumerate.MemberType;
+
+import java.util.Optional;
+
+@Service
+public class CustomOAuth2UserService implements OAuth2UserService {
+
+ private final MemberRepository memberRepository;
+ private final OAuth2UserService delegate;
+
+ public CustomOAuth2UserService(MemberRepository memberRepository) {
+ this.memberRepository = memberRepository;
+ this.delegate = new DefaultOAuth2UserService();
+ }
+
+ @Override
+ @Transactional
+ public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
+ OAuth2User oAuth2User = delegate.loadUser(request);
+ OAuth2Attributes.Parsed parsed = OAuth2Attributes.parse(request, oAuth2User);
+
+ Optional found = memberRepository.findByProviderAndProviderId(parsed.provider(), parsed.providerId());
+ if (found.isEmpty() && parsed.email() != null) {
+ found = memberRepository.findByEmail(parsed.email());
+ }
+
+ Member member = found.orElseGet(() ->
+ memberRepository.save(Member.newSocial(parsed.name(), parsed.email(), parsed.provider(), parsed.providerId(), null, MemberType.FOUNDER, parsed.profileImageUrl()))
+ );
+
+ String newImage = parsed.profileImageUrl();
+ if (newImage != null && !newImage.isBlank() && (member.getProfileImageUrl() == null || !member.getProfileImageUrl().equals(newImage))) {
+ member.updateProfileImage(newImage);
+
+ memberRepository.save(member);
+ }
+
+ return AuthDetails.of(member, oAuth2User.getAttributes(), parsed.nameAttributeKey());
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java
new file mode 100644
index 00000000..2232b637
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java
@@ -0,0 +1,50 @@
+package starlight.adapter.auth.security.oauth2;
+
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+import java.util.Map;
+
+public final class OAuth2Attributes {
+
+ private OAuth2Attributes() {}
+
+ public record Parsed(
+ String provider, String providerId,
+ String email, String name,
+ String profileImageUrl,
+ Map attributes, String nameAttributeKey
+ ) {}
+
+ @SuppressWarnings("unchecked")
+ public static Parsed parse(OAuth2UserRequest req, OAuth2User o) {
+ String registrationId = req.getClientRegistration().getRegistrationId(); // google/naver/kakao
+ Map attributes = o.getAttributes();
+
+ return switch (registrationId) {
+ case "naver" -> {
+ Map response = (Map) attributes.get("response");
+ if (response == null) response = Map.of();
+
+ String id = String.valueOf(response.getOrDefault("id", ""));
+ String email = (String) response.get("email");
+ String name = (String) (response.getOrDefault("name", response.getOrDefault("nickname", "")));
+ String profileImage = (String) response.getOrDefault("profile_image", "");
+
+ yield new Parsed("naver", id, email, name, profileImage, attributes, "id");
+ }
+ case "kakao" -> {
+ Map response = (Map) attributes.get("kakao_account");
+ if (response == null) response = Map.of();
+
+ String id = String.valueOf(attributes.getOrDefault("id", ""));
+ String email = (String) response.get("email");
+ String name = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("nickname", "");
+ String profileImage = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("profile_image_url", "");
+
+ yield new Parsed("kakao", id, email, name, profileImage, attributes, "id");
+ }
+ default -> new Parsed(registrationId, null, null, null, " ", attributes, "id");
+ };
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java
new file mode 100644
index 00000000..70569cb5
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java
@@ -0,0 +1,51 @@
+package starlight.adapter.auth.security.oauth2;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.application.auth.required.KeyValueMap;
+import starlight.application.auth.required.TokenProvider;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
+
+ private final TokenProvider tokenProvider;
+ private final KeyValueMap redisClient;
+
+ @Value("${app.oauth2.success-redirect:/}")
+ private String successRedirectBase;
+
+ @Value("${jwt.token.refresh-expiration-time}")
+ private Long refreshTokenExpirationTime;
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res, Authentication auth) throws IOException {
+ AuthDetails principal = (AuthDetails) auth.getPrincipal();
+
+ TokenResponse tokens = tokenProvider.createToken(principal.getUser());
+
+ String access = tokens.accessToken();
+ String refresh = tokens.refreshToken();
+
+ String redirect = successRedirectBase
+ + "?access=" + URLEncoder.encode(access, StandardCharsets.UTF_8)
+ + "&refresh=" + URLEncoder.encode(refresh, StandardCharsets.UTF_8);
+
+ redisClient.setValue(principal.member().getEmail(), tokens.refreshToken(), refreshTokenExpirationTime);
+
+ res.sendRedirect(redirect);
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/webapi/AuthController.java b/src/main/java/starlight/adapter/auth/webapi/AuthController.java
new file mode 100644
index 00000000..06ebb10e
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/webapi/AuthController.java
@@ -0,0 +1,54 @@
+package starlight.adapter.auth.webapi;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.adapter.auth.webapi.dto.request.AuthRequest;
+import starlight.adapter.auth.webapi.dto.request.SignInRequest;
+import starlight.adapter.auth.webapi.dto.response.MemberResponse;
+import starlight.adapter.auth.webapi.swagger.AuthApiDoc;
+import starlight.application.auth.provided.AuthService;
+import starlight.application.auth.required.TokenProvider;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@RestController
+@RequestMapping("/v1/auth")
+@RequiredArgsConstructor
+public class AuthController implements AuthApiDoc {
+
+ @Value("${jwt.header}")
+ private String tokenHeader;
+
+ private final AuthService authService;
+ private final TokenProvider tokenProvider;
+
+ @PostMapping("/sign-up")
+ public ApiResponse signUp(@Validated @RequestBody AuthRequest authRequest) {
+ return ApiResponse.success(authService.signUp(authRequest));
+ }
+
+ @PostMapping("/sign-in")
+ public ApiResponse signIn(@Validated @RequestBody SignInRequest signInRequest) {
+ return ApiResponse.success(authService.signIn(signInRequest));
+ }
+
+ @PostMapping("/sign-out")
+ public ApiResponse> signOut(HttpServletRequest request) {
+ String refreshToken = tokenProvider.resolveRefreshToken(request);
+ String accessToken = tokenProvider.resolveAccessToken(request);
+
+ authService.signOut(refreshToken, accessToken);
+ return ApiResponse.success("로그아웃 성공");
+ }
+
+ @GetMapping("/recreate")
+ public ApiResponse recreate(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails) {
+ String token = request.getHeader(tokenHeader);
+ return ApiResponse.success(authService.recreate(token, authDetails.getUser()));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java b/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java
new file mode 100644
index 00000000..ebf134e8
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java
@@ -0,0 +1,36 @@
+package starlight.adapter.auth.webapi.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import starlight.domain.member.entity.Credential;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.enumerate.MemberType;
+
+public record AuthRequest(
+
+ @NotBlank(message = "이름은 필수입니다.")
+ @Schema(description = "이름", example = "정성호")
+ String name,
+
+ @NotBlank(message = "이메일은 필수입니다")
+ @Email(message = "유효한 이메일 형식이어야 합니다")
+ @Schema(description = "이메일", example = "starLight@gmail.com")
+ String email,
+
+ @NotBlank(message = "전화번호는 필수입니다")
+ @Pattern(regexp = "^01[0-9]-[0-9]{4}-[0-9]{4}$", message = "전화번호 형식이 올바르지 않습니다")
+ @Schema(description = "전화번호", example = "010-1234-5678")
+ String phoneNumber,
+
+ @NotBlank(message = "비밀번호는 필수입니다")
+ @Schema(description = "비밀번호", example = "password123")
+ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+ String password
+) {
+ public Member toMember(Credential credential) {
+ return Member.create(name, email, phoneNumber, MemberType.FOUNDER, credential, null);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java b/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java
new file mode 100644
index 00000000..dbf7277c
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java
@@ -0,0 +1,17 @@
+package starlight.adapter.auth.webapi.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record SignInRequest(
+
+ @Schema(description = "이메일", example = "starLight@gmail.com")
+ @NotBlank(message = "이메일은 필수입니다")
+ @Email(message = "유효한 이메일 형식이어야 합니다")
+ String email,
+
+ @Schema(description = "비밀번호", example = "password123")
+ @NotBlank(message = "비밀번호는 필수입니다")
+ String password
+) { }
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java b/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java
new file mode 100644
index 00000000..6d7ef416
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java
@@ -0,0 +1,28 @@
+package starlight.adapter.auth.webapi.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.enumerate.MemberType;
+
+public record MemberResponse(
+ @Schema(description = "회원 ID", example = "1")
+ Long id,
+
+ @Schema(description = "이메일", example = "starLight@gmail.com")
+ String email,
+
+ @Schema(description = "전화번호", example = "010-1234-5678")
+ String phoneNumber,
+
+ @Schema(description = "회원 타입", example = "FOUNDER | EXPERT")
+ MemberType memberType
+) {
+ public static MemberResponse of(Member member) {
+ return new MemberResponse(
+ member.getId(),
+ member.getEmail(),
+ member.getPhoneNumber(),
+ member.getMemberType()
+ );
+ }
+}
diff --git a/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java b/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java
new file mode 100644
index 00000000..dbc8b0fd
--- /dev/null
+++ b/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java
@@ -0,0 +1,154 @@
+package starlight.adapter.auth.webapi.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.adapter.auth.webapi.dto.request.AuthRequest;
+import starlight.adapter.auth.webapi.dto.request.SignInRequest;
+import starlight.adapter.auth.webapi.dto.response.MemberResponse;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@Tag(name = "사용자", description = "사용자 관련 API")
+public interface AuthApiDoc {
+
+ @Operation(
+ summary = "회원가입",
+ description = "사용자 회원가입 기능"
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "회원가입 성공",
+ content = @Content(
+ schema = @Schema(implementation = MemberResponse.class),
+ examples = @ExampleObject(
+ name = "회원가입 성공",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": {
+ "id": 1,
+ "email": "starLight@gmail.com",
+ "phoneNumber": null,
+ "nickname": "starLight"
+ },
+ "error": null
+ }
+ """
+ )
+ )
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청",
+ content = @Content(
+ examples = {
+ @ExampleObject(
+ name = "이미 존재하는 회원",
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "MEMBER_ALREADY_EXISTS",
+ "message": "이미 존재하는 회원입니다."
+ }
+ }
+ """
+ )
+ }
+ )
+ )
+ })
+ @PostMapping("/sign-up")
+ ApiResponse signUp(
+ @RequestBody(
+ description = "회원가입 정보",
+ required = true,
+ content = @Content(
+ examples = @ExampleObject(
+ name = "회원가입 요청",
+ value = """
+ {
+ "name": "박나리",
+ "email": "starLight@gmail.com",
+ "phoneNumber": "010-2112-9765",
+ "password": "password123"
+ }
+ """
+ )
+ )
+ )
+ @org.springframework.web.bind.annotation.RequestBody AuthRequest authRequest
+ );
+
+ @Operation(
+ summary = "로그인",
+ description = "사용자 로그인 기능"
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "로그인 성공",
+ content = @Content(
+ schema = @Schema(implementation = TokenResponse.class),
+ examples = @ExampleObject(
+ name = "로그인 성공",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": {
+ "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NTk2OTA5MDB9...",
+ "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NjAyOTIxMDB9..."
+ },
+ "error": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/sign-in")
+ ApiResponse signIn(
+ @RequestBody(
+ description = "로그인 정보",
+ required = true,
+ content = @Content(
+ examples = @ExampleObject(
+ name = "로그인 요청",
+ value = """
+ {
+ "email": "starLight@gmail.com",
+ "password": "password123"
+ }
+ """
+ )
+ )
+ )
+ @org.springframework.web.bind.annotation.RequestBody SignInRequest signInRequest
+ );
+
+ @Operation(
+ summary = "로그아웃",
+ description = "사용자 로그아웃 기능"
+ )
+ @PostMapping("/sign-out")
+ ApiResponse> signOut(HttpServletRequest request);
+
+ @Operation(
+ summary = "토큰 재발급",
+ description = "AccessToken 만료 시 RefreshToken으로 AccessToken 재발급"
+ )
+ @GetMapping("/recreate")
+ ApiResponse recreate(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails);
+}
diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java
new file mode 100644
index 00000000..ce461cc3
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java
@@ -0,0 +1,39 @@
+package starlight.adapter.businessplan.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Page;
+import org.springframework.stereotype.Repository;
+import starlight.application.businessplan.required.BusinessPlanQuery;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.exception.BusinessPlanErrorType;
+import starlight.domain.businessplan.exception.BusinessPlanException;
+
+@Repository
+@RequiredArgsConstructor
+public class BusinessPlanJpa implements BusinessPlanQuery {
+
+ private final BusinessPlanRepository businessPlanRepository;
+
+ @Override
+ public BusinessPlan getOrThrow(Long id) {
+ return businessPlanRepository.findById(id).orElseThrow(
+ () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND)
+ );
+ }
+
+ @Override
+ public BusinessPlan save(BusinessPlan businessPlan) {
+ return businessPlanRepository.save(businessPlan);
+ }
+
+ @Override
+ public void delete(BusinessPlan businessPlan) {
+ businessPlanRepository.delete(businessPlan);
+ }
+
+ @Override
+ public Page findPreviewPage(Long memberId, Pageable pageable) {
+ return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable);
+ }
+}
diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java
new file mode 100644
index 00000000..10beb7a6
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java
@@ -0,0 +1,25 @@
+package starlight.adapter.businessplan.persistence;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Page;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import starlight.domain.businessplan.entity.BusinessPlan;
+
+import java.util.Optional;
+
+public interface BusinessPlanRepository extends JpaRepository {
+
+ @EntityGraph(attributePaths = { "feasibility", "problemRecognition", "growthTactic", "teamCompetence", "overview" })
+ Optional findById(Long id);
+
+ @Query("""
+ SELECT bp
+ FROM BusinessPlan bp
+ WHERE bp.memberId = :memberId
+ ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC
+ """)
+ Page findAllByMemberIdOrderedByLastSavedAt(@Param("memberId") Long memberId, Pageable pageable);
+}
diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java
new file mode 100644
index 00000000..875bd0d3
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java
@@ -0,0 +1,86 @@
+package starlight.adapter.businessplan.spellcheck;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import starlight.adapter.businessplan.spellcheck.dto.Finding;
+import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil;
+import starlight.application.businessplan.required.SpellChecker;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class DaumSpellChecker implements SpellChecker {
+
+ private static final int MAX_CHARS = 1000; // 요청 글자 수 제한
+ private static final long DAUM_MIN_INTERVAL_MS = 400L; // 호출 간 최소 간격
+
+ private final RestClient spellCheckClient;
+ private final SpellCheckUtil spellCheckUtil;
+
+ public String applyTopSuggestions(String original, List findings) {
+
+ String fixed = original;
+ if (findings == null || findings.isEmpty()) return fixed;
+
+ List sorted = new ArrayList<>(findings);
+ sorted.sort(Comparator.comparingInt((Finding f) -> f.token() != null ? f.token().length() : 0)
+ .reversed());
+
+ for (Finding f : sorted) {
+ if (f.token() != null && f.suggestions() != null && !f.suggestions().isEmpty()) {
+ fixed = fixed.replace(f.token(), f.suggestions().get(0));
+ }
+ }
+ return fixed;
+ }
+
+ public List check(String sentence) {
+
+ List parts = spellCheckUtil.splitByLength(sentence, ".,\n", MAX_CHARS);
+
+ if (parts.isEmpty()) {
+ return List.of();
+ }
+
+ List checkedSpells = new ArrayList<>(parts.size() * 8);
+
+ for (int i = 0; i < parts.size(); i++) {
+ String chunk = parts.get(i);
+ String spellCheckResponseRaw = requestHtml(chunk);
+ checkedSpells.addAll(spellCheckUtil.parseToFinding(spellCheckResponseRaw));
+ throttle(i, parts.size());
+ }
+
+ return checkedSpells;
+ }
+
+ private String requestHtml(String chunk) {
+
+ MultiValueMap form = new LinkedMultiValueMap<>();
+ form.add("sentence", chunk);
+ return spellCheckClient.post()
+ .uri("/grammar_checker.do")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(form)
+ .retrieve()
+ .body(String.class);
+ }
+
+ private void throttle(int index, int total) {
+
+ if (index < total - 1) {
+ try {
+ Thread.sleep(DAUM_MIN_INTERVAL_MS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java b/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java
new file mode 100644
index 00000000..06acb5c2
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java
@@ -0,0 +1,38 @@
+package starlight.adapter.businessplan.spellcheck.dto;
+
+import java.util.List;
+
+public record Finding(
+ String type, // data-error-type (space, spell, doubt, ...)
+ String severity, // txt_word 강조: error / doubt / normal
+ String token, // 잘못된 원문 토큰(data-error-input)
+ List suggestions, // 교정 제안(data-error-output) - 보통 1개
+ String visible, // 화면에 보이는 제안 텍스트(.txt_word)
+ String original, // 원문 스팬(.inner_spell)
+ String context, // 주변 문맥(data-error-context)
+ String help, // 도움말(ul#help 등)
+ List examples // 예문 리스트(div.lst ul#examples li)
+) {
+ public static Finding of(String type, String severity, String token, List suggestions,
+ String visible, String original, String context, String help, List examples)
+ {
+ String severe = (severity == null || severity.isBlank()) ? "normal" : severity;
+ List suggestion = (suggestions == null) ? List.of() : List.copyOf(suggestions);
+ List example = (examples == null) ? List.of() : List.copyOf(examples);
+ return new Finding(
+ nullIfBlank(type),
+ severe,
+ nullIfBlank(token),
+ suggestion,
+ nullIfBlank(visible),
+ nullIfBlank(original),
+ nullIfBlank(context),
+ nullIfBlank(help),
+ example
+ );
+ }
+
+ private static String nullIfBlank(String s) {
+ return (s == null || s.isBlank()) ? null : s;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java b/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java
new file mode 100644
index 00000000..756c3c7f
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java
@@ -0,0 +1,104 @@
+package starlight.adapter.businessplan.spellcheck.util;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.springframework.stereotype.Component;
+import starlight.adapter.businessplan.spellcheck.dto.Finding;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Component
+public class SpellCheckUtil {
+
+ public List splitByLength(String s, String delimiters, int maxLen) {
+ if (s.length() <= maxLen) {
+ return List.of(s);
+ };
+
+ Set sep = new HashSet<>();
+ for (char c : delimiters.toCharArray()) sep.add(c);
+
+ List out = new ArrayList<>();
+ StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < s.length(); i++) {
+ buf.append(s.charAt(i));
+ if (buf.length() >= maxLen) {
+ int cut = -1;
+ for (int j = buf.length() - 1; j >= Math.max(0, buf.length() - 50); j--) {
+ if (sep.contains(buf.charAt(j))) { cut = j + 1; break; }
+ }
+ if (cut == -1) cut = buf.length();
+ out.add(buf.substring(0, cut));
+ buf.delete(0, cut);
+ }
+ }
+ if (buf.length() > 0) out.add(buf.toString());
+ return out;
+ }
+
+
+ public List parseToFinding(String html) {
+ Document doc = Jsoup.parse(html);
+ List list = new ArrayList<>();
+
+ for (Element el : doc.select("a.txt_spell[data-error-type]")) {
+ String type = decode(el.attr("data-error-type"));
+ String token = decode(el.attr("data-error-input"));
+ String out = decode(el.attr("data-error-output"));
+ String ctx = decode(el.attr("data-error-context"));
+
+ String visible = null;
+ String severity = "normal";
+ Element word = el.selectFirst("span.txt_word");
+ if (word != null) {
+ word.select("button").remove(); // 버튼 제거
+ visible = decode(word.text()).trim();
+ var classes = word.classNames();
+ if (classes.contains("txt_error")) severity = "error"; // 확정 오류
+ else if (classes.contains("txt_error2")) severity = "doubt"; // 오류 의심
+ }
+
+ String original = null;
+ Element inner = el.selectFirst("span.inner_spell");
+ if (inner != null) original = decode(inner.text()).trim();
+
+ String help = null;
+ List examples = new ArrayList<>();
+ Element contents = el.selectFirst("span[name=contents]");
+ if (contents != null) {
+ Element helpUl = contents.selectFirst("ul#help");
+ if (helpUl != null) {
+ help = decode(helpUl.text()).trim();
+ if ("도움말이 없습니다.".equals(help)) help = null;
+ } else {
+ String raw = decode(contents.text());
+ raw = raw.replaceAll("\\t", "")
+ .replaceAll("\\n{3,}", "\n(예)\n")
+ .replaceAll("\\n+$", "")
+ .replaceAll("^[ \\n]+", "")
+ .trim();
+ if (!raw.isBlank()) help = raw;
+ }
+ for (Element li : contents.select("div.lst ul#examples li")) {
+ String ex = decode(li.text()).trim();
+ if (!ex.isBlank()) examples.add(ex);
+ }
+ }
+
+ List suggestions = new ArrayList<>();
+ if (out != null && !out.isBlank()) suggestions.add(out.trim());
+
+ list.add(Finding.of(type, severity, token, suggestions, visible, original, ctx, help, examples));
+ }
+ return list;
+ }
+
+ private static String decode(String s) {
+ if (s == null) return null;
+ return Jsoup.parse(s).text();
+ }
+}
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java
new file mode 100644
index 00000000..fcc9d431
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java
@@ -0,0 +1,163 @@
+package starlight.adapter.businessplan.webapi;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest;
+import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest;
+import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest;
+import starlight.application.businessplan.provided.dto.BusinessPlanResponse;
+import starlight.application.businessplan.provided.dto.SubSectionResponse;
+import starlight.application.businessplan.provided.BusinessPlanService;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+
+@Validated
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/business-plans")
+@Tag(name = "사업계획서", description = "사업계획서 API")
+public class BusinessPlanController {
+
+ private final BusinessPlanService businessPlanService;
+ private final ObjectMapper objectMapper;
+
+ @GetMapping
+ @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)")
+ public ApiResponse getBusinessPlanList(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page,
+ @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size
+ ) {
+ int zeroBasedPage = Math.max(0, page - 1);
+ Pageable pageable = PageRequest.of(zeroBasedPage, size);
+ return ApiResponse.success(businessPlanService.getBusinessPlanList(
+ authDetails.getMemberId(), pageable
+ ));
+ }
+
+ @GetMapping("/{planId}/subsections")
+ @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)")
+ public ApiResponse getBusinessPlanDetail(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(businessPlanService.getBusinessPlanDetail(
+ planId, authDetails.getMemberId()
+ ));
+ }
+
+ @GetMapping("/{planId}/titles")
+ @Operation(summary = "사업 계획서의 제목을 조회합니다.")
+ public ApiResponse getBusinessPlanTitle(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(businessPlanService
+ .getBusinessPlanInfo(planId, authDetails.getMemberId())
+ .title()
+ );
+ }
+
+ @PostMapping
+ @Operation(summary = "사업 계획서를 생성합니다.")
+ public ApiResponse createBusinessPlan(
+ @AuthenticationPrincipal AuthDetails authDetails
+ ) {
+ return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId()));
+ }
+
+ @PostMapping("/pdf")
+ @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.")
+ public ApiResponse createBusinessPlanWithPdfAndAiReport(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @Valid @RequestBody BusinessPlanCreateWithPdfRequest request
+ ) {
+ return ApiResponse.success(businessPlanService.createBusinessPlanWithPdf(
+ request.title(), request.pdfUrl(), authDetails.getMemberId()
+ ));
+ }
+
+ @PatchMapping("/{planId}")
+ @Operation(summary = "사업 계획서 제목을 수정합니다.")
+ public ApiResponse updateBusinessPlanTitle(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @RequestBody @Valid BusinessPlanCreateRequest request,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(businessPlanService.updateBusinessPlanTitle(
+ planId, request.title(), authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "사업 계획서를 삭제합니다.")
+ @DeleteMapping("/{planId}")
+ public ApiResponse deleteBusinessPlan(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId
+ ) {
+ return ApiResponse.success(businessPlanService.deleteBusinessPlan(
+ planId, authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "서브섹션을 생성 또는 수정합니다.")
+ @PostMapping("/{planId}/subsections")
+ public ApiResponse upsertSubSection(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId,
+ @Valid @RequestBody SubSectionCreateRequest request
+ ) {
+ return ApiResponse.success(businessPlanService.upsertSubSection(
+ planId, objectMapper.valueToTree(request), request.checks(), request.subSectionType(), authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "서브섹션을 조회합니다.")
+ @GetMapping("/{planId}/subsections/{subSectionType}")
+ public ApiResponse getSubSection(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId,
+ @PathVariable SubSectionType subSectionType
+ ) {
+ return ApiResponse.success(businessPlanService.getSubSectionDetail(
+ planId, subSectionType, authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.")
+ @PostMapping("/{planId}/subsections/check-and-update")
+ public ApiResponse> checkAndUpdateSubSection(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId,
+ @Valid @RequestBody SubSectionCreateRequest request
+ ) {
+ return ApiResponse.success(businessPlanService.checkAndUpdateSubSection(
+ planId, objectMapper.valueToTree(request), request.subSectionType(), authDetails.getMemberId()
+ ));
+ }
+
+ @Operation(summary = "서브섹션을 삭제합니다.")
+ @DeleteMapping("/{planId}/subsections/{subSectionType}")
+ public ApiResponse deleteSubSection(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @PathVariable Long planId,
+ @PathVariable SubSectionType subSectionType
+ ) {
+ return ApiResponse.success(businessPlanService.deleteSubSection(
+ planId, subSectionType, authDetails.getMemberId()
+ ));
+ }
+}
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java
new file mode 100644
index 00000000..71df82ec
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java
@@ -0,0 +1,38 @@
+package starlight.adapter.businessplan.webapi;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse;
+import starlight.adapter.businessplan.spellcheck.dto.Finding;
+import starlight.adapter.businessplan.webapi.swagger.SpellCheckApiDoc;
+import starlight.application.businessplan.required.SpellChecker;
+import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest;
+import starlight.application.businessplan.util.PlainTextExtractUtils;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/v1/business-plans")
+@RequiredArgsConstructor
+public class SpellController implements SpellCheckApiDoc {
+
+ private final ObjectMapper objectMapper;
+ private final SpellChecker spellChecker;
+
+ @Override
+ public ApiResponse check(
+ @Valid @RequestBody SubSectionCreateRequest subSectionCreateRequest
+ ) {
+ String text = PlainTextExtractUtils.extractPlainText(objectMapper, subSectionCreateRequest);
+
+ List typos = spellChecker.check(text);
+ String corrected = spellChecker.applyTopSuggestions(text, typos);
+
+ return ApiResponse.success(SpellCheckResponse.of(typos, corrected));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java
new file mode 100644
index 00000000..36357cff
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java
@@ -0,0 +1,10 @@
+package starlight.adapter.businessplan.webapi.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+public record BusinessPlanCreateRequest (
+ @NotBlank(message = "제목 입력은 필수입니다.")
+ @Schema(description = "제목", example = "예비창업패키지 사업계획서")
+ String title
+) {}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java
new file mode 100644
index 00000000..a2b85000
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java
@@ -0,0 +1,13 @@
+package starlight.adapter.businessplan.webapi.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record BusinessPlanCreateWithPdfRequest(
+ @NotBlank(message = "제목은 필수입니다.")
+ String title,
+
+ @NotBlank(message = "PDF URL은 필수입니다.")
+ String pdfUrl
+) {}
+
+
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java
new file mode 100644
index 00000000..b50e5d34
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java
@@ -0,0 +1,16 @@
+package starlight.adapter.businessplan.webapi.dto;
+
+import starlight.adapter.businessplan.spellcheck.dto.Finding;
+
+import java.util.List;
+
+public record SpellCheckResponse(
+
+ List typos,
+
+ String corrected
+) {
+ public static SpellCheckResponse of(List typos, String corrected) {
+ return new SpellCheckResponse(typos, corrected);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java
new file mode 100644
index 00000000..6c6ba559
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java
@@ -0,0 +1,188 @@
+package starlight.adapter.businessplan.webapi.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.*;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.List;
+
+public record SubSectionCreateRequest(
+ @NotNull SubSectionType subSectionType,
+ @NotNull List checks,
+ @Valid @NotNull Meta meta,
+ @Valid @NotNull List<@Valid Block> blocks) {
+ public record Meta(
+ @NotBlank String author,
+ @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$") String createdAt) {
+ }
+
+ public record Block(
+ @Valid @NotNull BlockMeta meta,
+ @Valid List<@Valid GeneralContent> content) {
+ }
+
+ public record BlockMeta(
+ @NotBlank String title) {
+ }
+
+ // 테이블 셀 내부에서 사용하는 기본 콘텐츠 (TextItem, ImageItem만)
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = TextItem.class, name = "text"),
+ @JsonSubTypes.Type(value = ImageItem.class, name = "image")
+ })
+ public sealed interface BasicContent
+ permits TextItem, ImageItem {
+ String type();
+ }
+
+ // 블록의 content에서 사용하는 일반 콘텐츠 (BasicContent + TableItem)
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = TextItem.class, name = "text"),
+ @JsonSubTypes.Type(value = ImageItem.class, name = "image"),
+ @JsonSubTypes.Type(value = TableItem.class, name = "table")
+ })
+ public sealed interface GeneralContent
+ permits TextItem, ImageItem, TableItem {
+ String type();
+ }
+
+ public record TextItem(
+ @NotBlank String type,
+ String value) implements BasicContent, GeneralContent {
+ }
+
+ public record ImageItem(
+ @NotBlank String type,
+ @NotBlank @Size(max = 1024) String src,
+ @JsonProperty(defaultValue = "400") Integer width,
+ @JsonProperty(defaultValue = "400") Integer height,
+ @Size(max = 255) String caption) implements BasicContent, GeneralContent {
+ public ImageItem {
+ width = width != null ? width : 400;
+ height = height != null ? height : 400;
+ }
+ }
+
+ // 컬럼 정보 (헤더 제거, 개수와 너비만)
+ public record TableColumn(
+ Integer width) {
+ }
+
+ // 셀 데이터
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ public record TableCell(
+ @NotEmpty List<@Valid BasicContent> content,
+ @Min(1) Integer rowspan,
+ @Min(1) Integer colspan) {
+ public TableCell {
+ rowspan = rowspan != null ? rowspan : 1;
+ colspan = colspan != null ? colspan : 1;
+ }
+ }
+
+ public record TableItem(
+ @NotBlank String type,
+ @NotEmpty List<@Valid TableColumn> columns,
+ @NotEmpty List> rows) implements GeneralContent {
+
+ @AssertTrue(message = "table rows must match columns length considering cell spans")
+ @JsonIgnore
+ public boolean isValidCellSpans() {
+ if (columns == null || rows == null || rows.isEmpty())
+ return false;
+
+ int expectedWidth = columns.size();
+ int rowCount = rows.size();
+
+ // 각 위치에서 rowspan으로 차지된 셀을 추적
+ // grid[row][col] = 위에서 내려온 rowspan 셀의 남은 rowspan 값 (0이면 비어있음)
+ int[][] grid = new int[rowCount][expectedWidth];
+
+ for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ var row = rows.get(rowIndex);
+ if (row == null)
+ return false;
+
+ // 현재 행에서 rowspan으로 차지된 위치 수 계산
+ int occupiedByRowspan = 0;
+ for (int col = 0; col < expectedWidth; col++) {
+ if (grid[rowIndex][col] > 0) {
+ occupiedByRowspan++;
+ }
+ }
+
+ // 빈 행인 경우 (모든 위치가 rowspan으로 차지됨)
+ if (row.isEmpty()) {
+ if (occupiedByRowspan == expectedWidth) {
+ // 빈 행이 유효함 (모든 위치가 rowspan으로 차지됨)
+ continue;
+ } else {
+ // 빈 행인데 rowspan으로 차지되지 않은 위치가 있음
+ return false;
+ }
+ }
+
+ // 현재 행의 셀들의 colspan 합 계산
+ int rowCellWidth = 0;
+ int colIndex = 0;
+
+ for (var cell : row) {
+ if (cell == null)
+ return false;
+
+ // rowspan으로 차지된 위치는 건너뛰기 (HTML 테이블 규칙)
+ while (colIndex < expectedWidth && grid[rowIndex][colIndex] > 0) {
+ colIndex++;
+ }
+
+ if (colIndex >= expectedWidth) {
+ // 컬럼 범위를 벗어남
+ return false;
+ }
+
+ // colspan 범위 확인
+ if (colIndex + cell.colspan() > expectedWidth) {
+ return false;
+ }
+
+ // colspan 범위 내에 rowspan으로 차지된 셀이 있는지 확인 (HTML 테이블 규칙)
+ for (int i = 0; i < cell.colspan(); i++) {
+ if (grid[rowIndex][colIndex + i] > 0) {
+ // rowspan으로 이미 차지된 위치와 겹침
+ return false;
+ }
+ }
+
+ // 셀을 그리드에 배치 (rowspan이 있으면 아래 행들도 표시)
+ int cellRowspan = cell.rowspan();
+ int cellColspan = cell.colspan();
+
+ for (int r = 0; r < cellRowspan && rowIndex + r < rowCount; r++) {
+ for (int c = 0; c < cellColspan; c++) {
+ if (rowIndex + r < rowCount && colIndex + c < expectedWidth) {
+ grid[rowIndex + r][colIndex + c] = cellRowspan - r;
+ }
+ }
+ }
+
+ rowCellWidth += cellColspan;
+ colIndex += cellColspan;
+ }
+
+ // 각 행의 실제 너비는 (컬럼 수 - rowspan으로 차지된 위치 수)와 일치해야 함
+ if (rowCellWidth != expectedWidth - occupiedByRowspan) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java
new file mode 100644
index 00000000..b3fb621c
--- /dev/null
+++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java
@@ -0,0 +1,92 @@
+package starlight.adapter.businessplan.webapi.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse;
+import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@Tag(name = "사업계획서", description = "사업계획서 API")
+public interface SpellCheckApiDoc {
+
+ @Operation(
+ summary = "맞춤법 검사 및 교정",
+ description = "입력된 텍스트의 맞춤법을 검사하고 교정된 텍스트를 반환합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "검사 성공",
+ content = @Content(
+ schema = @Schema(implementation = SpellCheckResponse.class),
+ examples = @ExampleObject(
+ name = "맞춤법 오류 발견",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": {
+ "typos": [
+ {
+ "type": "space",
+ "severity": "normal",
+ "token": "반갑습니다다리",
+ "suggestions": [
+ "반갑습니다 다리"
+ ],
+ "visible": "반갑습니다 다리",
+ "original": "반갑습니다다리",
+ "context": "안녕하세요 반갑습니다다리",
+ "help": "띄어쓰기 오류입니다. 대치어를 참고하여 고쳐 쓰세요.",
+ "examples": []
+ }
+ ],
+ "corrected": "안녕하세요 반갑습니다 다리"
+ },
+ "error": null
+ }
+ """
+ )
+ )
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청",
+ content = @Content(
+ examples = @ExampleObject(
+ name = "빈 텍스트",
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": "텍스트를 입력해주세요"
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/spellcheck")
+ ApiResponse check(
+ @RequestBody(
+ description = "검사할 텍스트",
+ required = true,
+ content = @Content(
+ examples = @ExampleObject(
+ name = "예시 요청",
+ value = """
+ {
+ "text": "안녕하세요 반갑습니다다리"
+ }
+ """
+ )
+ )
+ )
+ @org.springframework.web.bind.annotation.RequestBody SubSectionCreateRequest subSectionCreateRequest
+ );
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java
new file mode 100644
index 00000000..e3a18e2b
--- /dev/null
+++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java
@@ -0,0 +1,68 @@
+package starlight.adapter.expert.persistence;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import starlight.application.expert.required.ExpertQuery;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+import starlight.domain.expert.exception.ExpertErrorType;
+import starlight.domain.expert.exception.ExpertException;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExpertJpa implements ExpertQuery {
+
+ private final ExpertRepository repository;
+
+ @Override
+ public Expert findById(Long id) {
+ return repository.findById(id).orElseThrow(
+ () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND)
+ );
+ }
+
+ @Override
+ public Expert findByIdWithDetails(Long id) {
+ return repository.findByIdWithDetails(id).orElseThrow(
+ () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND)
+ );
+ }
+
+ @Override
+ public List findAllWithDetails() {
+ try {
+ return repository.findAllWithDetails();
+ } catch (Exception e) {
+ log.error("전문가 목록 조회 중 오류가 발생했습니다.", e);
+ throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR);
+ }
+ }
+
+ @Override
+ public List findByAllCategories(Collection categories) {
+ try {
+ return repository.findByAllCategories(categories, categories.size());
+ } catch (Exception e) {
+ log.error("전문가 목록 필터링 중 오류가 발생했습니다.", e);
+ throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR);
+ }
+ }
+
+ @Override
+ public Map findExpertMapByIds(Set expertIds) {
+
+ List experts = repository.findAllWithDetailsByIds(expertIds);
+
+ return experts.stream()
+ .collect(Collectors.toMap(Expert::getId, Function.identity()));
+ }
+}
diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java
new file mode 100644
index 00000000..dfe059d9
--- /dev/null
+++ b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java
@@ -0,0 +1,46 @@
+package starlight.adapter.expert.persistence;
+
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface ExpertRepository extends JpaRepository {
+
+ @Query("select distinct e from Expert e")
+ @EntityGraph(attributePaths = {"categories", "careers", "tags"})
+ List findAllWithDetails();
+
+ @Query("select distinct e from Expert e where e.id in :expertIds")
+ @EntityGraph(attributePaths = {"categories", "careers", "tags"})
+ List findAllWithDetailsByIds(Set expertIds);
+
+ @Query("""
+ select distinct e from Expert e where e.id in (
+ select e2.id
+ from Expert e2
+ join e2.categories c2
+ where c2 in :cats
+ group by e2.id
+ having count(distinct c2) = :size)
+ """)
+ @EntityGraph(attributePaths = {"categories", "careers", "tags"})
+ List findByAllCategories(@Param("cats") Collection cats,
+ @Param("size") long size);
+
+ @Query("""
+ select e from Expert e
+ left join fetch e.categories
+ left join fetch e.careers
+ left join fetch e.tags
+ where e.id = :id
+ """)
+ Optional findByIdWithDetails(@Param("id") Long id);
+}
diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java
new file mode 100644
index 00000000..4b5cf662
--- /dev/null
+++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java
@@ -0,0 +1,35 @@
+package starlight.adapter.expert.webapi;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import starlight.adapter.expert.webapi.dto.ExpertDetailResponse;
+import starlight.adapter.expert.webapi.swagger.ExpertQueryApiDoc;
+import starlight.application.expert.provided.ExpertFinder;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+import java.util.Set;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/experts")
+public class ExpertController implements ExpertQueryApiDoc {
+
+ private final ExpertFinder expertFinder;
+
+ @GetMapping
+ public ApiResponse> search(
+ @RequestParam(name = "categories", required = false) Set categories
+ ) {
+ List experts = (categories == null || categories.isEmpty())
+ ? expertFinder.loadAll()
+ : expertFinder.findByAllCategories(categories);
+
+ return ApiResponse.success(ExpertDetailResponse.fromAll(experts));
+ }
+}
diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java
new file mode 100644
index 00000000..f02ce085
--- /dev/null
+++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java
@@ -0,0 +1,51 @@
+package starlight.adapter.expert.webapi.dto;
+
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+
+import java.util.Collection;
+import java.util.List;
+
+public record ExpertDetailResponse(
+
+ Long id,
+
+ String name,
+
+ String profileImageUrl,
+
+ Long workedPeriod,
+
+ String email,
+
+ Integer mentoringPriceWon,
+
+ List careers,
+
+ List tags,
+
+ List categories
+) {
+ public static ExpertDetailResponse from(Expert expert) {
+ List categories = expert.getCategories().stream()
+ .map(TagCategory::name)
+ .distinct()
+ .toList();
+
+ return new ExpertDetailResponse(
+ expert.getId(),
+ expert.getName(),
+ expert.getProfileImageUrl(),
+ expert.getWorkedPeriod(),
+ expert.getEmail(),
+ expert.getMentoringPriceWon(),
+ expert.getCareers(),
+ expert.getTags().stream().distinct().toList(),
+ categories
+ );
+ }
+
+ public static List fromAll(Collection experts){
+ return experts.stream().map(ExpertDetailResponse::from).toList();
+ }
+}
diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java
new file mode 100644
index 00000000..99187fc4
--- /dev/null
+++ b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java
@@ -0,0 +1,77 @@
+package starlight.adapter.expert.webapi.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.ArraySchema;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import starlight.adapter.expert.webapi.dto.ExpertDetailResponse;
+import starlight.domain.expert.enumerate.TagCategory;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+import java.util.Set;
+
+@Tag(name = "전문가", description = "전문가 관련 API")
+public interface ExpertQueryApiDoc {
+
+ @Operation(
+ summary = "전문가 검색(AND 매칭)",
+ description = """
+ 카테고리 파라미터가 없으면 전체 전문가를 반환합니다.
+ \n카테고리를 하나 이상 전달하면 **전달된 모든 카테고리**를 보유한 전문가만 반환합니다(AND 매칭).
+ \n MARKET_BM: 시장성/BM, TEAM_CAPABILITY: 팀 역량, PROBLEM_DEFINITION: 문제 정의, GROWTH_STRATEGY: 성장 전략, METRIC_DATA: 지표/데이터
+ \nSwagger UI에서는 'Add item'으로 항목을 추가하면 ?categories=A&categories=B 형태로 전송됩니다.
+ \n예) GET /v1/experts?categories=GROWTH_STRATEGY&categories=TEAM_CAPABILITY
+ \n예) GET /v1/experts?categories=GROWTH_STRATEGY,TEAM_CAPABILITY
+ """
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "성공",
+ content = @Content(
+ mediaType = "application/json",
+ array = @ArraySchema(schema = @Schema(implementation = ExpertDetailResponse.class)),
+ examples = @ExampleObject(
+ name = "성공 예시",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": [
+ {
+ "id": 1,
+ "name": "홍길동",
+ "profileImageUrl": "https://cdn.example.com/profiles/1.png",
+ "email": "hong@example.com",
+ "mentoringPriceWon": 50000,
+ "careers": ["A사 PO (2019-2022)","B스타트업 PM (2023-)"],
+ "categories": ["성장 전략","팀 역량"]
+ },
+ {
+ "id": 2,
+ "name": "이영희",
+ "profileImageUrl": "https://cdn.example.com/profiles/2.png",
+ "email": "lee@example.com",
+ "mentoringPriceWon": 70000,
+ "careers": ["C기업 데이터분석 (2020-)"],
+ "categories": ["시장성/BM","지표/데이터"]
+ }
+ ],
+ "error": null
+ }
+ """
+ )
+ )
+ ),
+ })
+ @GetMapping
+ ApiResponse> search(
+ @RequestParam(name = "categories", required = false)
+ Set categories
+ );
+}
diff --git a/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java b/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java
new file mode 100644
index 00000000..fb7b2a2d
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java
@@ -0,0 +1,62 @@
+package starlight.adapter.expertApplication.email;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+import starlight.application.expertApplication.required.EmailSender;
+import starlight.application.expertApplication.event.FeedbackRequestDto;
+import starlight.domain.expertApplication.exception.ExpertApplicationErrorType;
+import starlight.domain.expertApplication.exception.ExpertApplicationException;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SMTPEmailSender implements EmailSender {
+
+ private final JavaMailSender javaMailSender;
+ private final SpringTemplateEngine templateEngine;
+
+ @Value("${spring.mail.username}")
+ private String senderEmail;
+
+ @Override
+ public void sendFeedbackRequestMail(FeedbackRequestDto dto) {
+ try {
+ MimeMessage message = javaMailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+
+ helper.setFrom(senderEmail);
+ helper.setTo(dto.mentorEmail());
+ helper.setSubject("[STARLIGHT] " + dto.menteeName() + "의 사업계획서 검토 요청");
+
+ Context ctx = new Context();
+ ctx.setVariable("mentorName", dto.mentorName());
+ ctx.setVariable("studentName", dto.menteeName());
+ ctx.setVariable("planTitle", dto.businessPlanTitle());
+ ctx.setVariable("feedbackDeadline", dto.feedbackDeadline());
+ ctx.setVariable("feedbackUrl", dto.feedbackUrl());
+
+ String htmlContent = templateEngine.process("feedback-request", ctx);
+ helper.setText(htmlContent, true);
+
+ if (dto.attachedFile() != null && dto.filename() != null) {
+ helper.addAttachment(dto.filename(), new ByteArrayResource(dto.attachedFile()));
+ }
+
+ javaMailSender.send(message);
+ log.info("피드백 요청 메일 발송 성공 - To: {}", dto.mentorEmail());
+
+ } catch (MessagingException e) {
+ log.error("피드백 요청 메일 발송 실패 - To: {}", dto.mentorEmail(), e);
+ throw new ExpertApplicationException(ExpertApplicationErrorType.EMAIL_SEND_ERROR);
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java
new file mode 100644
index 00000000..5b57ab7c
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java
@@ -0,0 +1,44 @@
+package starlight.adapter.expertApplication.persistence;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import starlight.application.expertApplication.required.ExpertApplicationQuery;
+import starlight.domain.expertApplication.entity.ExpertApplication;
+import starlight.domain.expertApplication.exception.ExpertApplicationErrorType;
+import starlight.domain.expertApplication.exception.ExpertApplicationException;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExpertApplicationJpa implements ExpertApplicationQuery {
+
+ private final ExpertApplicationRepository repository;
+
+ @Override
+ public Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId) {
+ try {
+ return repository.existsByExpertIdAndBusinessPlanId(expertId, businessPlanId);
+ } catch (Exception e) {
+ log.error("전문가 신청 존재 여부 조회 중 오류가 발생했습니다.", e);
+ throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR);
+ }
+ }
+
+ @Override
+ public List findRequestedExpertIds(Long businessPlanId) {
+ try {
+ return repository.findRequestedExpertIdsByPlanId(businessPlanId);
+ } catch (Exception e) {
+ log.error("신청된 전문가 목록 조회 중 오류가 발생했습니다.", e);
+ throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR);
+ }
+ }
+
+ @Override
+ public ExpertApplication save(ExpertApplication application) {
+ return repository.save(application);
+ }
+}
diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java
new file mode 100644
index 00000000..a912bd5b
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java
@@ -0,0 +1,20 @@
+package starlight.adapter.expertApplication.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import starlight.domain.expertApplication.entity.ExpertApplication;
+
+import java.util.List;
+
+public interface ExpertApplicationRepository extends JpaRepository {
+
+ Boolean existsByExpertIdAndBusinessPlanId(Long mentorId, Long businessPlanId);
+
+ @Query("""
+ select distinct e.expertId
+ from ExpertApplication e
+ where e.businessPlanId = :businessPlanId
+ """)
+ List findRequestedExpertIdsByPlanId(@Param("businessPlanId") Long businessPlanId);
+}
diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java
new file mode 100644
index 00000000..7d46e333
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java
@@ -0,0 +1,43 @@
+package starlight.adapter.expertApplication.webapi;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.expertApplication.webapi.swagger.ExpertApplicationApiDoc;
+import starlight.application.expertApplication.provided.ExpertApplicationService;
+import starlight.application.expertApplication.required.ExpertApplicationQuery;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/expert-applications")
+public class ExpertApplicationController implements ExpertApplicationApiDoc {
+
+ private final ExpertApplicationQuery finder;
+ private final ExpertApplicationService expertApplicationService;
+
+ @GetMapping
+ public ApiResponse> search(
+ @RequestParam Long businessPlanId
+ ) {
+ return ApiResponse.success(finder.findRequestedExpertIds(businessPlanId));
+ }
+
+ @PostMapping(value = "/{expertId}/request", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ApiResponse requestFeedback(
+ @PathVariable Long expertId,
+ @RequestParam Long businessPlanId,
+ @RequestParam("file") MultipartFile file,
+ @AuthenticationPrincipal AuthDetails auth
+ ) throws Exception {
+ expertApplicationService.requestFeedback(expertId, businessPlanId, file, auth.getUser().getName());
+ return ApiResponse.success("피드백 요청이 전달되었습니다.");
+ }
+}
diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java
new file mode 100644
index 00000000..b73f81b4
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java
@@ -0,0 +1,251 @@
+package starlight.adapter.expertApplication.webapi.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
+import starlight.adapter.auth.security.auth.AuthDetails;
+
+import java.util.List;
+
+@Tag(name = "전문가", description = "전문가 관련 API")
+public interface ExpertApplicationApiDoc {
+
+ @Operation(
+ summary = "피드백 요청한 전문가 목록 조회",
+ description = "특정 사업계획서에 피드백을 요청한 전문가들의 ID 목록을 조회합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "조회 성공",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": [1, 3, 5, 7],
+ "error": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "사업계획서를 찾을 수 없음",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "BUSINESS_PLAN_NOT_FOUND",
+ "message": "사업계획서를 찾을 수 없습니다."
+ }
+ }
+ """
+ )
+ )
+ )
+ })
+ starlight.shared.apiPayload.response.ApiResponse> search(
+ @Parameter(
+ description = "사업계획서 ID",
+ required = true,
+ example = "1"
+ )
+ @RequestParam Long businessPlanId
+ );
+
+ @Operation(
+ summary = "전문가에게 피드백 요청",
+ description = """
+ 특정 전문가에게 사업계획서에 대한 피드백을 요청합니다.
+
+ - 사업계획서 PDF 파일을 첨부하여 전문가 이메일로 발송합니다.
+ - 동일한 전문가에게 동일한 사업계획서로 중복 요청할 수 없습니다.
+ - 이메일 발송은 비동기로 처리되며, 요청 즉시 응답을 반환합니다.
+ """,
+ security = @SecurityRequirement(name = "Bearer Authentication")
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "피드백 요청 성공",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": "피드백 요청이 전달되었습니다.",
+ "error": null
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청 (파일 없음, 파일 형식 오류 등)",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "INVALID_FILE",
+ "message": "유효하지 않은 파일입니다."
+ }
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "전문가 또는 사업계획서를 찾을 수 없음",
+ content = @Content(
+ mediaType = "application/json",
+ examples = {
+ @ExampleObject(
+ name = "전문가를 찾을 수 없음",
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "EXPERT_NOT_FOUND",
+ "message": "전문가를 찾을 수 없습니다."
+ }
+ }
+ """
+ ),
+ @ExampleObject(
+ name = "사업계획서를 찾을 수 없음",
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "BUSINESS_PLAN_NOT_FOUND",
+ "message": "사업계획서를 찾을 수 없습니다."
+ }
+ }
+ """
+ )
+ }
+ )
+ ),
+ @ApiResponse(
+ responseCode = "409",
+ description = "이미 피드백을 요청한 전문가",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "APPLICATION_ALREADY_EXISTS",
+ "message": "이미 신청한 전문가입니다."
+ }
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 오류 (파일 처리 실패, 이메일 발송 실패 등)",
+ content = @Content(
+ mediaType = "application/json",
+ examples = @ExampleObject(
+ value = """
+ {
+ "result": "ERROR",
+ "data": null,
+ "error": {
+ "code": "INTERNAL_SERVER_ERROR",
+ "message": "서버 오류가 발생했습니다."
+ }
+ }
+ """
+ )
+ )
+ )
+ })
+ @RequestBody(
+ description = "피드백 요청 정보",
+ required = true,
+ content = @Content(
+ mediaType = "multipart/form-data",
+ schema = @Schema(implementation = FeedbackRequestSchema.class)
+ )
+ )
+ starlight.shared.apiPayload.response.ApiResponse requestFeedback(
+ @Parameter(
+ description = "전문가 ID",
+ required = true,
+ example = "1"
+ )
+ @PathVariable Long expertId,
+
+ @Parameter(
+ description = "사업계획서 ID",
+ required = true,
+ example = "10"
+ )
+ @RequestParam Long businessPlanId,
+
+ @Parameter(
+ description = "사업계획서 PDF 파일 (최대 50MB)",
+ required = true,
+ content = @Content(mediaType = "application/pdf")
+ )
+ @RequestParam("file") MultipartFile file,
+
+ @Parameter(hidden = true)
+ @AuthenticationPrincipal AuthDetails auth
+ ) throws Exception;
+
+ /**
+ * Swagger 문서화를 위한 스키마 클래스
+ */
+ @Schema(description = "피드백 요청 데이터")
+ class FeedbackRequestSchema {
+
+ @Schema(
+ description = "사업계획서 ID",
+ example = "10",
+ required = true
+ )
+ public Long businessPlanId;
+
+ @Schema(
+ description = "사업계획서 PDF 파일",
+ type = "string",
+ format = "binary",
+ required = true,
+ maxLength = 52428800 // 50MB
+ )
+ public MultipartFile file;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java
new file mode 100644
index 00000000..4cd23163
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java
@@ -0,0 +1,53 @@
+package starlight.adapter.expertReport.persistence;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import starlight.application.expertReport.required.ExpertReportQuery;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.expertReport.exception.ExpertReportErrorType;
+import starlight.domain.expertReport.exception.ExpertReportException;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExpertReportJpa implements ExpertReportQuery {
+
+ private final ExpertReportRepository repository;
+
+ @Override
+ public ExpertReport getOrThrow(Long id) {
+ return repository.findById(id).orElseThrow(
+ () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND)
+ );
+ }
+
+ @Override
+ public ExpertReport save(ExpertReport expertReport) {
+ return repository.save(expertReport);
+ }
+
+ @Override
+ public void delete(ExpertReport expertReport) {
+ repository.delete(expertReport);
+ }
+
+ @Override
+ public boolean existsByToken(String token) {
+ return repository.existsByToken(token);
+ }
+
+ @Override
+ public ExpertReport findByTokenWithDetails(String token) {
+ return repository.findByToken(token).orElseThrow(
+ () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND)
+ );
+ }
+
+ @Override
+ public List findAllByBusinessPlanId(Long businessPlanId) {
+ return repository.findAllByBusinessPlanIdOrderByCreatedAtDesc(businessPlanId);
+ }
+}
diff --git a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java
new file mode 100644
index 00000000..b46a4b66
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java
@@ -0,0 +1,19 @@
+package starlight.adapter.expertReport.persistence;
+
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.expertReport.entity.ExpertReport;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ExpertReportRepository extends JpaRepository {
+
+ boolean existsByToken(String token);
+
+ @EntityGraph(attributePaths = {"details"})
+ Optional findByToken(String token);
+
+ @EntityGraph(attributePaths = {"details"})
+ List findAllByBusinessPlanIdOrderByCreatedAtDesc(Long businessPlanId);
+}
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java
new file mode 100644
index 00000000..5b62567c
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java
@@ -0,0 +1,78 @@
+package starlight.adapter.expertReport.webapi;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.expertReport.webapi.dto.ExpertReportResponse;
+import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest;
+import starlight.adapter.expertReport.webapi.mapper.ExpertReportMapper;
+import starlight.application.expertReport.provided.ExpertReportService;
+import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.expertReport.entity.ExpertReportDetail;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/v1/expert-reports")
+@RequiredArgsConstructor
+@Tag(name = "전문가", description = "전문가 관련 API")
+public class ExpertReportController {
+
+ private final ExpertReportMapper mapper;
+ private final ExpertReportService expertReportService;
+
+ @Operation(summary = "전문가 리포트 목록을 조회합니다. (사용자 사용)")
+ @GetMapping
+ public ApiResponse> getExpertReports(
+ @RequestParam Long businessPlanId
+ ) {
+ List dtos = expertReportService
+ .getExpertReportsWithExpertByBusinessPlanId(businessPlanId);
+
+ List responses = dtos.stream()
+ .map(dto -> ExpertReportResponse.fromEntities(
+ dto.report(),
+ dto.expert()
+ ))
+ .toList();
+
+ return ApiResponse.success(responses);
+ }
+
+ @Operation(summary = "전문가 리포트를 조회합니다. (전문가 사용)")
+ @GetMapping("/{token}")
+ public ApiResponse getExpertReport(
+ @PathVariable String token
+ ) {
+ ExpertReportWithExpertDto dto = expertReportService.getExpertReportWithExpert(token);
+
+ ExpertReportResponse response = ExpertReportResponse.fromEntities(
+ dto.report(),
+ dto.expert()
+ );
+
+ return ApiResponse.success(response);
+ }
+
+ @Operation(summary = "전문가 리포트를 저장합니다 (전문가 사용)")
+ @PostMapping("/{token}")
+ public ApiResponse> save(
+ @PathVariable String token,
+ @Valid @RequestBody UpsertExpertReportRequest request
+ ) {
+ List details = mapper.toEntityList(request.details());
+
+ ExpertReport report = expertReportService.saveReport(
+ token,
+ request.overallComment(),
+ details,
+ request.saveType()
+ );
+
+ return ApiResponse.success(ExpertReportResponse.from(report));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java
new file mode 100644
index 00000000..b18164ca
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java
@@ -0,0 +1,13 @@
+package starlight.adapter.expertReport.webapi.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import starlight.domain.expertReport.enumerate.CommentType;
+
+public record CreateExpertReportDetailRequest(
+ @NotNull(message = "평가 타입은 필수입니다")
+ CommentType commentType,
+
+ @NotBlank(message = "내용은 필수입니다")
+ String content
+) { }
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java
new file mode 100644
index 00000000..0390448b
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java
@@ -0,0 +1,17 @@
+package starlight.adapter.expertReport.webapi.dto;
+
+import starlight.domain.expertReport.entity.ExpertReportDetail;
+import starlight.domain.expertReport.enumerate.CommentType;
+
+public record ExpertReportDetailResponse(
+ CommentType commentType,
+
+ String content
+) {
+ public static ExpertReportDetailResponse from(ExpertReportDetail detail) {
+ return new ExpertReportDetailResponse(
+ detail.getCommentType(),
+ detail.getContent()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java
new file mode 100644
index 00000000..d53d86c8
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java
@@ -0,0 +1,44 @@
+package starlight.adapter.expertReport.webapi.dto;
+
+import starlight.adapter.expert.webapi.dto.ExpertDetailResponse;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.expertReport.enumerate.SubmitStatus;
+
+import java.util.List;
+
+public record ExpertReportResponse(
+ ExpertDetailResponse expertDetailResponse,
+
+ SubmitStatus status,
+
+ boolean canEdit,
+
+ String overallComment,
+
+ List details
+) {
+ public static ExpertReportResponse fromEntities(ExpertReport report, Expert expert) {
+ return new ExpertReportResponse(
+ ExpertDetailResponse.from(expert),
+ report.getSubmitStatus(),
+ report.canEdit(),
+ report.getOverallComment(),
+ report.getDetails().stream()
+ .map(ExpertReportDetailResponse::from)
+ .toList()
+ );
+ }
+
+ public static ExpertReportResponse from(ExpertReport report) {
+ return new ExpertReportResponse(
+ null,
+ report.getSubmitStatus(),
+ report.canEdit(),
+ report.getOverallComment(),
+ report.getDetails().stream()
+ .map(ExpertReportDetailResponse::from)
+ .toList()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java
new file mode 100644
index 00000000..1f2c429c
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java
@@ -0,0 +1,16 @@
+package starlight.adapter.expertReport.webapi.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import starlight.domain.expertReport.enumerate.SaveType;
+
+import java.util.List;
+
+public record UpsertExpertReportRequest(
+ @NotNull(message = "저장 유형은 필수입니다")
+ SaveType saveType,
+
+ String overallComment,
+
+ List<@Valid CreateExpertReportDetailRequest> details
+) { }
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java
new file mode 100644
index 00000000..89cb75e0
--- /dev/null
+++ b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java
@@ -0,0 +1,23 @@
+package starlight.adapter.expertReport.webapi.mapper;
+
+import org.springframework.stereotype.Component;
+import starlight.adapter.expertReport.webapi.dto.CreateExpertReportDetailRequest;
+import starlight.domain.expertReport.entity.ExpertReportDetail;
+
+import java.util.List;
+
+@Component
+public class ExpertReportMapper {
+ public ExpertReportDetail toEntity(CreateExpertReportDetailRequest dto) {
+ return ExpertReportDetail.create(
+ dto.commentType(),
+ dto.content()
+ );
+ }
+
+ public List toEntityList(List dtos) {
+ return dtos.stream()
+ .map(this::toEntity)
+ .toList();
+ }
+}
diff --git a/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java b/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java
new file mode 100644
index 00000000..9e3f7638
--- /dev/null
+++ b/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java
@@ -0,0 +1,7 @@
+package starlight.adapter.member.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.member.entity.Credential;
+
+public interface CredentialRepository extends JpaRepository {
+}
diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java
new file mode 100644
index 00000000..3d5280aa
--- /dev/null
+++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java
@@ -0,0 +1,22 @@
+package starlight.adapter.member.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+import starlight.application.member.required.MemberQuery;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.exception.MemberErrorType;
+import starlight.domain.member.exception.MemberException;
+
+@Repository
+@RequiredArgsConstructor
+public class MemberJpa implements MemberQuery {
+
+ private final MemberRepository memberRepository;
+
+ @Override
+ public Member getOrThrow(Long id) {
+ return memberRepository.findById(id).orElseThrow(
+ () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND)
+ );
+ }
+}
diff --git a/src/main/java/starlight/adapter/member/persistence/MemberRepository.java b/src/main/java/starlight/adapter/member/persistence/MemberRepository.java
new file mode 100644
index 00000000..3b5d370a
--- /dev/null
+++ b/src/main/java/starlight/adapter/member/persistence/MemberRepository.java
@@ -0,0 +1,13 @@
+package starlight.adapter.member.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.member.entity.Member;
+
+import java.util.Optional;
+
+public interface MemberRepository extends JpaRepository {
+
+ Optional findByEmail(String email);
+
+ Optional findByProviderAndProviderId(String provider, String providerId);
+}
diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java
new file mode 100644
index 00000000..f5e8b042
--- /dev/null
+++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java
@@ -0,0 +1,29 @@
+package starlight.adapter.member.webapi;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.adapter.member.webapi.dto.MemberDetailResponse;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@Tag(name = "사용자", description = "사용자 관련 API")
+@RequestMapping("/v1/members")
+public class MemberController {
+
+ @GetMapping
+ @Operation(summary = "멤버 정보를 조회합니다.")
+ public ApiResponse getMemberDetail(
+ @AuthenticationPrincipal AuthDetails authDetails
+ ) {
+ return ApiResponse.success(MemberDetailResponse.from(authDetails.getUser()));
+ }
+}
diff --git a/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java
new file mode 100644
index 00000000..1d123553
--- /dev/null
+++ b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java
@@ -0,0 +1,28 @@
+package starlight.adapter.member.webapi.dto;
+
+import starlight.domain.member.entity.Member;
+
+public record MemberDetailResponse (
+ Long id,
+
+ String name,
+
+ String email,
+
+ String phoneNumber,
+
+ String provider,
+
+ String profileImageUrl
+){
+ public static MemberDetailResponse from(Member member) {
+ return new MemberDetailResponse(
+ member.getId(),
+ member.getName(),
+ member.getEmail(),
+ member.getPhoneNumber(),
+ member.getProvider(),
+ member.getProfileImageUrl()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java b/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java
new file mode 100644
index 00000000..07e35068
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java
@@ -0,0 +1,31 @@
+package starlight.adapter.ncp.clova.infra;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import starlight.shared.dto.infrastructure.ClovaStudioResponse;
+import starlight.adapter.ncp.clova.util.ClovaUtil;
+
+import java.util.Map;
+
+@Component
+public class ClovaStudioClient {
+
+ private final RestClient restClient;
+
+ public ClovaStudioClient(@Qualifier("clovaClient") RestClient restClient) {
+ this.restClient = restClient;
+ }
+
+ public ClovaStudioResponse check(String systemMsg, String userMsg, int criteriaSize) {
+ Map body = ClovaUtil.buildClovaRequestBody(systemMsg, userMsg, criteriaSize);
+
+ return restClient.post()
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .body(body)
+ .retrieve()
+ .body(ClovaStudioResponse.class);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java b/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java
new file mode 100644
index 00000000..3513f6a6
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java
@@ -0,0 +1,80 @@
+package starlight.adapter.ncp.clova.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class ClovaUtil {
+
+ public static Map buildClovaRequestBody(String systemMsg, String userMsg, int n){
+ Map props = new LinkedHashMap<>();
+ List required = new ArrayList<>();
+
+ for (int i = 1; i <= n; i++) {
+ String key = "c" + i;
+ props.put(key, Map.of("type", "boolean"));
+ required.add(key);
+ }
+
+ Map body = new LinkedHashMap<>();
+ body.put("messages", List.of(
+ Map.of("role", "system", "content", systemMsg),
+ Map.of("role", "user", "content", userMsg)
+ ));
+ body.put("thinking", Map.of("effort", "none"));
+ body.put("responseFormat", Map.of(
+ "type", "json",
+ "schema", Map.of(
+ "type", "array",
+ "items", Map.of("type", "boolean"),
+ "minItems", n,
+ "maxItems", n
+ )
+ ));
+ // 필요 시 파라미터 사용
+ // body.put("temperature", 0.0);
+ // body.put("topP", 0.9);
+ // body.put("topK", 0);
+ // body.put("repetitionPenalty", 1.1);
+ // body.put("maxCompletionTokens", Math.max(64, n * 8)); // maxTokens 금지
+
+ return body;
+ }
+
+ public static String buildUserContent(String input, List criteria) {
+ StringBuilder stringBuilder = new StringBuilder();
+ stringBuilder.append("[CHECKLIST]\n");
+
+ for (int i = 0; i < criteria.size(); i++) {
+ stringBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n");
+ }
+ stringBuilder.append("\n[INPUT]\n").append(input);
+ stringBuilder.append("\n\n[REQUEST]\n").append("위의 CHECKLIST 항목에 대해 각각 TRUE 또는 FALSE로 답변해 주세요. 답변은 JSON 배열 형식으로 제공해 주세요.");
+ return stringBuilder.toString();
+ }
+
+ public static List toBooleanList(String contentJson, int n) {
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root;
+ try {
+ root = mapper.readTree(contentJson);
+ } catch (Exception e) {
+ throw new IllegalStateException("Invalid JSON from Clova: " + contentJson, e);
+ }
+
+ List checks = new ArrayList<>();
+ for (int i = 0; i < n; i++) {
+ JsonNode node = root.get(i);
+ if (node == null || !node.isBoolean()) {
+ throw new IllegalStateException("Non-boolean value at index " + i);
+ }
+ checks.add(node.asBoolean());
+ }
+
+ return checks;
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java
new file mode 100644
index 00000000..0711e00b
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java
@@ -0,0 +1,69 @@
+package starlight.adapter.ncp.ocr;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import starlight.adapter.ncp.ocr.exception.OcrException;
+import starlight.adapter.ncp.ocr.infra.ClovaOcrClient;
+import starlight.adapter.ncp.ocr.infra.PdfDownloadClient;
+import starlight.adapter.ncp.ocr.util.OcrResponseMerger;
+import starlight.adapter.ncp.ocr.util.OcrTextExtractor;
+import starlight.adapter.ncp.ocr.util.PdfUtils;
+import starlight.application.infrastructure.provided.OcrProvider;
+import starlight.shared.dto.infrastructure.OcrResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ClovaOcrProvider implements OcrProvider {
+
+ private static final int MAX_PAGES_PER_REQUEST = 10;
+
+ private final ClovaOcrClient clovaOcrClient;
+ private final PdfDownloadClient pdfDownloadClient;
+
+ /**
+ * 지정한 PDF URL을 전체 페이지 OCR 처리한 뒤, 단일 응답으로 병합해 반환한다.
+ * 1) PDF 다운로드 → 2) 10페이지씩 분할 → 3) 각 조각별 OCR 호출 → 4) 전체 응답 병합
+ *
+ * @param pdfUrl 접근 가능한 원격 PDF의 절대 URL
+ * @return 병합된 CLOVA OCR 응답
+ * @throws OcrException 다음 에러 타입으로 래핑되어 발생할 수 있음
+ * - {@code PDF_DOWNLOAD_ERROR} : 네트워크/HTTP 오류 등으로 PDF 다운로드 실패
+ * - {@code PDF_EMPTY_RESPONSE} : 응답 본문이 비어 있음
+ * - {@code PDF_TOO_LARGE} : 허용된 최대 크기를 초과
+ * - {@code PDF_SPLIT_ERROR} : PDF 분할 실패
+ * - {@code OCR_CLIENT_ERROR} : OCR 서버에서 4xx/5xx 등 오류 응답
+ */
+ @Override
+ public OcrResponse ocrPdfByUrl(String pdfUrl) {
+ byte[] pdfBytes = pdfDownloadClient.downloadPdfFromUrl(pdfUrl);
+
+ List chunks = PdfUtils.splitByPageLimit(pdfBytes, MAX_PAGES_PER_REQUEST);
+
+ List parts = new ArrayList<>();
+ for (byte[] chunk : chunks) {
+ parts.add(clovaOcrClient.recognizePdfBytes(chunk));
+ }
+
+ return OcrResponseMerger.merge(parts);
+ }
+
+ /**
+ * 한 번에 텍스트 완성본까지 반환하는 편의 메서드.
+ * - 위의 ocrPdfByUrl로 병합 응답을 만든 뒤,
+ * - OcrTextExtractor로 토큰을 정제/라인브레이크 반영하여 평문 텍스트를 생성.
+ *
+ * @param pdfUrl 원격 PDF URL
+ * @return 페이지 구분선(“-----”)이 포함된 최종 텍스트
+ */
+ @Override
+ public String ocrPdfTextByUrl(String pdfUrl) {
+ OcrResponse ocrResponse = ocrPdfByUrl(pdfUrl);
+
+ return OcrTextExtractor.toPlainText(ocrResponse);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java b/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java
new file mode 100644
index 00000000..251a019f
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java
@@ -0,0 +1,43 @@
+package starlight.adapter.ncp.ocr.dto;
+
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.Base64;
+import java.util.List;
+import java.util.UUID;
+
+public record ClovaOcrRequest(
+ String version,
+ String requestId,
+ long timestamp,
+ String lang,
+ List images
+) {
+ public static ClovaOcrRequest create(String version, String lang, List images) {
+ return new ClovaOcrRequest(
+ version,
+ UUID.randomUUID().toString(),
+ System.currentTimeMillis(),
+ lang,
+ images
+ );
+ }
+
+ public static ClovaOcrRequest createPdfByBytes(String version, byte[] pdfBytes) {
+ String b64 = Base64.getEncoder().encodeToString(pdfBytes);
+ return create(version, "ko", List.of(Image.ofData("pdf", "input", b64)));
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record Image(
+ String format,
+ String name,
+ String url,
+ String data
+ ) {
+ public static Image ofData(String format, String name, String base64) {
+ return new Image(format, name, null, base64);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java
new file mode 100644
index 00000000..263e7180
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java
@@ -0,0 +1,22 @@
+package starlight.adapter.ncp.ocr.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import starlight.shared.apiPayload.exception.ErrorType;
+
+@Getter
+@RequiredArgsConstructor
+public enum OcrErrorType implements ErrorType {
+ PDF_SPLIT_ERROR(HttpStatus.BAD_REQUEST, "PDF 분할 실패"),
+ PDF_DOWNLOAD_ERROR(HttpStatus.BAD_GATEWAY, "PDF 다운로드 실패"),
+ OCR_CLIENT_ERROR(HttpStatus.BAD_GATEWAY, "CLOVA OCR 호출 실패"),
+ PDF_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "PDF 응답이 비어있음"),
+ PDF_TOO_LARGE(HttpStatus.INTERNAL_SERVER_ERROR, "PDF의 크기가 업로드 제한 크기를 넘습니다."),
+ PAGE_COUNT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "페이지 수를 가져오는 데 실패했습니다."),
+ ;
+
+ private final HttpStatus status;
+
+ private final String message;
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java
new file mode 100644
index 00000000..31128d49
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java
@@ -0,0 +1,10 @@
+package starlight.adapter.ncp.ocr.exception;
+
+import starlight.shared.apiPayload.exception.ErrorType;
+import starlight.shared.apiPayload.exception.GlobalException;
+
+public class OcrException extends GlobalException {
+ public OcrException(ErrorType errorType) {
+ super(errorType);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java b/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java
new file mode 100644
index 00000000..9427738b
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java
@@ -0,0 +1,42 @@
+package starlight.adapter.ncp.ocr.infra;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import starlight.adapter.ncp.ocr.dto.ClovaOcrRequest;
+import starlight.adapter.ncp.ocr.exception.OcrErrorType;
+import starlight.adapter.ncp.ocr.exception.OcrException;
+import starlight.shared.dto.infrastructure.OcrResponse;
+
+@Slf4j
+@Component
+public class ClovaOcrClient {
+
+ private final RestClient clovaOcrRestClient;
+
+ public ClovaOcrClient(@Qualifier("clovaOcrRestClient") RestClient restClient) {
+ this.clovaOcrRestClient = restClient;
+ }
+
+ public OcrResponse recognizePdfBytes(byte[] pdfBytes) {
+ ClovaOcrRequest request = ClovaOcrRequest.createPdfByBytes("V2", pdfBytes);
+
+ try {
+ OcrResponse resp = clovaOcrRestClient.post()
+ .body(request)
+ .retrieve()
+ .body(OcrResponse.class);
+
+ if (resp == null) {
+ log.warn("CLOVA OCR 응답이 null 입니다.");
+ throw new OcrException(OcrErrorType.OCR_CLIENT_ERROR);
+ }
+
+ return resp;
+ } catch (Exception e) {
+ log.warn("CLOVA OCR 호출 실패", e);
+ throw new OcrException(OcrErrorType.OCR_CLIENT_ERROR);
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java b/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java
new file mode 100644
index 00000000..188532bb
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java
@@ -0,0 +1,61 @@
+package starlight.adapter.ncp.ocr.infra;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import starlight.adapter.ncp.ocr.exception.OcrErrorType;
+import starlight.adapter.ncp.ocr.exception.OcrException;
+
+import java.net.URI;
+
+@Slf4j
+@Component
+public class PdfDownloadClient {
+
+ private static final int MAX_PDF_BYTES = 30 * 1024 * 1024; // 30MB까지 허용
+
+ private final RestClient pdfDownloadClient;
+
+ public PdfDownloadClient(@Qualifier("downloadClient") RestClient downloadClient) {
+ this.pdfDownloadClient = downloadClient;
+ }
+
+ /**
+ * 원격 PDF를 Spring RestClient로 다운로드하여 바이트 배열로 반환한다.
+ * - URI를 그대로 사용해(이중 인코딩 방지) GET 요청 수행
+ * - 2xx 가 아니면 내부적으로 예외 발생(RestClient의 기본 동작)
+ * - 응답 바이트 검증: 비어있으면 PDF_EMPTY_RESPONSE, 최대 크기 초과면 PDF_TOO_LARGE
+ * - 그 외 네트워크/타임아웃/HTTP 예외는 PDF_DOWNLOAD_ERROR로 래핑
+ *
+ * @param url 다운로드할 PDF의 절대 URL(프리사인드/퍼센트 인코딩 포함 가능)
+ * @return 다운로드한 PDF 바이트 배열
+ * @throws OcrException 다음의 에러타입으로 발생
+ * - PDF_EMPTY_RESPONSE : 본문이 비어있음
+ * - PDF_TOO_LARGE : 허용 최대 크기 초과
+ * - PDF_DOWNLOAD_ERROR : 네트워크/HTTP/기타 예외 전반
+ */
+ public byte[] downloadPdfFromUrl(String url) {
+ try {
+ ResponseEntity entity = pdfDownloadClient.get()
+ .uri(URI.create(url))
+ .retrieve()
+ .toEntity(byte[].class);
+
+ byte[] data = entity.getBody();
+ if (data == null || data.length == 0) {
+ throw new OcrException(OcrErrorType.PDF_EMPTY_RESPONSE);
+ }
+ if (data.length > MAX_PDF_BYTES) {
+ throw new OcrException(OcrErrorType.PDF_TOO_LARGE);
+ }
+ return data;
+ } catch (OcrException e) {
+ throw e; // 이미 처리된 OcrException은 재던짐
+ } catch (Exception e) {
+ log.error("PDF 다운로드 실패: {}", e.getMessage());
+ throw new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR);
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java b/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java
new file mode 100644
index 00000000..4c2f21cb
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java
@@ -0,0 +1,28 @@
+package starlight.adapter.ncp.ocr.util;
+
+import starlight.shared.dto.infrastructure.OcrResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class OcrResponseMerger {
+
+ private OcrResponseMerger() {}
+
+ public static OcrResponse merge(List parts) {
+ if (parts == null || parts.isEmpty()) {
+ return OcrResponse.createEmpty();
+ }
+
+ OcrResponse first = parts.get(0);
+
+ List images = new ArrayList<>();
+ for (OcrResponse resp : parts) {
+ if (resp.images() != null) {
+ images.addAll(resp.images());
+ }
+ }
+
+ return OcrResponse.create(first.version(), first.requestId(), images);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java b/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java
new file mode 100644
index 00000000..e52f872a
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java
@@ -0,0 +1,97 @@
+package starlight.adapter.ncp.ocr.util;
+
+import starlight.shared.dto.infrastructure.OcrResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class OcrTextExtractor {
+
+ private OcrTextExtractor() {}
+
+ private static final double MINIMUM_CONFIDENCE_THRESHOLD = 0.85;
+
+ /**
+ * 모든 페이지를 하나의 문자열로 병합.
+ * 페이지 사이에는 "\n\n-----\n\n" 구분선을 넣는다.
+ *
+ * @param response OCR 원본 응답 (널 가능)
+ * @return 결합된 전체 텍스트 (널 입력이면 빈 문자열)
+ */
+ public static String toPlainText(OcrResponse response) {
+ List pageTexts = toPages(response);
+ return String.join("\n\n-----\n\n", pageTexts);
+ }
+
+ /**
+ * 페이지(= images 배열의 각 요소)별 텍스트를 리스트로 반환.
+ *
+ * @param ocrResponse OCR 원본 응답
+ * @return 각 페이지의 문자열 (images가 비었거나 널이면 빈 리스트)
+ */
+ public static List toPages(OcrResponse ocrResponse) {
+ List pages = new ArrayList<>();
+ if (ocrResponse == null || ocrResponse.images() == null) {
+ return pages;
+ }
+
+ for (OcrResponse.ImageResult page : ocrResponse.images()) {
+ if (page == null || page.fields() == null || page.fields().isEmpty()) {
+ pages.add("");
+ continue;
+ }
+
+ StringBuilder pageBuilder = new StringBuilder();
+ boolean atLineStart = true;
+
+ for (OcrResponse.ImageResult.Field fieldItem : page.fields()) {
+ if (fieldItem == null) {
+ continue;
+ }
+
+ Double confidence = fieldItem.inferConfidence();
+ if (confidence == null || confidence < MINIMUM_CONFIDENCE_THRESHOLD) {
+ continue;
+ }
+
+ String normalizedToken = normalize(fieldItem.inferText());
+ if (normalizedToken.isEmpty()) {
+ continue;
+ }
+
+ if (!atLineStart) {
+ pageBuilder.append(' ');
+ }
+
+ pageBuilder.append(normalizedToken);
+ if (Boolean.TRUE.equals(fieldItem.lineBreak())) {
+ pageBuilder.append('\n');
+ atLineStart = true;
+ } else {
+ atLineStart = false;
+ }
+ }
+
+ pages.add(pageBuilder.toString().strip());
+ }
+
+ return pages;
+ }
+
+ /**
+ * 토큰 정규화:
+ * - null → "" (스킵되도록)
+ * - 앞뒤 공백 제거
+ * - 연속 공백 1칸으로 축약
+ * - 구두점 앞의 공백 제거, 괄호 주변 공백 정리
+ */
+ private static String normalize(String raw) {
+ if (raw == null) return "";
+ String out = raw.strip()
+ .replaceAll("\\s+", " ");
+ out = out.replaceAll("\\s+([,.:;!?])", "$1")
+ .replaceAll("\\(\\s+", "(")
+ .replaceAll("\\s+\\)", ")");
+ return out;
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java b/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java
new file mode 100644
index 00000000..c11eab05
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java
@@ -0,0 +1,59 @@
+package starlight.adapter.ncp.ocr.util;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import starlight.adapter.ncp.ocr.exception.OcrErrorType;
+import starlight.adapter.ncp.ocr.exception.OcrException;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+public final class PdfUtils {
+
+ private PdfUtils() {}
+
+ /**
+ * PDF를 페이지 단위로 잘라 maxPagesPerChunk 크기의 조각들로 분할하여 반환합니다.
+ *
+ * @param sourcePdfBytes 원본 PDF 바이트
+ * @param maxPagesPerChunk 조각당 최대 페이지 수(예: 10)
+ * @return 분할된 PDF 바이트 배열 목록(페이지 순서 유지)
+ */
+ public static List splitByPageLimit(byte[] sourcePdfBytes, int maxPagesPerChunk) {
+ try (PDDocument sourceDoc = PDDocument.load(new ByteArrayInputStream(sourcePdfBytes))) {
+
+ int totalPages = sourceDoc.getNumberOfPages();
+ if (totalPages <= maxPagesPerChunk) {
+ return List.of(sourcePdfBytes);
+ }
+
+ List chunks = new ArrayList<>();
+ int startPageIndex = 0;
+
+ while (startPageIndex < totalPages) {
+ int endPageIndexExclusive = Math.min(startPageIndex + maxPagesPerChunk, totalPages);
+
+ // 부분 문서 생성
+ try (PDDocument chunkDoc = new PDDocument()) {
+ for (int pageIndex = startPageIndex; pageIndex < endPageIndexExclusive; pageIndex++) {
+ chunkDoc.addPage(sourceDoc.getPage(pageIndex));
+ }
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ chunkDoc.save(out);
+ chunks.add(out.toByteArray());
+ }
+ }
+
+ startPageIndex = endPageIndexExclusive;
+ }
+
+ return chunks;
+ } catch (Exception e) {
+ log.error("PDF 분할 실패: {}", e.getMessage());
+ throw new OcrException(OcrErrorType.PDF_SPLIT_ERROR);
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java
new file mode 100644
index 00000000..eca9cb4c
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java
@@ -0,0 +1,119 @@
+package starlight.adapter.ncp.storage;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
+import software.amazon.awssdk.services.s3.model.PutObjectAclRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
+import starlight.application.infrastructure.provided.PresignedUrlProvider;
+import starlight.shared.dto.infrastructure.PreSignedUrlResponse;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NcpPresignedUrlProvider implements PresignedUrlProvider {
+
+ private final S3Client ncpS3Client;
+ private final S3Presigner s3Presigner;
+
+ @Value("${cloud.ncp.object-storage.bucket-name}")
+ private String bucket;
+ @Value("${cloud.ncp.object-storage.end-point}")
+ private String endpoint;
+
+ /**
+ * 업로드용 Presigned URL 생성
+ * - 클라이언트는 추가 헤더 없이 PUT(binary)만 하면 됨
+ *
+ * @param userId 사용자 ID (파일 경로 생성에 사용)
+ * @param originalFileName 원본 파일명 (확장자 추출에 사용)
+ * @return Presigned URL 및 최종 공개 URL을 포함한 응답 객체
+ */
+ @Override
+ public PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName) {
+ String safeFileName = encodePathSegment(originalFileName);
+ String key = String.format("%d/%s", userId, safeFileName);
+
+ PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build();
+
+ PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofMinutes(10))
+ .putObjectRequest(putObjectRequest)
+ .build();
+
+ PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
+
+ String presignedUrl = presignedRequest.url().toString();
+ String objectUrl = buildObjectUrl(key);
+
+ return PreSignedUrlResponse.of(presignedUrl, objectUrl);
+ }
+
+ /**
+ * 업로드 후 공개가 필요할 때 서버에서 ACL을 지정
+ *
+ * @param objectUrl objectUrl ACL 변경 대상 오브젝트의 URL
+ * @return 공개 설정이 완료된 오브젝트 URL
+ */
+ @Override
+ public String makePublic(String objectUrl) {
+ String key = extractKeyFromUrl(objectUrl);
+
+ try {
+ PutObjectAclRequest aclRequest = PutObjectAclRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .acl(ObjectCannedACL.PUBLIC_READ)
+ .build();
+ ncpS3Client.putObjectAcl(aclRequest);
+ log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl);
+ } catch (S3Exception e) {
+ log.error("객체 공개 처리 실패 - Message: {}", e.getMessage());
+ throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e);
+ }
+ return objectUrl;
+ }
+
+ /**
+ * 파일명/경로 segment-safe 인코딩 (공백을 %20으로 보존)
+ */
+ private static String encodePathSegment(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ /**
+ * 가상호스트 형태의 공개 URL 생성
+ */
+ private String buildObjectUrl(String key) {
+ String host = endpoint.replaceFirst("^https?://", "").replaceAll("/$", "");
+
+ return String.format("https://%s.%s/%s", bucket, host, key);
+ }
+
+ /**
+ * Object URL에서 key 부분 추출
+ */
+ private String extractKeyFromUrl(String objectUrl) {
+ int schemeEnd = objectUrl.indexOf("://");
+ if (schemeEnd == -1) throw new IllegalArgumentException("잘못된 URL 형식");
+
+ int pathStart = objectUrl.indexOf("/", schemeEnd + 3);
+ if (pathStart == -1) throw new IllegalArgumentException("잘못된 URL 형식 - path가 없습니다");
+
+ return objectUrl.substring(pathStart + 1);
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/webapi/ImageController.java b/src/main/java/starlight/adapter/ncp/webapi/ImageController.java
new file mode 100644
index 00000000..7ff465b4
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/webapi/ImageController.java
@@ -0,0 +1,32 @@
+package starlight.adapter.ncp.webapi;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.shared.dto.infrastructure.PreSignedUrlResponse;
+import starlight.application.infrastructure.provided.PresignedUrlProvider;
+import starlight.adapter.ncp.webapi.swagger.ImageApiDoc;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@RestController
+@RequestMapping("/v1/images")
+@RequiredArgsConstructor
+public class ImageController implements ImageApiDoc {
+
+ private final PresignedUrlProvider presignedUrlReader;
+
+ @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ApiResponse getPresignedUrl(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @RequestParam String fileName
+ ) {
+ return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authDetails.getMemberId(), fileName));
+ }
+
+ @PostMapping("/upload-url/public")
+ public ApiResponse finalizePublic(@RequestParam String objectUrl) {
+ return ApiResponse.success(presignedUrlReader.makePublic(objectUrl));
+ }
+}
diff --git a/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java
new file mode 100644
index 00000000..8919e99e
--- /dev/null
+++ b/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java
@@ -0,0 +1,80 @@
+package starlight.adapter.ncp.webapi.swagger;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.shared.dto.infrastructure.PreSignedUrlResponse;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+@Tag(name = "UTIL", description = "유틸리티 API")
+public interface ImageApiDoc {
+
+ @Operation(
+ summary = "Presigned URL 발급",
+ description = "S3 Presigned URL을 발급합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "성공적으로 Presigned URL 반환",
+ content = @Content(
+ schema = @Schema(implementation = PreSignedUrlResponse.class),
+ examples = @ExampleObject(
+ name = "Presigned URL 예시",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": {
+ "preSignedUrl": "https://starlight-s3.kr.object.ncloudstorage.com/test/..........",
+ "objectUrl": "https://starlight-s3.kr.object.ncloudstorage.com/test/78e6919c-0b0f-47bd-96c6-2b3e9b176167-test"
+ },
+ "error": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE)
+ ApiResponse getPresignedUrl(
+ @AuthenticationPrincipal AuthDetails authDetails,
+ @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName
+ );
+
+ @Operation(
+ summary = "이미지 공개 전환",
+ description = "업로드된 이미지를 공개 상태로 전환합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "성공적으로 공개 처리",
+ content = @Content(
+ examples = @ExampleObject(
+ name = "공개 처리 성공",
+ value = """
+ {
+ "result": "SUCCESS",
+ "data": "test/000239dc-542e-493a-aceb-6eda786d0eaf-tests",
+ "error": null
+ }
+ """
+ )
+ )
+ )
+ })
+ @PostMapping("/v1/images/upload-url/public")
+ ApiResponse> finalizePublic(
+ @io.swagger.v3.oas.annotations.Parameter(description = "S3 Object URL", required = true) @RequestParam String objectUrl
+ );
+}
+
diff --git a/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java
new file mode 100644
index 00000000..f4cf0f5d
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java
@@ -0,0 +1,40 @@
+package starlight.adapter.order.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+import starlight.application.order.provided.OrdersQuery;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.order.exception.OrderErrorType;
+import starlight.domain.order.exception.OrderException;
+import starlight.domain.order.order.Orders;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class OrderRepositoryJpa implements OrdersQuery {
+
+ private final OrdersRepository repository;
+
+ @Override
+ public Optional findByOrderCode(String orderCode) {
+ return repository.findByOrderCode(orderCode);
+ }
+
+ @Override
+ public List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(Long buyerId) {
+ return repository.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId);
+ }
+
+ @Override
+ public Orders getByOrderCodeOrThrow(String orderCode) {
+ return repository.findByOrderCode(orderCode)
+ .orElseThrow(() -> new OrderException(OrderErrorType.ORDER_NOT_FOUND));
+ }
+
+ @Override
+ public Orders save(Orders order) {
+ return repository.save(order);
+ }
+}
diff --git a/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java
new file mode 100644
index 00000000..b6ea0b27
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java
@@ -0,0 +1,24 @@
+package starlight.adapter.order.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import starlight.domain.order.order.Orders;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface OrdersRepository extends JpaRepository {
+
+ Optional findByOrderCode(String orderCode);
+
+ @Query("""
+ select distinct o
+ from Orders o
+ left join fetch o.payments p
+ where o.buyerId = :buyerId
+ order by o.createdAt desc
+ """)
+ List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(@Param("buyerId") Long buyerId);
+
+}
diff --git a/src/main/java/starlight/adapter/order/toss/TossClient.java b/src/main/java/starlight/adapter/order/toss/TossClient.java
new file mode 100644
index 00000000..ee8640ea
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/toss/TossClient.java
@@ -0,0 +1,120 @@
+package starlight.adapter.order.toss;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import starlight.application.order.provided.dto.TossClientResponse;
+import starlight.domain.order.exception.OrderErrorType;
+import starlight.domain.order.exception.OrderException;
+
+import java.util.Map;
+import java.util.Objects;
+
+@Slf4j
+@Component
+public class TossClient {
+
+ private final RestClient restClient;
+
+ public TossClient(@Qualifier("tossRestClient") RestClient restClient) {
+ this.restClient = restClient;
+ }
+
+ public TossClientResponse.Confirm confirm(String orderCode, String paymentKey, Long price) {
+ Map body = Map.of(
+ "paymentKey", paymentKey,
+ "orderId", orderCode,
+ "amount", price
+ );
+ try {
+ TossClientResponse.Confirm response = restClient.post()
+ .uri("/v1/payments/confirm")
+ .header("Idempotency-Key", orderCode)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(body)
+ .retrieve()
+ .body(TossClientResponse.Confirm.class);
+
+ // 응답 검증
+ validateConfirmResponse(response, orderCode, price);
+
+ return response;
+
+ } catch (Exception e) {
+ log.error("토스 결제 승인 요청 중 에러 발생. orderId: {}, paymentKey: {}, message: {}", orderCode, paymentKey, e.getMessage(), e);
+ throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR);
+ }
+ }
+
+ public TossClientResponse.Cancel cancel(String paymentKey, String reason) {
+ Map body = Map.of(
+ "cancelReason", reason != null ? reason : "user_request"
+ );
+ try {
+ TossClientResponse.Cancel response = restClient.post()
+ .uri("/v1/payments/{paymentKey}/cancel", paymentKey)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(body)
+ .retrieve()
+ .body(TossClientResponse.Cancel.class);
+
+ // 응답 검증
+ validateCancelResponse(response);
+
+ return response;
+
+ } catch (Exception e) {
+ log.error("토스 환불 요청 중 에러발생: {}", e.getMessage(), e);
+ log.error("paymentKey: {}, reason: {}", paymentKey, reason);
+ throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR);
+ }
+
+ }
+
+ /**
+ * confirm 응답 검증
+ */
+ private void validateConfirmResponse(TossClientResponse.Confirm response, String expectedOrderId, Long expectedAmount) {
+ if (response == null) {
+ throw new IllegalStateException("PG 응답이 null입니다.");
+ }
+
+ if (!Objects.equals(response.orderId(), expectedOrderId)) {
+ throw new IllegalStateException(
+ String.format("PG 응답의 주문번호가 일치하지 않습니다. 예상: %s, 실제: %s",
+ expectedOrderId, response.orderId())
+ );
+ }
+
+ if (!Objects.equals(response.totalAmount(), expectedAmount)) {
+ throw new IllegalStateException(
+ String.format("PG 응답 금액이 주문 금액과 일치하지 않습니다. 예상: %d, 실제: %d",
+ expectedAmount, response.totalAmount())
+ );
+ }
+
+ if (!"DONE".equals(response.status())) {
+ throw new IllegalStateException(
+ "PG 응답 상태가 완료(DONE)가 아닙니다. status=" + response.status()
+ );
+ }
+ }
+
+ /**
+ * cancel 응답 검증
+ */
+ private void validateCancelResponse(TossClientResponse.Cancel response) {
+ if (response == null) {
+ throw new IllegalStateException("PG 취소 응답이 null입니다.");
+ }
+
+ // 취소 응답은 status가 CANCELED여야 함
+ if (response.status() != null && !"CANCELED".equals(response.status())) {
+ throw new IllegalStateException(
+ "PG 취소가 완료 상태(CANCELED)가 아닙니다. status=" + response.status()
+ );
+ }
+ }
+}
diff --git a/src/main/java/starlight/adapter/order/webapi/OrderController.java b/src/main/java/starlight/adapter/order/webapi/OrderController.java
new file mode 100644
index 00000000..46829fee
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/OrderController.java
@@ -0,0 +1,102 @@
+package starlight.adapter.order.webapi;
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import starlight.adapter.auth.security.auth.AuthDetails;
+import starlight.application.order.provided.dto.TossClientResponse;
+import starlight.adapter.order.webapi.dto.request.OrderCancelRequest;
+import starlight.adapter.order.webapi.dto.request.OrderConfirmRequest;
+import starlight.adapter.order.webapi.dto.request.OrderPrepareRequest;
+import starlight.adapter.order.webapi.dto.response.OrderCancelResponse;
+import starlight.adapter.order.webapi.dto.response.OrderConfirmResponse;
+import starlight.adapter.order.webapi.dto.response.OrderPrepareResponse;
+import starlight.adapter.order.webapi.dto.response.WalletCheckResponse;
+import starlight.application.order.provided.OrderPaymentService;
+import starlight.application.order.provided.dto.PaymentHistoryItemDto;
+import starlight.application.usage.provided.UsageCreditPort;
+import starlight.domain.order.order.Orders;
+import starlight.shared.apiPayload.response.ApiResponse;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+@Tag(name = "결제", description = "결제 관련 API")
+@RequestMapping("/v1/orders")
+public class OrderController {
+
+ private final OrderPaymentService orderPaymentService;
+ private final UsageCreditPort usageCreditPort;
+
+ /**
+ * 결제 준비 (주문 생성)
+ * POST /api/toss/request
+ */
+ @PostMapping("/request")
+ public ApiResponse prepareOrder(
+ @Valid @RequestBody OrderPrepareRequest request,
+ @AuthenticationPrincipal AuthDetails authDetails
+ ) {
+ Orders order = orderPaymentService.prepare(
+ request.orderCode(),
+ authDetails.getMemberId(),
+ request.productCode()
+ );
+
+ OrderPrepareResponse response = OrderPrepareResponse.from(order);
+
+ return ApiResponse.success(response);
+ }
+
+ /**
+ * 결제 승인
+ * POST /api/toss/confirm
+ */
+ @PostMapping("/confirm")
+ public ApiResponse confirmPayment(
+ @Valid @RequestBody OrderConfirmRequest request,
+ @AuthenticationPrincipal AuthDetails authDetails
+ ) {
+ Orders order = orderPaymentService.confirm(
+ request.orderCode(),
+ request.paymentKey(),
+ authDetails.getMemberId()
+ );
+
+ OrderConfirmResponse response = OrderConfirmResponse.from(order);
+
+ return ApiResponse.success(response);
+ }
+
+ /**
+ * 결제 취소
+ * POST /api/toss/cancel
+ */
+ @PostMapping("/cancel")
+ public ApiResponse cancelPayment(
+ @Valid @RequestBody OrderCancelRequest request
+ ) {
+ TossClientResponse.Cancel tossResponse = orderPaymentService.cancel(request);
+
+ OrderCancelResponse response = OrderCancelResponse.from(tossResponse);
+
+ return ApiResponse.success(response);
+ }
+
+ /**
+ * 나의 결제 내역 조회
+ * GET /api/orders
+ */
+ @GetMapping
+ public ApiResponse> getMyPayments(
+ @AuthenticationPrincipal AuthDetails authDetails
+ ) {
+ Long memberId = authDetails.getMemberId();
+ List history = orderPaymentService.getPaymentHistory(memberId);
+
+ return ApiResponse.success(history);
+ }
+}
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java
new file mode 100644
index 00000000..7aa386f3
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java
@@ -0,0 +1,11 @@
+package starlight.adapter.order.webapi.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OrderCancelRequest(
+ @NotBlank(message = "orderCode는 필수입니다.")
+ String orderCode,
+
+ @NotBlank(message = "취소 사유는 필수입니다.")
+ String reason
+) { }
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java
new file mode 100644
index 00000000..9e246bc0
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java
@@ -0,0 +1,11 @@
+package starlight.adapter.order.webapi.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OrderConfirmRequest(
+ @NotBlank
+ String paymentKey,
+
+ @NotBlank
+ String orderCode
+) { }
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java
new file mode 100644
index 00000000..60db489f
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java
@@ -0,0 +1,11 @@
+package starlight.adapter.order.webapi.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OrderPrepareRequest(
+ @NotBlank
+ String orderCode,
+
+ @NotBlank
+ String productCode
+) { }
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java
new file mode 100644
index 00000000..6ffc25cd
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java
@@ -0,0 +1,19 @@
+package starlight.adapter.order.webapi.dto.response;
+
+import starlight.application.order.provided.dto.TossClientResponse;
+
+public record OrderCancelResponse(
+ String orderId,
+ String paymentKey,
+ String status,
+ Integer totalAmount
+) {
+ public static OrderCancelResponse from(TossClientResponse.Cancel tossResponse) {
+ return new OrderCancelResponse(
+ tossResponse.orderId(),
+ tossResponse.paymentKey(),
+ tossResponse.status(),
+ tossResponse.totalAmount()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java
new file mode 100644
index 00000000..06c87607
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java
@@ -0,0 +1,34 @@
+package starlight.adapter.order.webapi.dto.response;
+
+import starlight.domain.order.order.Orders;
+import starlight.domain.order.order.PaymentRecords;
+
+import java.time.Instant;
+
+public record OrderConfirmResponse(
+ Long buyerId,
+ String paymentKey,
+ String orderId,
+ Long amount,
+ String status,
+ Instant approvedAt,
+ String receiptUrl,
+ String method,
+ String provider
+) {
+ public static OrderConfirmResponse from(Orders order) {
+ PaymentRecords done = order.getLatestPaymentOrThrow();
+
+ return new OrderConfirmResponse(
+ order.getBuyerId(),
+ done.getPaymentKey(),
+ order.getOrderCode(),
+ order.getPrice(),
+ order.getStatus().name(),
+ done.getApprovedAt(),
+ done.getReceiptUrl(),
+ done.getMethod(),
+ done.getProvider()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java
new file mode 100644
index 00000000..daa67302
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java
@@ -0,0 +1,17 @@
+package starlight.adapter.order.webapi.dto.response;
+
+import starlight.domain.order.order.Orders;
+
+public record OrderPrepareResponse(
+ String orderCode,
+ Long amount,
+ String status
+) {
+ public static OrderPrepareResponse from(Orders order) {
+ return new OrderPrepareResponse(
+ order.getOrderCode(),
+ order.getPrice(),
+ order.getStatus().name()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java
new file mode 100644
index 00000000..325321bf
--- /dev/null
+++ b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java
@@ -0,0 +1,11 @@
+package starlight.adapter.order.webapi.dto.response;
+
+public record WalletCheckResponse(
+ Boolean hasCredit
+) {
+ public static WalletCheckResponse of(Boolean hasCredit) {
+ return new WalletCheckResponse(
+ hasCredit
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java
new file mode 100644
index 00000000..e055482b
--- /dev/null
+++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java
@@ -0,0 +1,7 @@
+package starlight.adapter.usage.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.order.wallet.UsageHistory;
+
+public interface UsageHistoryRepository extends JpaRepository {
+}
diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java
new file mode 100644
index 00000000..803689b9
--- /dev/null
+++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java
@@ -0,0 +1,18 @@
+package starlight.adapter.usage.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+import starlight.application.usage.provided.UsageHistoryQuery;
+import starlight.domain.order.wallet.UsageHistory;
+
+@Repository
+@RequiredArgsConstructor
+public class UsageHistoryRepositoryJpa implements UsageHistoryQuery {
+
+ private final UsageHistoryRepository repository;
+
+ @Override
+ public UsageHistory save(UsageHistory usageHistory){
+ return repository.save(usageHistory);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java
new file mode 100644
index 00000000..5f1352a7
--- /dev/null
+++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java
@@ -0,0 +1,11 @@
+package starlight.adapter.usage.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import starlight.domain.order.wallet.UsageWallet;
+
+import java.util.Optional;
+
+public interface UsageWalletRepository extends JpaRepository {
+
+ Optional findByUserId(Long userId);
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java
new file mode 100644
index 00000000..c8bdf4c6
--- /dev/null
+++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java
@@ -0,0 +1,25 @@
+package starlight.adapter.usage.persistence;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+import starlight.application.usage.provided.UsageWalletQuery;
+import starlight.domain.order.wallet.UsageWallet;
+
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class UsageWalletRepositoryJpa implements UsageWalletQuery {
+
+ private final UsageWalletRepository repository;
+
+ @Override
+ public Optional findByUserId(Long userId){
+ return repository.findByUserId(userId);
+ }
+
+ @Override
+ public UsageWallet save(UsageWallet usageWallet){
+ return repository.save(usageWallet);
+ }
+}
diff --git a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java
new file mode 100644
index 00000000..0db896a8
--- /dev/null
+++ b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java
@@ -0,0 +1,129 @@
+package starlight.application.aireport;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.adapter.ai.util.AiReportResponseParser;
+import starlight.application.aireport.provided.AiReportService;
+import starlight.application.aireport.provided.dto.AiReportResponse;
+import starlight.application.aireport.required.AiReportGrader;
+import starlight.application.aireport.required.AiReportQuery;
+import starlight.application.businessplan.provided.BusinessPlanService;
+import starlight.application.businessplan.provided.dto.BusinessPlanResponse;
+import starlight.application.businessplan.required.BusinessPlanQuery;
+import starlight.application.businessplan.util.BusinessPlanContentExtractor;
+import starlight.application.infrastructure.provided.OcrProvider;
+import starlight.domain.aireport.entity.AiReport;
+import starlight.domain.aireport.exception.AiReportErrorType;
+import starlight.domain.aireport.exception.AiReportException;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.enumerate.PlanStatus;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class AiReportServiceImpl implements AiReportService {
+
+ private final BusinessPlanQuery businessPlanQuery;
+ private final BusinessPlanService businessPlanService;
+ private final AiReportQuery aiReportQuery;
+ private final AiReportGrader aiReportGrader;
+ private final ObjectMapper objectMapper;
+ private final OcrProvider ocrProvider;
+ private final AiReportResponseParser responseParser;
+ private final BusinessPlanContentExtractor contentExtractor;
+
+ @Override
+ public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) {
+
+ BusinessPlan plan = businessPlanQuery.getOrThrow(planId);
+ checkBusinessPlanOwned(plan, memberId);
+ checkBusinessPlanWritingCompleted(plan);
+
+ AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan));
+
+ String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult);
+
+ AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan);
+
+ return responseParser.toResponse(aiReportQuery.save(aiReport));
+ }
+
+ @Override
+ public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) {
+
+ BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf(
+ title,
+ pdfUrl,
+ memberId
+ );
+ Long businessPlanId = businessPlanResult.businessPlanId();
+ BusinessPlan plan = businessPlanQuery.getOrThrow(businessPlanId);
+
+ String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl);
+
+ AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText);
+
+ String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult);
+
+ AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan);
+
+ return responseParser.toResponse(aiReportQuery.save(aiReport));
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public AiReportResponse getAiReport(Long planId, Long memberId) {
+ BusinessPlan plan = businessPlanQuery.getOrThrow(planId);
+ checkBusinessPlanOwned(plan, memberId);
+
+ AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId)
+ .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND));
+
+ return responseParser.toResponse(aiReport);
+ }
+
+ private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) {
+ JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult);
+ String rawJsonString;
+ try {
+ rawJsonString = objectMapper.writeValueAsString(gradingJsonNode);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to convert JsonNode to string", e);
+ }
+ return rawJsonString;
+ }
+
+ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) {
+ Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId());
+
+ AiReport aiReport;
+ if (existingReport.isPresent()) {
+ aiReport = existingReport.get();
+ aiReport.update(rawJsonString);
+ } else {
+ aiReport = AiReport.create(plan.getId(), rawJsonString);
+ }
+ plan.updateStatus(PlanStatus.AI_REVIEWED);
+ businessPlanQuery.save(plan);
+
+ return aiReport;
+ }
+
+ private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) {
+ if (!plan.isOwnedBy(memberId)) {
+ throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS);
+ }
+ }
+
+ private void checkBusinessPlanWritingCompleted(BusinessPlan plan) {
+ if (!plan.areWritingCompleted()) {
+ throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT);
+ }
+ }
+}
diff --git a/src/main/java/starlight/application/aireport/provided/AiReportService.java b/src/main/java/starlight/application/aireport/provided/AiReportService.java
new file mode 100644
index 00000000..6c618fd6
--- /dev/null
+++ b/src/main/java/starlight/application/aireport/provided/AiReportService.java
@@ -0,0 +1,11 @@
+package starlight.application.aireport.provided;
+
+import starlight.application.aireport.provided.dto.AiReportResponse;
+
+public interface AiReportService {
+ AiReportResponse gradeBusinessPlan(Long businessPlanId, Long memberId);
+
+ AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId);
+
+ AiReportResponse getAiReport(Long businessPlanId, Long memberId);
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java
new file mode 100644
index 00000000..93ec0c04
--- /dev/null
+++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java
@@ -0,0 +1,67 @@
+package starlight.application.aireport.provided.dto;
+
+import com.fasterxml.jackson.annotation.JsonRawValue;
+import java.util.List;
+
+/**
+ * AI 리포트 응답 DTO
+ * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO
+ */
+public record AiReportResponse(
+ Long id, // null 가능 (LLM 결과 파싱 시에는 null)
+ Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null)
+ Integer totalScore,
+ Integer problemRecognitionScore,
+ Integer feasibilityScore,
+ Integer growthStrategyScore,
+ Integer teamCompetenceScore,
+ List sectionScores,
+ List strengths,
+ List weaknesses
+) {
+ public record SectionScoreDetailResponse(
+ String sectionType,
+ @JsonRawValue String gradingListScores
+ ) {}
+
+ public record StrengthWeakness(
+ String title,
+ String content
+ ) {}
+
+ /**
+ * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null)
+ */
+ public static AiReportResponse fromGradingResult(
+ Integer problemRecognitionScore,
+ Integer feasibilityScore,
+ Integer growthStrategyScore,
+ Integer teamCompetenceScore,
+ List sectionScores,
+ List strengths,
+ List weaknesses
+ ) {
+ Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore);
+
+ return new AiReportResponse(
+ null,
+ null,
+ totalScore,
+ problemRecognitionScore,
+ feasibilityScore,
+ growthStrategyScore,
+ teamCompetenceScore,
+ sectionScores,
+ strengths,
+ weaknesses
+ );
+ }
+
+ private static Integer sumTotalScore(Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, Integer teamCompetenceScore) {
+ return (problemRecognitionScore != null ? problemRecognitionScore : 0) +
+ (feasibilityScore != null ? feasibilityScore : 0) +
+ (growthStrategyScore != null ? growthStrategyScore : 0) +
+ (teamCompetenceScore != null ? teamCompetenceScore : 0);
+ }
+}
+
diff --git a/src/main/java/starlight/application/aireport/required/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java
new file mode 100644
index 00000000..0ba2d255
--- /dev/null
+++ b/src/main/java/starlight/application/aireport/required/AiReportGrader.java
@@ -0,0 +1,7 @@
+package starlight.application.aireport.required;
+
+import starlight.application.aireport.provided.dto.AiReportResponse;
+
+public interface AiReportGrader {
+ AiReportResponse gradeContent(String content);
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/aireport/required/AiReportQuery.java b/src/main/java/starlight/application/aireport/required/AiReportQuery.java
new file mode 100644
index 00000000..8e18704a
--- /dev/null
+++ b/src/main/java/starlight/application/aireport/required/AiReportQuery.java
@@ -0,0 +1,11 @@
+package starlight.application.aireport.required;
+
+import starlight.domain.aireport.entity.AiReport;
+
+import java.util.Optional;
+
+public interface AiReportQuery {
+ AiReport save(AiReport aiReport);
+ Optional findByBusinessPlanId(Long businessPlanId);
+}
+
diff --git a/src/main/java/starlight/application/auth/AuthServiceImpl.java b/src/main/java/starlight/application/auth/AuthServiceImpl.java
new file mode 100644
index 00000000..bc7baa2b
--- /dev/null
+++ b/src/main/java/starlight/application/auth/AuthServiceImpl.java
@@ -0,0 +1,118 @@
+package starlight.application.auth;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.adapter.auth.webapi.dto.request.AuthRequest;
+import starlight.adapter.auth.webapi.dto.request.SignInRequest;
+import starlight.adapter.auth.webapi.dto.response.MemberResponse;
+import starlight.application.auth.provided.AuthService;
+import starlight.application.auth.required.KeyValueMap;
+import starlight.application.auth.required.TokenProvider;
+import starlight.application.member.provided.CredentialService;
+import starlight.application.member.provided.MemberService;
+import starlight.domain.auth.exception.AuthErrorType;
+import starlight.domain.auth.exception.AuthException;
+import starlight.domain.member.entity.Credential;
+import starlight.domain.member.entity.Member;
+import starlight.domain.member.exception.MemberErrorType;
+import starlight.domain.member.exception.MemberException;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class AuthServiceImpl implements AuthService {
+
+ private final MemberService memberService;
+ private final CredentialService credentialService;
+ private final TokenProvider tokenProvider;
+ private final KeyValueMap redisClient;
+
+ @Value("${jwt.token.refresh-expiration-time}")
+ private Long refreshTokenExpirationTime;
+
+ /**
+ * 회원가입 메서드
+ *
+ * @param authRequest
+ * @return MemberResponse
+ */
+ @Override
+ @Transactional
+ public MemberResponse signUp(AuthRequest authRequest) {
+ Credential credential = credentialService.createCredential(authRequest);
+ Member member = memberService.createUser(credential, authRequest);
+
+ return MemberResponse.of(member);
+ }
+
+ /**
+ * 로그인 메서드
+ *
+ * @param signInRequest
+ * @return TokenResponse
+ */
+ @Override
+ @Transactional
+ public TokenResponse signIn(SignInRequest signInRequest) {
+ Member member = memberService.getUserByEmail(signInRequest.email());
+ credentialService.checkPassword(member, signInRequest.password());
+
+ TokenResponse tokenResponse = tokenProvider.createToken(member);
+ redisClient.setValue(member.getEmail(), tokenResponse.refreshToken(), refreshTokenExpirationTime);
+
+ return tokenResponse;
+ }
+
+ /**
+ * 로그아웃 메서드
+ *
+ * @param refreshToken
+ * @param accessToken
+ */
+ @Override
+ @Transactional
+ public void signOut(String refreshToken, String accessToken) {
+ if(refreshToken==null || accessToken==null) throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND);
+ if(!tokenProvider.validateToken(accessToken)) {
+ throw new AuthException(AuthErrorType.TOKEN_INVALID);
+ }
+ tokenProvider.invalidateTokens(refreshToken, accessToken);
+ }
+
+ /**
+ * 토큰 재발급 메서드
+ *
+ * @param token
+ * @param member
+ * @return tokenResponse
+ */
+ @Override
+ public TokenResponse recreate(String token, Member member) {
+ if (token ==null) {
+ throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND);
+ }
+ if (member == null) {
+ throw new MemberException(MemberErrorType.MEMBER_NOT_FOUND);
+ }
+
+ String refreshToken = token.substring(7);
+ boolean isValid = tokenProvider.validateToken(refreshToken);
+
+ if (!isValid) {
+ throw new AuthException(AuthErrorType.TOKEN_INVALID);
+ }
+
+ String email = tokenProvider.getEmail(refreshToken);
+ String redisRefreshToken = redisClient.getValue(email);
+
+ if (refreshToken.isEmpty() || redisRefreshToken.isEmpty() || !redisRefreshToken.equals(refreshToken)) {
+ throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND);
+ }
+
+ return tokenProvider.recreate(member, refreshToken);
+ }
+}
+
diff --git a/src/main/java/starlight/application/auth/provided/AuthService.java b/src/main/java/starlight/application/auth/provided/AuthService.java
new file mode 100644
index 00000000..1aa8056f
--- /dev/null
+++ b/src/main/java/starlight/application/auth/provided/AuthService.java
@@ -0,0 +1,19 @@
+package starlight.application.auth.provided;
+
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.adapter.auth.webapi.dto.request.AuthRequest;
+import starlight.adapter.auth.webapi.dto.request.SignInRequest;
+import starlight.adapter.auth.webapi.dto.response.MemberResponse;
+import starlight.domain.member.entity.Member;
+
+public interface AuthService {
+
+ MemberResponse signUp(AuthRequest authRequest);
+
+ TokenResponse signIn(SignInRequest signInRequest);
+
+ void signOut(String refreshToken, String accessToken);
+
+ TokenResponse recreate(String token, Member member);
+}
+
diff --git a/src/main/java/starlight/application/auth/required/KeyValueMap.java b/src/main/java/starlight/application/auth/required/KeyValueMap.java
new file mode 100644
index 00000000..4dc1b121
--- /dev/null
+++ b/src/main/java/starlight/application/auth/required/KeyValueMap.java
@@ -0,0 +1,13 @@
+package starlight.application.auth.required;
+
+public interface KeyValueMap {
+
+ void setValue(String key, String value, Long timeout);
+
+ String getValue(String key);
+
+ void deleteValue(String key);
+
+ boolean checkExistsValue(String key);
+}
+
diff --git a/src/main/java/starlight/application/auth/required/TokenProvider.java b/src/main/java/starlight/application/auth/required/TokenProvider.java
new file mode 100644
index 00000000..e84b5daf
--- /dev/null
+++ b/src/main/java/starlight/application/auth/required/TokenProvider.java
@@ -0,0 +1,26 @@
+package starlight.application.auth.required;
+
+import jakarta.servlet.http.HttpServletRequest;
+import starlight.adapter.auth.security.jwt.dto.TokenResponse;
+import starlight.domain.member.entity.Member;
+
+public interface TokenProvider {
+
+ String createAccessToken(Member member);
+
+ TokenResponse createToken(Member member);
+
+ TokenResponse recreate(Member member, String refreshToken);
+
+ boolean validateToken(String token);
+
+ String getEmail(String token);
+
+ Long getExpirationTime(String token);
+
+ String resolveRefreshToken(HttpServletRequest request);
+
+ String resolveAccessToken(HttpServletRequest request);
+
+ void invalidateTokens(String refreshToken, String accessToken);
+}
diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java
new file mode 100644
index 00000000..ba5247fa
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java
@@ -0,0 +1,253 @@
+package starlight.application.businessplan;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Page;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.application.businessplan.provided.dto.BusinessPlanResponse;
+import starlight.application.businessplan.provided.dto.SubSectionResponse;
+import starlight.application.businessplan.provided.BusinessPlanService;
+import starlight.application.businessplan.required.BusinessPlanQuery;
+import starlight.application.businessplan.required.ChecklistGrader;
+import starlight.application.businessplan.util.PlainTextExtractUtils;
+import starlight.application.businessplan.util.SubSectionSupportUtils;
+import starlight.application.member.required.MemberQuery;
+import starlight.domain.businessplan.entity.*;
+import starlight.domain.businessplan.enumerate.PlanStatus;
+import starlight.domain.member.entity.Member;
+import starlight.shared.enumerate.SectionType;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+import starlight.domain.businessplan.exception.BusinessPlanErrorType;
+import starlight.domain.businessplan.exception.BusinessPlanException;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class BusinessPlanServiceImpl implements BusinessPlanService {
+
+ private final BusinessPlanQuery businessPlanQuery;
+ private final MemberQuery memberQuery;
+ private final ChecklistGrader checklistGrader;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public BusinessPlanResponse.Result createBusinessPlan(Long memberId) {
+ Member member = memberQuery.getOrThrow(memberId);
+
+ String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서";
+
+ BusinessPlan plan = BusinessPlan.create(planTitle, memberId);
+
+ return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "Business plan created");
+ }
+
+ @Override
+ public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) {
+ BusinessPlan plan = BusinessPlan.createWithPdf(
+ title,
+ memberId,
+ pdfUrl
+ );
+
+ return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created");
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ return BusinessPlanResponse.Result.from(plan, "Business plan retrieved");
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ List subSectionDetailList = Arrays.stream(SubSectionType.values())
+ .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type))
+ .filter(Objects::nonNull)
+ .map(SubSectionResponse.Detail::from)
+ .toList();
+
+ return BusinessPlanResponse.Detail.from(plan, subSectionDetailList);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) {
+ Page page = businessPlanQuery.findPreviewPage(memberId, pageable);
+ List content = page.getContent().stream()
+ .map(BusinessPlanResponse.Preview::from)
+ .toList();
+
+ return BusinessPlanResponse.PreviewPage.from(content, page);
+ }
+
+ @Override
+ public String updateBusinessPlanTitle(Long planId, String title, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ plan.updateTitle(title);
+
+ businessPlanQuery.save(plan);
+
+ return plan.getTitle();
+ }
+
+ @Override
+ public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted");
+ businessPlanQuery.delete(plan);
+
+ return result;
+ }
+
+ @Override
+ public SubSectionResponse.Result upsertSubSection(
+ Long planId,
+ JsonNode jsonNode,
+ List checks,
+ SubSectionType subSectionType,
+ Long memberId
+ ) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ SectionType sectionType = subSectionType.getSectionType();
+ BaseSection section = getSectionByPlanAndType(plan, sectionType);
+ SubSection subSection = section.getSubSectionByType(subSectionType);
+
+ String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks);
+ String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode);
+
+ String message;
+
+ if (subSection == null) {
+ SubSection newSubSection = SubSection.create(subSectionType, content, rawJsonStr, checks);
+ section.putSubSection(newSubSection);
+ message = "Subsection created";
+ } else {
+ subSection.update(content, rawJsonStr, checks);
+ message = "Subsection updated";
+ }
+
+ if (plan.areWritingCompleted()) {
+ plan.updateStatus(PlanStatus.WRITTEN_COMPLETED);
+ message = "Subsection writing completed";
+ }
+
+ BusinessPlan savedPlan = businessPlanQuery.save(plan);
+ SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType)
+ .getSubSectionByType(subSectionType);
+
+ return SubSectionResponse.Result.from(persistedSubSection, message);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ SectionType sectionType = subSectionType.getSectionType();
+ SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType);
+ if (subSection == null) {
+ throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND);
+ }
+
+ return SubSectionResponse.Detail.from(subSection);
+ }
+
+ @Override
+ public List checkAndUpdateSubSection(
+ Long planId,
+ JsonNode jsonNode,
+ SubSectionType subSectionType,
+ Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ SectionType sectionType = subSectionType.getSectionType();
+ SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType);
+ if (subSection == null) {
+ throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND);
+ }
+
+ String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode);
+
+ List checks = checklistGrader.check(subSectionType, content);
+
+ SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE());
+ String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks);
+
+ subSection.update(content, rawJsonStr, checks);
+
+ businessPlanQuery.save(plan);
+
+ return checks;
+ }
+
+ @Override
+ public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) {
+ BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId);
+
+ SectionType sectionType = subSectionType.getSectionType();
+ BaseSection section = getSectionByPlanAndType(plan, sectionType);
+ SubSection target = section.getSubSectionByType(subSectionType);
+ if (target == null) {
+ throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND);
+ }
+ SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted");
+ section.removeSubSection(subSectionType);
+
+ businessPlanQuery.save(plan);
+
+ return result;
+ }
+
+ private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks) {
+
+ ObjectNode updatedJsonNode = (ObjectNode) objectMapper.valueToTree(jsonNode);
+
+ ArrayNode checkListArray;
+ if (updatedJsonNode.has("checks") && updatedJsonNode.get("checks").isArray()) {
+ checkListArray = (ArrayNode) updatedJsonNode.get("checks");
+ checkListArray.removeAll();
+
+ for (Boolean check : checks) {
+ checkListArray.add(check);
+ }
+ }
+
+ return SubSectionSupportUtils.serializeJsonNodeSafely(objectMapper, updatedJsonNode);
+ }
+
+ private BusinessPlan getOwnedBusinessPlanOrThrow(Long planId, Long memberId) {
+ BusinessPlan businessPlan = businessPlanQuery.getOrThrow(planId);
+ if (!businessPlan.isOwnedBy(memberId)) {
+ throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS);
+ }
+ return businessPlan;
+ }
+
+ private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) {
+ return switch (type) {
+ case OVERVIEW -> plan.getOverview();
+ case PROBLEM_RECOGNITION -> plan.getProblemRecognition();
+ case FEASIBILITY -> plan.getFeasibility();
+ case GROWTH_STRATEGY -> plan.getGrowthTactic();
+ case TEAM_COMPETENCE -> plan.getTeamCompetence();
+ default -> throw new IllegalArgumentException("Unsupported section: " + type);
+ };
+ }
+}
diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java
new file mode 100644
index 00000000..79f5d48f
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java
@@ -0,0 +1,38 @@
+package starlight.application.businessplan.provided;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.data.domain.Pageable;
+import starlight.application.businessplan.provided.dto.BusinessPlanResponse;
+import starlight.application.businessplan.provided.dto.SubSectionResponse;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.List;
+
+public interface BusinessPlanService {
+
+ BusinessPlanResponse.Result createBusinessPlan(Long memberId);
+
+ BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId);
+
+ BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId);
+
+ BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId);
+
+ BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable);
+
+ String updateBusinessPlanTitle(Long planId, String title, Long memberId);
+
+ BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId);
+
+ SubSectionResponse.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks,
+ SubSectionType subSectionType, Long memberId);
+
+ SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId);
+
+ List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType,
+ Long memberId);
+
+ SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId);
+
+
+}
diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java
new file mode 100644
index 00000000..6f837466
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java
@@ -0,0 +1,93 @@
+package starlight.application.businessplan.provided.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import org.springframework.data.domain.Page;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.enumerate.PlanStatus;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record BusinessPlanResponse() {
+
+ public record Result(
+ Long businessPlanId,
+ String title,
+ PlanStatus planStatus,
+ String message
+ ) {
+ public static Result from(BusinessPlan businessPlan, String message) {
+ return new Result(
+ businessPlan.getId(),
+ businessPlan.getTitle(),
+ businessPlan.getPlanStatus(),
+ message
+ );
+ }
+ }
+
+ public record Detail(
+ Long businessPlanId,
+ String title,
+ PlanStatus planStatus,
+ List subSectionDetailList
+ ) {
+ public static Detail from(
+ BusinessPlan businessPlan,
+ List subSectionDetailList
+ ) {
+ return new Detail(
+ businessPlan.getId(),
+ businessPlan.getTitle(),
+ businessPlan.getPlanStatus(),
+ subSectionDetailList
+ );
+ }
+ }
+
+ public record Preview(
+ Long businessPlanId,
+ String title,
+ String pdfUrl,
+ @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime lastSavedAt,
+ PlanStatus planStatus
+ ) {
+ public static Preview from(BusinessPlan businessPlan) {
+ LocalDateTime lastSavedAt = businessPlan.getModifiedAt() != null
+ ? businessPlan.getModifiedAt()
+ : businessPlan.getCreatedAt();
+
+ return new Preview(
+ businessPlan.getId(),
+ businessPlan.getTitle(),
+ businessPlan.isPdfBased() ? businessPlan.getPdfUrl() : null,
+ lastSavedAt,
+ businessPlan.getPlanStatus()
+ );
+ }
+ }
+
+ public record PreviewPage(
+ List content,
+ int page,
+ int size,
+ int totalPages,
+ long totalElements,
+ int numberOfElements,
+ boolean first,
+ boolean last
+ ) {
+ public static PreviewPage from(List content, Page> page) {
+ return new BusinessPlanResponse.PreviewPage(
+ content,
+ page.getNumber() + 1,
+ page.getSize(),
+ page.getTotalPages(),
+ page.getTotalElements(),
+ page.getNumberOfElements(),
+ page.isFirst(),
+ page.isLast()
+ );
+ }
+ }
+}
diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java
new file mode 100644
index 00000000..019721d6
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java
@@ -0,0 +1,39 @@
+package starlight.application.businessplan.provided.dto;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import starlight.domain.businessplan.entity.SubSection;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+public record SubSectionResponse() {
+
+ public record Result(
+ SubSectionType subSectionType,
+ Long subSectionId,
+ String message
+ ) {
+ public static Result from(
+ SubSection subSection,
+ String message
+ ) {
+ return new Result(
+ subSection.getSubSectionType(),
+ subSection.getId(),
+ message
+ );
+ }
+ }
+
+ public record Detail(
+ SubSectionType subSectionType,
+ Long subSectionId,
+ JsonNode content
+ ) {
+ public static Detail from(SubSection subSection) {
+ return new Detail(
+ subSection.getSubSectionType(),
+ subSection.getId(),
+ subSection.getRawJson().asTree()
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java
new file mode 100644
index 00000000..1fe30fa9
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java
@@ -0,0 +1,16 @@
+package starlight.application.businessplan.required;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Page;
+import starlight.domain.businessplan.entity.BusinessPlan;
+
+public interface BusinessPlanQuery {
+
+ BusinessPlan getOrThrow(Long id);
+
+ BusinessPlan save(BusinessPlan businessPlan);
+
+ void delete(BusinessPlan businessPlan);
+
+ Page findPreviewPage(Long memberId, Pageable pageable);
+}
diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java
new file mode 100644
index 00000000..33049e2a
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java
@@ -0,0 +1,20 @@
+package starlight.application.businessplan.required;
+
+import starlight.domain.businessplan.enumerate.SubSectionType;
+
+import java.util.List;
+
+public interface ChecklistGrader {
+
+ /**
+ * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다.
+ *
+ * @param subSectionType 서브섹션 타입
+ * @param content 서브섹션 내용
+ * @return 체크리스트 결과
+ */
+ List check(
+ SubSectionType subSectionType,
+ String content
+ );
+}
diff --git a/src/main/java/starlight/application/businessplan/required/SpellChecker.java b/src/main/java/starlight/application/businessplan/required/SpellChecker.java
new file mode 100644
index 00000000..347abd57
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/required/SpellChecker.java
@@ -0,0 +1,12 @@
+package starlight.application.businessplan.required;
+
+import starlight.adapter.businessplan.spellcheck.dto.Finding;
+
+import java.util.List;
+
+public interface SpellChecker {
+
+ List check(String sentence);
+
+ String applyTopSuggestions(String original, List findings);
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java
new file mode 100644
index 00000000..5f3e2eed
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java
@@ -0,0 +1,92 @@
+package starlight.application.businessplan.util;
+
+import org.springframework.stereotype.Component;
+import starlight.domain.businessplan.entity.BaseSection;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.entity.SubSection;
+import starlight.domain.businessplan.enumerate.SubSectionType;
+import starlight.shared.enumerate.SectionType;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * BusinessPlan에서 LLM 채점을 위한 텍스트 컨텐츠를 추출하는 컴포넌트
+ */
+@Component
+public class BusinessPlanContentExtractor {
+
+ /**
+ * BusinessPlan에서 모든 섹션의 컨텐츠를 추출하여 하나의 문자열로 반환
+ */
+ public String extractContent(BusinessPlan businessPlan) {
+ StringBuilder promptBuilder = new StringBuilder();
+ promptBuilder.append("다음 사업계획서 내용을 채점해주세요:\n\n");
+
+ List sections = new ArrayList<>();
+
+ // 문제 인식 섹션
+ String problemRecognition = extractSectionContent(
+ businessPlan.getProblemRecognition(),
+ SectionType.PROBLEM_RECOGNITION,
+ "문제 인식");
+ if (!problemRecognition.isBlank()) {
+ sections.add(problemRecognition);
+ }
+
+ // 실현 가능성 섹션
+ String feasibility = extractSectionContent(
+ businessPlan.getFeasibility(),
+ SectionType.FEASIBILITY,
+ "실현 가능성");
+ if (!feasibility.isBlank()) {
+ sections.add(feasibility);
+ }
+
+ // 성장 전략 섹션
+ String growthStrategy = extractSectionContent(
+ businessPlan.getGrowthTactic(),
+ SectionType.GROWTH_STRATEGY,
+ "성장 전략");
+ if (!growthStrategy.isBlank()) {
+ sections.add(growthStrategy);
+ }
+
+ // 팀 역량 섹션
+ String teamCompetence = extractSectionContent(
+ businessPlan.getTeamCompetence(),
+ SectionType.TEAM_COMPETENCE,
+ "팀 역량");
+ if (!teamCompetence.isBlank()) {
+ sections.add(teamCompetence);
+ }
+
+ promptBuilder.append(String.join("\n\n", sections));
+ return promptBuilder.toString();
+ }
+
+ /**
+ * 특정 섹션의 컨텐츠를 추출
+ */
+ private String extractSectionContent(BaseSection section, SectionType sectionType, String sectionTitle) {
+ if (section == null) {
+ return "";
+ }
+
+ StringBuilder sectionBuilder = new StringBuilder();
+ sectionBuilder.append("## ").append(sectionTitle).append("\n");
+
+ for (SubSectionType subSectionType : SubSectionType.values()) {
+ if (subSectionType.getSectionType() == sectionType) {
+ SubSection subSection = section.getSubSectionByType(subSectionType);
+ if (subSection != null && subSection.getContent() != null && !subSection.getContent().isBlank()) {
+ sectionBuilder.append("### ").append(subSectionType.getDescription()).append("\n");
+ sectionBuilder.append(subSection.getContent()).append("\n");
+ }
+ }
+ }
+
+ return sectionBuilder.toString();
+ }
+}
+
diff --git a/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java
new file mode 100644
index 00000000..80011df2
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java
@@ -0,0 +1,299 @@
+package starlight.application.businessplan.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Content(JSON) → 줄글 변환 (고정 포맷)
+ * - text : value
+ * - image : "[사진] {caption}" (캡션 없으면 "[사진]")
+ * - table : "col1: v1, col2: v2, ..." (행마다 한 줄)
+ *
+ * 지원 입력:
+ * (1) {"content":[ ... ]}
+ * (2) {"blocks":[ {"content":[ ... ]}, ... ]}
+ */
+public final class PlainTextExtractUtils {
+
+ private PlainTextExtractUtils() {
+ }
+
+ // 고정 포맷 상수
+ private static final String IMAGE_TOKEN = "[사진]";
+ private static final String TABLE_PAIR_SEPARATOR = ": ";
+ private static final String TABLE_FIELD_SEPARATOR = ", ";
+
+ /** raw JSON 문자열 → 줄글 */
+ public static String extractPlainText(ObjectMapper objectMapper, String jsonString) {
+ try {
+ JsonNode rootNode = objectMapper.readTree(jsonString);
+ return extractPlainText(rootNode);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid JSON", e);
+ }
+ }
+
+ /** DTO → 줄글 */
+ public static String extractPlainText(ObjectMapper objectMapper, Object dtoObject) {
+ try {
+ JsonNode rootNode = objectMapper.valueToTree(dtoObject);
+ return extractPlainText(rootNode);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Cannot serialize DTO", e);
+ }
+ }
+
+ /** 핵심 처리 */
+ static String extractPlainText(JsonNode rootNode) {
+ List outputLines = new ArrayList<>();
+ List contentItems = findContentItems(rootNode);
+
+ for (JsonNode contentItem : contentItems) {
+ String type = contentItem.path("type").asText("");
+
+ switch (type) {
+ case "text":
+ Optional textLine = extractText(contentItem);
+ textLine.ifPresent(outputLines::add);
+ break;
+
+ case "image":
+ Optional imageLine = extractImage(contentItem);
+ imageLine.ifPresent(outputLines::add);
+ break;
+
+ case "table":
+ List tableLines = extractTable_new(contentItem);
+ outputLines.addAll(tableLines);
+ break;
+
+ default:
+ break;
+ }
+ }
+ return joinNonBlank(outputLines, "\n").trim();
+ }
+
+ /** content 아이템 전부 추출(순서 보존) */
+ static List findContentItems(JsonNode rootNode) {
+ List contentItems = new ArrayList<>();
+
+ // case (1): content 바로 있는 경우
+ if (rootNode.has("content") && rootNode.path("content").isArray()) {
+ addAll(contentItems, (ArrayNode) rootNode.path("content"));
+ return contentItems;
+ }
+
+ // case (2): blocks 내부 content들
+ if (rootNode.has("blocks") && rootNode.path("blocks").isArray()) {
+ for (JsonNode blockNode : rootNode.path("blocks")) {
+ if (blockNode.has("content") && blockNode.path("content").isArray()) {
+ addAll(contentItems, (ArrayNode) blockNode.path("content"));
+ }
+ }
+ }
+ return contentItems;
+ }
+
+ /** 텍스트 항목 */
+ static Optional extractText(JsonNode contentItem) {
+ String value = contentItem.path("value").asText("");
+ return value.isBlank() ? Optional.empty() : Optional.of(value);
+ }
+
+ /** 이미지 항목: "[사진]" + (있으면 공백+캡션) */
+ static Optional extractImage(JsonNode contentItem) {
+ String caption = contentItem.hasNonNull("caption") ? contentItem.path("caption").asText().trim() : "";
+ if (caption.isBlank()) {
+ return Optional.of(IMAGE_TOKEN);
+ }
+ return Optional.of(IMAGE_TOKEN + " " + caption);
+ }
+
+ static List extractTable_new(JsonNode contentItem) {
+ List tableLines = new ArrayList<>();
+ JsonNode columnArrayNode = contentItem.path("columns");
+ JsonNode rowArrayNode = contentItem.path("rows");
+ if (!columnArrayNode.isArray() || !rowArrayNode.isArray()) {
+ return tableLines;
+ }
+
+ int columnCount = columnArrayNode.size();
+ int rowCount = rowArrayNode.size();
+
+ if (rowCount == 0) {
+ return tableLines;
+ }
+
+ // 컬럼 개수 표시
+ tableLines.add("[" + columnCount + " columns]");
+
+ // rowspan과 colspan을 고려하여 실제 테이블 그리드 구성
+ // grid[row][col] = 해당 위치의 셀 텍스트 (null이면 rowspan으로 차지된 위치)
+ String[][] grid = new String[rowCount][columnCount];
+ boolean[][] isOccupied = new boolean[rowCount][columnCount];
+
+ // 각 행을 순회하며 셀 배치
+ for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ JsonNode rowNode = rowArrayNode.get(rowIndex);
+ if (!rowNode.isArray()) {
+ continue;
+ }
+
+ int colIndex = 0;
+ for (JsonNode cellNode : rowNode) {
+ // rowspan으로 차지된 위치는 건너뛰기
+ while (colIndex < columnCount && isOccupied[rowIndex][colIndex]) {
+ colIndex++;
+ }
+
+ if (colIndex >= columnCount) {
+ break;
+ }
+
+ // 셀 내용 추출
+ String cellText = extractCellContent(cellNode);
+
+ // rowspan과 colspan 값 가져오기 (기본값 1)
+ int rowspan = resolveSpanValue(cellNode, "rowspan", "rowSpan");
+ int colspan = resolveSpanValue(cellNode, "colspan", "colSpan");
+
+ // colspan 범위 확인
+ if (colIndex + colspan > columnCount) {
+ colspan = columnCount - colIndex;
+ }
+
+ // 셀을 그리드에 배치
+ for (int r = 0; r < rowspan && rowIndex + r < rowCount; r++) {
+ for (int c = 0; c < colspan && colIndex + c < columnCount; c++) {
+ if (r == 0 && c == 0) {
+ // 첫 번째 위치에만 텍스트 저장
+ grid[rowIndex + r][colIndex + c] = cellText;
+ }
+ // 모든 위치를 차지된 것으로 표시
+ isOccupied[rowIndex + r][colIndex + c] = true;
+ }
+ }
+
+ colIndex += colspan;
+ }
+ }
+
+ // 그리드를 순회하며 각 행의 텍스트 생성
+ for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ List rowValues = new ArrayList<>();
+ for (int colIndex = 0; colIndex < columnCount; colIndex++) {
+ String cellText = grid[rowIndex][colIndex];
+ if (cellText == null) {
+ // rowspan으로 차지된 위치는 빈 문자열
+ cellText = "";
+ }
+ rowValues.add("\"" + cellText + "\"");
+ }
+ tableLines.add("[" + String.join(", ", rowValues) + "]");
+ }
+
+ return tableLines;
+ }
+
+ /** 셀 내부의 content (BasicContent 리스트)를 텍스트로 추출 */
+ static String extractCellContent(JsonNode cellNode) {
+ if (!cellNode.has("content") || !cellNode.path("content").isArray()) {
+ return "";
+ }
+
+ List cellParts = new ArrayList<>();
+ for (JsonNode contentItem : cellNode.path("content")) {
+ String type = contentItem.path("type").asText("");
+ switch (type) {
+ case "text":
+ String textValue = contentItem.path("value").asText("");
+ if (!textValue.isBlank()) {
+ cellParts.add(textValue);
+ }
+ break;
+ case "image":
+ Optional imageText = extractImage(contentItem);
+ imageText.ifPresent(cellParts::add);
+ break;
+ default:
+ break;
+ }
+ }
+
+ return String.join(" ", cellParts);
+ }
+
+ /** 테이블 항목: 각 행을 "col1: v1, col2: v2 ..." */
+ static List extractTable(JsonNode contentItem) {
+ List tableLines = new ArrayList<>();
+ JsonNode columnArrayNode = contentItem.path("columns");
+ JsonNode rowArrayNode = contentItem.path("rows");
+ if (!columnArrayNode.isArray() || !rowArrayNode.isArray()) {
+ return tableLines;
+ }
+
+ for (JsonNode rowNode : rowArrayNode) {
+ String line = mapRow(columnArrayNode, rowNode);
+ if (!line.isBlank()) {
+ tableLines.add(line);
+ }
+ }
+
+ return tableLines;
+ }
+
+ /** 한 행을 "col: val, col: val" 형태로 매핑 */
+ static String mapRow(JsonNode columnArrayNode, JsonNode rowArrayNode) {
+ StringBuilder rowBuilder = new StringBuilder();
+ int columnIndex = 0;
+ Iterator columnIterator = columnArrayNode.elements();
+
+ while (columnIterator.hasNext()) {
+ String columnName = columnIterator.next().asText("");
+ String cellValue = rowArrayNode.has(columnIndex) ? rowArrayNode.get(columnIndex).asText("") : "";
+ if (!columnName.isBlank()) {
+ if (rowBuilder.length() > 0) {
+ rowBuilder.append(TABLE_FIELD_SEPARATOR);
+ }
+ rowBuilder.append(columnName).append(TABLE_PAIR_SEPARATOR).append(cellValue);
+ }
+ columnIndex++;
+ }
+ return rowBuilder.toString();
+ }
+
+ /** ArrayNode → List에 추가 */
+ static void addAll(List targetList, ArrayNode sourceArrayNode) {
+ for (JsonNode node : sourceArrayNode) {
+ targetList.add(node);
+ }
+ }
+
+ /** 공백/빈 문자열 제외 후 결합 */
+ static String joinNonBlank(List partsList, String separator) {
+ String result = "";
+ for (String part : partsList) {
+ if (part != null && !part.isBlank()) {
+ result = result.isEmpty() ? part : result + separator + part;
+ }
+ }
+ return result;
+ }
+
+ private static int resolveSpanValue(JsonNode cellNode, String lowerKey, String camelKey) {
+ if (cellNode.has(lowerKey)) {
+ return Math.max(1, cellNode.path(lowerKey).asInt(1));
+ }
+ if (cellNode.has(camelKey)) {
+ return Math.max(1, cellNode.path(camelKey).asInt(1));
+ }
+ return 1;
+ }
+}
diff --git a/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java b/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java
new file mode 100644
index 00000000..c171025c
--- /dev/null
+++ b/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java
@@ -0,0 +1,46 @@
+package starlight.application.businessplan.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import starlight.domain.businessplan.exception.BusinessPlanErrorType;
+import starlight.domain.businessplan.exception.BusinessPlanException;
+
+import java.util.List;
+
+public final class SubSectionSupportUtils {
+
+ private SubSectionSupportUtils() {}
+
+ /**
+ * JsonNode를 문자열로 변환하며 안정성 검사
+ *
+ * @param objectMapper JSON 처리를 위한 ObjectMapper
+ * @param node 변환할 JsonNode
+ * @return JSON 문자열
+ * @throws BusinessPlanException 변환에 실패하거나 node가 null인 경우
+ */
+ public static String serializeJsonNodeSafely(ObjectMapper objectMapper, JsonNode node) {
+ if (node == null) {
+ throw new BusinessPlanException(BusinessPlanErrorType.REQUEST_EMPTY_RAW_JSON);
+ }
+ try {
+ return objectMapper.writeValueAsString(node);
+ } catch (JsonProcessingException e) {
+ throw new BusinessPlanException(BusinessPlanErrorType.RAW_JSON_SERIALIZATION_FAILURE);
+ }
+ }
+
+ /**
+ + * 리스트가 지정된 크기를 가지는지 검증합니다.
+ + *
+ + * @param checks 검증할 Boolean 리스트
+ + * @param expectedSize 기대되는 리스트 크기
+ + * @throws BusinessPlanException 리스트가 null이거나 크기가 일치하지 않는 경우
+ + */
+ public static void requireSize(List checks, int expectedSize) {
+ if (checks == null || checks.size() != expectedSize) {
+ throw new BusinessPlanException(BusinessPlanErrorType.CHECKS_LIST_SIZE_INVALID);
+ }
+ }
+}
diff --git a/src/main/java/starlight/application/expert/ExpertQueryService.java b/src/main/java/starlight/application/expert/ExpertQueryService.java
new file mode 100644
index 00000000..c21c258e
--- /dev/null
+++ b/src/main/java/starlight/application/expert/ExpertQueryService.java
@@ -0,0 +1,47 @@
+package starlight.application.expert;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.application.expert.provided.ExpertFinder;
+import starlight.application.expert.required.ExpertQuery;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ExpertQueryService implements ExpertFinder {
+
+ private final ExpertQuery expertQuery;
+
+ @Override
+ public Expert findById(Long id) {
+ return expertQuery.findById(id);
+ }
+
+ @Override
+ public Expert findByIdWithDetails(Long id) {
+ return expertQuery.findByIdWithDetails(id);
+ }
+
+ @Override
+ public List loadAll() {
+ return expertQuery.findAllWithDetails();
+ }
+
+ @Override
+ public List findByAllCategories(Collection categories) {
+ return expertQuery.findByAllCategories(categories);
+ }
+
+ @Override
+ public Map findByIds(Set expertIds) {
+ return expertQuery.findExpertMapByIds(expertIds);
+ }
+}
diff --git a/src/main/java/starlight/application/expert/provided/ExpertFinder.java b/src/main/java/starlight/application/expert/provided/ExpertFinder.java
new file mode 100644
index 00000000..5fa1a25a
--- /dev/null
+++ b/src/main/java/starlight/application/expert/provided/ExpertFinder.java
@@ -0,0 +1,21 @@
+package starlight.application.expert.provided;
+
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public interface ExpertFinder {
+ Expert findById(Long id);
+
+ Expert findByIdWithDetails(Long id);
+
+ List loadAll();
+
+ List findByAllCategories(Collection categories);
+
+ Map findByIds(Set expertIds);
+}
diff --git a/src/main/java/starlight/application/expert/required/ExpertQuery.java b/src/main/java/starlight/application/expert/required/ExpertQuery.java
new file mode 100644
index 00000000..925dcd7a
--- /dev/null
+++ b/src/main/java/starlight/application/expert/required/ExpertQuery.java
@@ -0,0 +1,22 @@
+package starlight.application.expert.required;
+
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expert.enumerate.TagCategory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public interface ExpertQuery {
+
+ Expert findById(Long id);
+
+ Expert findByIdWithDetails(Long id);
+
+ Map findExpertMapByIds(Set expertIds);
+
+ List findAllWithDetails();
+
+ List findByAllCategories(Collection categories);
+}
diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java
new file mode 100644
index 00000000..2f6e3e6e
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java
@@ -0,0 +1,127 @@
+package starlight.application.expertApplication;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+import starlight.application.businessplan.required.BusinessPlanQuery;
+import starlight.application.expert.required.ExpertQuery;
+import starlight.application.expertApplication.event.FeedbackRequestDto;
+import starlight.application.expertApplication.provided.ExpertApplicationService;
+import starlight.application.expertApplication.required.ExpertApplicationQuery;
+import starlight.application.expertReport.provided.ExpertReportService;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.enumerate.PlanStatus;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expertApplication.entity.ExpertApplication;
+import starlight.domain.expertApplication.exception.ExpertApplicationErrorType;
+import starlight.domain.expertApplication.exception.ExpertApplicationException;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ExpertApplicationServiceImpl implements ExpertApplicationService {
+
+ private final ExpertQuery expertQuery;
+ private final BusinessPlanQuery planQuery;
+ private final ExpertApplicationQuery applicationQuery;
+ private final ApplicationEventPublisher eventPublisher;
+ private final ExpertReportService expertReportService;
+
+ private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
+ private static final String ALLOWED_CONTENT_TYPE = "application/pdf";
+
+ @Value("${feedback-token.expiration-date}")
+ private Long FEEDBACK_DEADLINE_DAYS = 7L;
+
+ @Override
+ @Transactional
+ public void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) {
+ try {
+ validateFile(file);
+
+ BusinessPlan plan = planQuery.getOrThrow(planId);
+ Expert expert = expertQuery.findById(expertId);
+
+ plan.updateStatus(PlanStatus.EXPERT_MATCHED);
+
+ registerApplicationRecord(expertId, planId);
+
+ publishEmailEvent(expert, plan, file, menteeName);
+ } catch (Exception e) {
+ log.error("Failed to request Feedback. planId={}, expertId={}", planId, expertId, e);
+ throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_FEEDBACK_REQUEST_FAILED);
+ }
+ }
+
+ private void validateFile(MultipartFile file) {
+ if (file.isEmpty()) {
+ throw new ExpertApplicationException(ExpertApplicationErrorType.EMPTY_FILE);
+ }
+
+ if (file.getSize() > MAX_FILE_SIZE) {
+ throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_SIZE_EXCEEDED);
+ }
+
+ String contentType = file.getContentType();
+ if (contentType == null || !contentType.equals(ALLOWED_CONTENT_TYPE)) {
+ throw new ExpertApplicationException(ExpertApplicationErrorType.UNSUPPORTED_FILE_TYPE);
+ }
+ }
+
+ public void registerApplicationRecord(Long expertId, Long planId) {
+ if (applicationQuery.existsByExpertIdAndBusinessPlanId(expertId, planId)) {
+ throw new ExpertApplicationException(ExpertApplicationErrorType.APPLICATION_ALREADY_EXISTS);
+ }
+
+ ExpertApplication application = ExpertApplication.create(planId, expertId);
+ applicationQuery.save(application);
+ }
+
+ private String generateFilename(MultipartFile file, BusinessPlan plan, String menteeName) {
+ String originalFilename = file.getOriginalFilename();
+
+ if (originalFilename != null && !originalFilename.isBlank()) {
+ return originalFilename;
+ }
+
+ return String.format("[사업계획서]%s_%s.pdf", plan.getTitle(), menteeName);
+ }
+
+ protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile file, String menteeName) {
+ try {
+ byte[] fileBytes = file.getBytes();
+ String filename = generateFilename(file, plan, menteeName);
+ String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId());
+
+ FeedbackRequestDto event = FeedbackRequestDto.of(
+ expert.getEmail(),
+ expert.getName(),
+ menteeName,
+ plan.getTitle(),
+ LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE),
+ feedbackUrl,
+ fileBytes,
+ filename
+ );
+
+ log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId());
+
+ eventPublisher.publishEvent(event);
+ } catch (IOException e) {
+ log.error("Failed to read file. planId={}, expertId={}", plan.getId(), expert.getId(), e);
+ throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR);
+ }
+ }
+
+ private String buildFeedbackRequestUrl(Long expertId, Long planId) {
+ return expertReportService.createExpertReportLink(expertId, planId);
+ }
+}
diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java
new file mode 100644
index 00000000..2226a90c
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java
@@ -0,0 +1,29 @@
+package starlight.application.expertApplication.event;
+
+public record FeedbackRequestDto(
+ String mentorEmail,
+
+ String mentorName,
+
+ String menteeName,
+
+ String businessPlanTitle,
+
+ String feedbackDeadline,
+
+ String feedbackUrl,
+
+ byte[] attachedFile,
+
+ String filename
+) {
+ public static FeedbackRequestDto of(
+ String mentorEmail, String mentorName, String menteeName, String businessPlanTitle,
+ String feedbackDeadline, String feedbackUrl, byte[] attachedFile, String filename
+ ) {
+ return new FeedbackRequestDto(
+ mentorEmail, mentorName, menteeName, businessPlanTitle,
+ feedbackDeadline, feedbackUrl, attachedFile, filename
+ );
+ }
+}
diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java
new file mode 100644
index 00000000..fff777a7
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java
@@ -0,0 +1,53 @@
+package starlight.application.expertApplication.event;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.retry.annotation.Backoff;
+import org.springframework.retry.annotation.Recover;
+import org.springframework.retry.annotation.Retryable;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+import starlight.application.expertApplication.required.EmailSender;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FeedbackRequestEventListener {
+
+ private final EmailSender emailSender;
+
+ @Async("emailTaskExecutor")
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ @Retryable(
+ maxAttempts = 3,
+ backoff = @Backoff(delay = 2000, multiplier = 2),
+ retryFor = {Exception.class}
+ )
+ public void handleFeedbackRequestEvent(FeedbackRequestDto event) {
+ log.info("[EMAIL] listener triggered menteeName={}, businessPlanTitle={}", event.menteeName(), event.businessPlanTitle());
+ try {
+ emailSender.sendFeedbackRequestMail(event);
+
+ log.info("[EMAIL] sending via JavaMailSender to={} subject={}", event.mentorEmail(), event.menteeName());
+
+ } catch (Exception e) {
+ log.error("[EMAIL] Failed to send feedback request email after retries. menteeName={}, businessPlanTitle={}",
+ event.menteeName(), event.businessPlanTitle(), e);
+
+ throw e;
+ }
+ }
+
+ @Recover
+ public void recoverEmailSend(Exception e, FeedbackRequestDto event) {
+ log.error("[EMAIL FINAL FAILURE] ... menteeName={}, businessPlanTitle={}",
+ event.menteeName(), event.businessPlanTitle(), e);
+
+ // TODO: 실패 처리 전략
+ // 1. Dead Letter Queue에 저장
+ // 2. 관리자에게 알림 전송
+ // 3. DB에 실패 기록 저장
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java
new file mode 100644
index 00000000..17e63974
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java
@@ -0,0 +1,10 @@
+package starlight.application.expertApplication.provided;
+
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+public interface ExpertApplicationService {
+
+ void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) throws IOException;
+}
diff --git a/src/main/java/starlight/application/expertApplication/required/EmailSender.java b/src/main/java/starlight/application/expertApplication/required/EmailSender.java
new file mode 100644
index 00000000..87a97d5e
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/required/EmailSender.java
@@ -0,0 +1,8 @@
+package starlight.application.expertApplication.required;
+
+import starlight.application.expertApplication.event.FeedbackRequestDto;
+
+public interface EmailSender {
+
+ void sendFeedbackRequestMail(FeedbackRequestDto dto);
+}
diff --git a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java
new file mode 100644
index 00000000..33ad33ed
--- /dev/null
+++ b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java
@@ -0,0 +1,13 @@
+package starlight.application.expertApplication.required;
+
+import starlight.domain.expertApplication.entity.ExpertApplication;
+
+import java.util.List;
+
+public interface ExpertApplicationQuery {
+ Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId);
+
+ List findRequestedExpertIds(Long businessPlanId);
+
+ ExpertApplication save(ExpertApplication application);
+}
diff --git a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java
new file mode 100644
index 00000000..44295a5d
--- /dev/null
+++ b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java
@@ -0,0 +1,128 @@
+package starlight.application.expertReport;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import starlight.application.businessplan.required.BusinessPlanQuery;
+import starlight.application.expert.provided.ExpertFinder;
+import starlight.application.expertReport.provided.ExpertReportService;
+import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto;
+import starlight.application.expertReport.required.ExpertReportQuery;
+import starlight.domain.businessplan.entity.BusinessPlan;
+import starlight.domain.businessplan.enumerate.PlanStatus;
+import starlight.domain.expert.entity.Expert;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.expertReport.entity.ExpertReportDetail;
+import starlight.domain.expertReport.enumerate.SaveType;
+
+import java.security.SecureRandom;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class ExpertReportServiceImpl implements ExpertReportService {
+
+ @Value("${feedback-token.token-length}")
+ private int tokenLength;
+
+ @Value("${feedback-token.charset}")
+ private String base62Chars;
+
+ @Value("${feedback-token.base-url}")
+ private String feedbackBaseUrl;
+
+ private final ExpertReportQuery expertReportQuery;
+ private final ExpertFinder expertFinder;
+ private final BusinessPlanQuery businessPlanQuery;
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ @Override
+ public String createExpertReportLink(
+ Long expertId,
+ Long businessPlanId
+ ) {
+ String token = generateToken();
+
+ ExpertReport report = ExpertReport.create(expertId, businessPlanId, token);
+ expertReportQuery.save(report);
+
+ return feedbackBaseUrl + token;
+ }
+
+ @Override
+ public ExpertReport saveReport(
+ String token,
+ String overallComment,
+ List details,
+ SaveType saveType
+ ) {
+ ExpertReport report = expertReportQuery.findByTokenWithDetails(token);
+
+ report.updateOverallComment(overallComment);
+ report.updateDetails(details);
+
+ switch (saveType) {
+ case TEMPORARY -> {
+ report.temporarySave();
+ }
+ case FINAL -> {
+ report.submit();
+ BusinessPlan plan = businessPlanQuery.getOrThrow(report.getBusinessPlanId());
+ plan.updateStatus(PlanStatus.FINALIZED);
+ }
+
+ }
+
+ return expertReportQuery.save(report);
+ }
+
+ @Override
+ public ExpertReportWithExpertDto getExpertReportWithExpert(String token) {
+ ExpertReport report = expertReportQuery.findByTokenWithDetails(token);
+ report.incrementViewCount();
+
+ Expert expert = expertFinder.findByIdWithDetails(report.getExpertId());
+
+ return ExpertReportWithExpertDto.of(report, expert);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId) {
+ List reports = expertReportQuery.findAllByBusinessPlanId(businessPlanId);
+
+ Set expertIds = reports.stream()
+ .map(ExpertReport::getExpertId)
+ .collect(Collectors.toSet());
+
+ Map expertsMap = expertFinder.findByIds(expertIds);
+
+ return reports.stream()
+ .map(report -> {
+ Expert expert = expertsMap.get(report.getExpertId());
+ return ExpertReportWithExpertDto.of(report, expert);
+ })
+ .toList();
+ }
+
+ private String generateToken() {
+ StringBuilder token = new StringBuilder(tokenLength);
+
+ do {
+ token.setLength(0);
+ for (int i = 0; i < tokenLength; i++) {
+ token.append(base62Chars.charAt(
+ secureRandom.nextInt(base62Chars.length())
+ ));
+ }
+ } while (expertReportQuery.existsByToken(token.toString()));
+
+ return token.toString();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java
new file mode 100644
index 00000000..1a6242e4
--- /dev/null
+++ b/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java
@@ -0,0 +1,19 @@
+package starlight.application.expertReport.provided;
+
+import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto;
+import starlight.domain.expertReport.entity.ExpertReport;
+import starlight.domain.expertReport.entity.ExpertReportDetail;
+import starlight.domain.expertReport.enumerate.SaveType;
+
+import java.util.List;
+
+public interface ExpertReportService{
+
+ String createExpertReportLink(Long expertId, Long businessPlanId);
+
+ ExpertReport saveReport(String token, String overallComment, List