diff --git a/.github/resources/init-ssl.sh b/.github/resources/init-ssl.sh deleted file mode 100644 index 742e0f58..00000000 --- a/.github/resources/init-ssl.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# Let's Encrypt SSL 인증서 초기 발급 스크립트 -# 서버에서 최초 1회만 실행 - -set -e - -DOMAIN="api.q-asker.com" -EMAIL="${1:?이메일 주소를 입력하세요 (예: ./init-ssl.sh admin@example.com)}" - -echo "=== Let's Encrypt SSL 인증서 발급 ===" -echo "도메인: $DOMAIN" -echo "이메일: $EMAIL" - -# 1. certbot 설치 (없는 경우) -if ! command -v certbot &> /dev/null; then - echo ">>> certbot 설치 중..." - sudo apt-get update - sudo apt-get install -y certbot -fi - -# 2. ACME challenge 디렉토리 생성 -sudo mkdir -p /var/www/certbot - -# 3. 80 포트 사용 중인 컨테이너 일시 중지 -echo ">>> 80 포트 해제를 위해 nginx 컨테이너 중지..." -docker compose stop nginx 2>/dev/null || true - -# 4. standalone 모드로 인증서 발급 -echo ">>> 인증서 발급 중..." -sudo certbot certonly \ - --standalone \ - --preferred-challenges http \ - -d "$DOMAIN" \ - --email "$EMAIL" \ - --agree-tos \ - --non-interactive - -# 5. 인증서 자동 갱신 cron 등록 -echo ">>> 인증서 자동 갱신 cron 등록..." -CRON_CMD="0 3 * * * certbot renew --webroot -w /var/www/certbot --quiet && docker exec nginx nginx -s reload" -(crontab -l 2>/dev/null | grep -v "certbot renew"; echo "$CRON_CMD") | crontab - - -echo "=== 인증서 발급 완료 ===" -echo "인증서 경로: /etc/letsencrypt/live/$DOMAIN/" -echo "자동 갱신: 매일 03:00 (cron)" -echo "" -echo "이제 docker compose up -d nginx 로 Nginx를 시작하세요." diff --git a/.github/workflows/cd-prod_deploy.yml b/.github/workflows/cd-prod_deploy.yml index 86271474..130e37ab 100644 --- a/.github/workflows/cd-prod_deploy.yml +++ b/.github/workflows/cd-prod_deploy.yml @@ -97,7 +97,7 @@ jobs: host: ${{ env.EC2_HOST }} username: ${{ env.EC2_USER }} key: ${{ env.EC2_KEY }} - source: ".github/resources/*" + source: "infra/blue-green/*" target: "/home/${{ env.EC2_USER }}/deploy" strip_components: 2 diff --git a/.gitignore b/.gitignore index d550fb7d..ebef0c34 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,11 @@ out/ .vscode/ .DS_Store +### Terraform ### +*.tfstate +*.tfstate.backup +.terraform/ + ### Custom ### **/src/main/resources/application-** !**/src/main/resources/application-test.yml @@ -48,9 +53,9 @@ app/newrelic/newrelic.yml monitor_downtime.sh # Claude Code -CLAUDE.md shrimp-rules.md .mcp.json .claude shrimp_data docs +.gstack/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..63428b62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# Q-Asker API + +## 프로젝트 개요 + +문서(PDF, PPT, DOCX)를 업로드하면 Google Gemini AI가 자동으로 퀴즈를 생성하는 Spring Boot 기반 백엔드 API 서버. SSE를 통한 실시간 생성 스트리밍, 퀴즈 세트 관리, 풀이 히스토리 기록을 지원한다. + +## 기술 스택 + +| 분류 | 기술 | 버전 | +|---|---|---| +| 언어 | Java | 21 | +| 프레임워크 | Spring Boot | 3.5.8 | +| AI | Spring AI (Google Gemini via Vertex AI) | 1.1.2 | +| ORM | Spring Data JPA + Hibernate | (Boot BOM) | +| DB | MySQL | - | +| 인증 | JWT (Auth0 java-jwt 4.5.0) + OAuth2 Client | - | +| 클라우드 | OCI Java SDK (Object Storage) + Cloudflare CDN + Google Cloud Storage | 3.80.3 | +| 문서변환 | JODConverter (LibreOffice) | 4.4.9 | +| 모니터링 | Micrometer + Prometheus + Actuator | (Boot BOM) | +| 장애격리 | Resilience4j (Circuit Breaker) | 2.3.0 | +| Rate Limiting | Bucket4j + Caffeine | 8.16.1 | +| API 문서 | SpringDoc OpenAPI (Swagger UI) | 2.8.8 | +| 암호화 | Jasypt | 3.0.5 | +| ID 난독화 | Hashids | 1.0.3 | +| 빌드 | Gradle (Groovy DSL) | 8.14.3 | +| 컨테이너 | Jib (Docker) | 3.4.0 | +| 포맷터 | Spotless + Google Java Format | 7.0.4 / 1.25.2 | +| 테스트 | JUnit 5 | (Boot BOM) | + +## 명령어 (Scripts) + +```bash +# 빌드 +./gradlew build # 전체 빌드 (컴파일 + 테스트 + JAR) +./gradlew :app:bootJar # 실행 가능 JAR 생성 +./gradlew :app:bootRun # 로컬 실행 + +# 테스트 +./gradlew test # 전체 테스트 +./gradlew :모듈명:test # 특정 모듈 테스트 + +# 포맷팅 +./gradlew spotlessApply # 코드 포맷 적용 (Google Java Format) +./gradlew spotlessCheck # 포맷 위반 검증 + +# Docker +./gradlew jib # Docker 이미지 빌드 + 푸시 +./gradlew jibDockerBuild # 로컬 Docker 이미지 빌드 + +# 유틸리티 +./gradlew installGitHooks # Git hooks 경로 설정 +./gradlew dependencyGraphStyled # 모듈 의존성 그래프 생성 (SVG) +``` + +## 아키텍처 + +### 멀티모듈 구조 + +의존 방향: `app` → `*-impl` → `*-api` → `global` + +``` +q-asker/api/ +├── app/ # 진입점 (Spring Boot Application) +│ └── src/main/resources/ +│ ├── application.yml # 설정 진입점 (config/ import) +│ ├── application-secrets.yml # 암호화된 시크릿 +│ └── config/ # 분리된 설정 파일들 +│ ├── server.yml # 서버, DB, JPA, 캐시 +│ ├── ai.yml # Google Gemini AI 설정 +│ ├── security.yml # JWT, OAuth2, CORS +│ ├── aws.yml # OCI Object Storage, CDN +│ ├── jodconverter.yml # LibreOffice 문서변환 +│ ├── monitoring.yml # Actuator, Prometheus +│ ├── q-asker.yml # 앱 커스텀 설정 +│ ├── resilience4j.yml # Circuit Breaker +│ └── springdoc.yml # Swagger/OpenAPI +├── modules/ +│ ├── global/ # 공통 (BaseEntity, ApiResponse, GlobalExceptionHandler) +│ ├── auth/ (api + impl) # 인증 (JWT, OAuth2, RateLimitFilter) +│ ├── oci/ (api + impl) # OCI Object Storage 파일 업로드 +│ ├── board/ (api + impl) # 게시판 +│ ├── quiz-ai/ (api + impl) # AI 퀴즈 생성 (Gemini 호출, 메트릭) +│ ├── quiz-make/(api + impl) # 퀴즈 생성 흐름 (파일업로드, SSE, 생성결과) +│ ├── quiz-set/ (api + impl) # 퀴즈 세트 CRUD +│ ├── quiz-history/(api + impl) # 풀이 히스토리 +│ └── util/ (api + impl) # 유틸리티 (문서변환) +├── infra/ +│ ├── monitoring/ # Grafana Alloy 설정 +│ ├── mysql/ # MySQL Docker 설정 +│ ├── base-image/ # Docker 베이스 이미지 +│ └── terraform/ +│ ├── gcp/ # GCP 인프라 (GCS, IAM, Vertex AI) +│ └── oci/ # OCI 인프라 (NSG Cloudflare 인바운드 규칙) +├── docs/ # 문서, 분석 자료 +├── .githooks/ # Git 훅 (pre-commit, pre-push, prepare-commit-msg) +└── .github/workflows/ # CI/CD + ├── cd-prod_deploy.yml + ├── ci-auto-version-bump.yml + ├── ci-check-code-convention.yml + └── ci-update-api-docs.yml +``` + +## 환경 변수 + +- 민감한 값은 `application-secrets.yml`에 Jasypt `ENC()`로 암호화하여 관리 +- Jasypt 복호화 키: `JASYPT_PASSWORD` 환경변수 또는 JVM 옵션으로 전달 +- 프로파일: `local` (개발), `prod` (운영) +- Actuator 포트: 9090 (서비스 포트와 분리) +- Virtual Threads 활성화 (`spring.threads.virtual.enabled: true`) +- OCI Object Storage: `~/.oci/config` 파일 기반 인증, `OCI_NAMESPACE`/`OCI_BUCKET_NAME` 환경변수 +- Google Cloud: Vertex AI + GCS (ADC 인증) + - `spring.ai.google.genai.project-id`: GCP 프로젝트 ID + - `spring.ai.google.genai.location`: GCP 엔드포인트 (현재: `global`) + - `GCS_BUCKET_NAME`: GCS 버킷 이름 (기본값: `q-asker-ai-files`) + - 로컬: `gcloud auth application-default login`, 프로덕션: 서비스 계정 +- DDoS 방어: Cloudflare Free (`api.q-asker.com`만 프록시 활성화) +- SSL/HTTPS: Cloudflare (Universal SSL) → Nginx (Origin CA TLS), Full (Strict) 모드 + - Origin 인증서: Cloudflare Origin CA (15년 유효) + - 인증서 경로: `/etc/ssl/cloudflare/api.q-asker.com.pem`, `.key` + - OCI NSG: 80/443 인바운드 Cloudflare IP 대역만 허용 + +## 개발 도구 및 설정 + +- **빌드**: Gradle 8.14.3 (Groovy DSL) +- **JDK**: 21 (Gradle Toolchain 자동 관리) +- **포맷터**: Spotless + Google Java Format 1.25.2 + - `./gradlew spotlessApply` — 포맷 적용 + - `./gradlew spotlessCheck` — 포맷 검증 +- **Git Hooks** (`.githooks/`) + - `prepare-commit-msg` — 브랜치에서 JIRA 티켓(`[A-Z]+-[0-9]+`) 감지하여 커밋 메시지 접두사 자동 추가 + - `pre-commit` — `spotlessCheck` 실행, 위반 시 커밋 차단 + - `pre-push` — `spotlessCheck` 실행, 위반 시 푸시 차단 +- **CI/CD**: GitHub Actions + - `ci-check-code-convention.yml` — PR 포맷 검증 + - `ci-auto-version-bump.yml` — 자동 버전 범프 + - `ci-update-api-docs.yml` — OpenAPI 스펙 자동 갱신 + - `cd-prod_deploy.yml` — 운영 배포 + +## gstack + +- 모든 웹 브라우징은 gstack의 `/browse` 스킬을 사용한다. `mcp__claude-in-chrome__*` 도구는 사용하지 않는다. +- 사용 가능한 스킬: `/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/design-shotgun`, `/design-html`, `/review`, `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/setup-deploy`, `/retro`, `/investigate`, `/document-release`, `/codex`, `/cso`, `/autoplan`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade`, `/learn` + +## Skill routing + +When the user's request matches an available skill, ALWAYS invoke it using the Skill +tool as your FIRST action. Do NOT answer directly, do NOT use other tools first. +The skill has specialized workflows that produce better results than ad-hoc answers. + +Key routing rules: +- Product ideas, "is this worth building", brainstorming → invoke office-hours +- Bugs, errors, "why is this broken", 500 errors → invoke investigate +- Ship, deploy, push, create PR → invoke ship +- QA, test the site, find bugs → invoke qa +- Code review, check my diff → invoke review +- Update docs after shipping → invoke document-release +- Weekly retro → invoke retro +- Design system, brand → invoke design-consultation +- Visual audit, design polish → invoke design-review +- Architecture review → invoke plan-eng-review diff --git a/app/src/main/resources/application-secrets.yml b/app/src/main/resources/application-secrets.yml index 4273db86..c1527d65 100644 --- a/app/src/main/resources/application-secrets.yml +++ b/app/src/main/resources/application-secrets.yml @@ -5,7 +5,8 @@ spring: ai: google: genai: - api-key: ENC(QT6BgyfUJpU8eMQ1HeNsDMAO2F4vbCYclqSof8EFxzSqnuuH+rc74ep469JuWspP2N6lHjQvjROZlA6l2gXET1m3hsaLoVt0Rknj0tUTUlk=) + project-id: ENC(M5//c8RRJMLe3mL8W0/4oHNV4xvjE345unvzaasi0rEbEfBEja7BmNlE7/H9LKSSjC4hsexV2z5UNAC0+qTskg==) + location: ENC(At/NbaopVE34W3e3iNNvBTYFHYUYwkjBotCZlYjJo+b8VDIwaRqQFmQnMQO+1XQB) security: oauth2: client: @@ -19,21 +20,23 @@ spring: jwt: secret: ENC(Jeu3MuAaMOI3X+lLvscvqJCIYwjGacOoWze0XmTWV89W3F8QU2yucQO4kfXWuQgi) -aws: - cloudfront: - base-url: ENC(9+YmILh4Tu8KExN3af3xft6fqJBfi8THL8eIQE1RhDJfau54OHutWOvDpKhSHn5i02liSEoDGu6Ybcj6HiXGQA==) - -oci: - object-storage: - namespace: ENC(5t4tYpNdUIeyvrKDePas63mev1aNO0kcv+E5l1THH3UWXpsa0IkYw9FZrOVAd5Ew) - bucket-name: ENC(/eDdNp9IQdDEH+YUrSNAUzQfONKmxq86HGlnw+8rJtPyakHknvh4WR54mblmC7HXJJvReuCAKgNBRVwspoFyxA==) - q-asker: + ai: + gcs: + bucket-name: ENC(O65crsFZNuUzdCrd5FI3w75/yb5YQ8gvNDErIbGgVmoR2Pn9+j0KQgSBTJ5fi4sEnh63lY3VFcsInB5mOQdEgQ==) slack: webhook-url-notify: ENC(9gKy67mFwk2AbeisSdGxQQI6j/DIiKUZoWGzik3QCRvEQa49e6bQsJ69VmfI0cD5kR93TTg1rFl+0f5bnx3SBVKoVHhUUMRVxcN61iyUEMJe0svSRd71VT6Pz5193SJqm1OcULxDd6A8Ifqf0QwlGUULYcVMmTraugoHwvpB1e8=) hashid: salt: ENC(Y+PWwkcYm1pmuHBHW6O4MGw4sS3l/GZAkTxSoJ8Pi3Cp8buxGKyGD1QztMHnVScvHeqTbQJDC9UlKFaNcJ49LEy33dFYEQznm0U4AUCGyuU=) +cdn: + base-url: ENC(9+YmILh4Tu8KExN3af3xft6fqJBfi8THL8eIQE1RhDJfau54OHutWOvDpKhSHn5i02liSEoDGu6Ybcj6HiXGQA==) + +oci: + object-storage: + namespace: ENC(5t4tYpNdUIeyvrKDePas63mev1aNO0kcv+E5l1THH3UWXpsa0IkYw9FZrOVAd5Ew) + bucket-name: ENC(/eDdNp9IQdDEH+YUrSNAUzQfONKmxq86HGlnw+8rJtPyakHknvh4WR54mblmC7HXJJvReuCAKgNBRVwspoFyxA==) + --- spring: config: diff --git a/app/src/main/resources/application-test.yml b/app/src/main/resources/application-test.yml index 9a50419b..50d4b4ea 100644 --- a/app/src/main/resources/application-test.yml +++ b/app/src/main/resources/application-test.yml @@ -41,7 +41,8 @@ spring: ai: google: genai: - api-key: ci-dummy-key + project-id: ci-dummy-project + location: us-central1 chat: options: model: gemini-3-flash-preview @@ -58,17 +59,9 @@ q-asker: web: frontend-deploy-url: http://localhost:3000 - frontend-dev-url: http://localhost:3000 -aws: - s3: - bucket-name: ci-dummy - access-key: ci-dummy - secret-key: ci-dummy - allowed-extensions: application/pdf - - cloudfront: - base-url: http://localhost +cdn: + base-url: http://localhost jodconverter: local: diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml index 75c1b6a0..b423d2f7 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -3,14 +3,14 @@ spring: name: q-asker config: import: - - config/server.yml - - config/ai.yml - - config/security.yml - - config/oci.yml + - config/database-config.yml + - config/ai-setting.yml + - config/spring-security.yml + - config/oci-bucket-config.yml - config/jodconverter.yml - - config/monitoring.yml - - config/q-asker.yml - - config/resilience4j.yml - - config/springdoc.yml + - config/actuator.yml + - config/app-common.yml + - config/resilience.yml + - config/spring-doc.yml - application-secrets.yml diff --git a/app/src/main/resources/config/monitoring.yml b/app/src/main/resources/config/actuator.yml similarity index 100% rename from app/src/main/resources/config/monitoring.yml rename to app/src/main/resources/config/actuator.yml diff --git a/app/src/main/resources/config/ai.yml b/app/src/main/resources/config/ai-setting.yml similarity index 69% rename from app/src/main/resources/config/ai.yml rename to app/src/main/resources/config/ai-setting.yml index 3fd4d3a6..374343a3 100644 --- a/app/src/main/resources/config/ai.yml +++ b/app/src/main/resources/config/ai-setting.yml @@ -1,6 +1,8 @@ # AI (Google Gemini) 설정 spring: ai: + retry: + max-attempts: 1 google: genai: chat: @@ -11,10 +13,7 @@ spring: q-asker: ai: + chat-timeout-ms: 90000 equalization-model: gemini-2.5-flash-lite chunk: max-count-variants: 10, 5 - file-client: - base-url: https://generativelanguage.googleapis.com - connect-timeout-ms: 5000 - read-timeout-ms: 30000 diff --git a/app/src/main/resources/config/app-common.yml b/app/src/main/resources/config/app-common.yml new file mode 100644 index 00000000..c831d99d --- /dev/null +++ b/app/src/main/resources/config/app-common.yml @@ -0,0 +1,55 @@ +# Q-Asker 애플리케이션 공통 설정 +q-asker: + hashid: + min-length: 8 + async: + task-termination-timeout-ms: 10000 + sse: + timeout-ms: 120000 + +# 파일 업로드 검증 + file-validation: + max-file-name-length: 255 + allowed-extensions: application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint + max-file-size: 36_700_160 + +server: + shutdown: graceful + +spring: + lifecycle: + timeout-per-shutdown-phase: 60s + + servlet: + multipart: + max-file-size: 35MB + max-request-size: 35MB + file-size-threshold: 0 + + threads: + virtual: + enabled: true + +--- +spring: + config: + activate: + on-profile: local + +q-asker: + slack: + enabled: false + web: + frontend-deploy-url: http://localhost:5173 + +--- +spring: + config: + activate: + on-profile: prod + +q-asker: + slack: + enabled: true + web: + frontend-deploy-url: https://www.q-asker.com diff --git a/app/src/main/resources/config/server.yml b/app/src/main/resources/config/database-config.yml similarity index 64% rename from app/src/main/resources/config/server.yml rename to app/src/main/resources/config/database-config.yml index 01f53d67..917a55d1 100644 --- a/app/src/main/resources/config/server.yml +++ b/app/src/main/resources/config/database-config.yml @@ -1,23 +1,5 @@ -# 서버, DB, JPA, 캐시 설정 -server: - shutdown: graceful - +# 데이터베이스, JPA, 캐시 설정 spring: - lifecycle: - timeout-per-shutdown-phase: 60s - - servlet: - multipart: - max-file-size: 35MB - max-request-size: 35MB - file-size-threshold: 0 - - threads: - virtual: - enabled: true - ai: - retry: - max-attempts: 1 jpa: open-in-view: false properties: diff --git a/app/src/main/resources/config/oci-bucket-config.yml b/app/src/main/resources/config/oci-bucket-config.yml new file mode 100644 index 00000000..6dcd861a --- /dev/null +++ b/app/src/main/resources/config/oci-bucket-config.yml @@ -0,0 +1,6 @@ +# OCI Object Storage 버킷 설정 +oci: + object-storage: + region: ap-chuncheon-1 + config-file-path: ~/.oci/config + profile: DEFAULT diff --git a/app/src/main/resources/config/oci.yml b/app/src/main/resources/config/oci.yml deleted file mode 100644 index b77aa74a..00000000 --- a/app/src/main/resources/config/oci.yml +++ /dev/null @@ -1,11 +0,0 @@ -# AWS CloudFront + OCI Object Storage 설정 - -# OCI Object Storage (파일 업로드 스토리지) -oci: - object-storage: - region: ap-chuncheon-1 - config-file-path: ~/.oci/config - profile: DEFAULT - max-file-name-length: 255 - allowed-extensions: application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint - max-file-size: 36_700_160 diff --git a/app/src/main/resources/config/q-asker.yml b/app/src/main/resources/config/q-asker.yml deleted file mode 100644 index fbfd8541..00000000 --- a/app/src/main/resources/config/q-asker.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Q-Asker 애플리케이션 공통 설정 -q-asker: - hashid: - min-length: 8 - ai: - chat-timeout-ms: 90000 - async: - task-termination-timeout-ms: 10000 - sse: - timeout-ms: 120000 - rate-limit: - enabled: true - bucket-expire-minutes: 10 - max-bucket-size: 100000 - tiers: - CRITICAL: - capacity: 5 - refill-per-minute: 5 - HEAVY: - capacity: 10 - refill-per-minute: 10 - WRITE: - capacity: 20 - refill-per-minute: 20 - STANDARD: - capacity: 10 - refill-per-minute: 10 - READ: - capacity: 60 - refill-per-minute: 60 - ---- -spring: - config: - activate: - on-profile: local - -q-asker: - slack: - enabled: false - web: - frontend-deploy-url: http://localhost:5173 - frontend-dev-url: http://localhost:5173 - ---- -spring: - config: - activate: - on-profile: prod - -q-asker: - slack: - enabled: true - web: - frontend-deploy-url: https://www.q-asker.com - frontend-dev-url: http://q-asker-test-webpage-20251211.s3-website.ap-northeast-2.amazonaws.com diff --git a/app/src/main/resources/config/resilience4j.yml b/app/src/main/resources/config/resilience.yml similarity index 50% rename from app/src/main/resources/config/resilience4j.yml rename to app/src/main/resources/config/resilience.yml index c350be5c..a1f93e1a 100644 --- a/app/src/main/resources/config/resilience4j.yml +++ b/app/src/main/resources/config/resilience.yml @@ -1,4 +1,6 @@ -# Resilience4j CircuitBreaker 설정 +# 장애 방어 및 트래픽 제어 설정 + +# Circuit Breaker (Resilience4j) resilience4j: circuitbreaker: instances: @@ -16,3 +18,26 @@ resilience4j: - com.icc.qasker.ai.exception.GeminiInfraException metrics: enabled: true + +# Rate Limiting (Bucket4j) +q-asker: + rate-limit: + enabled: true + bucket-expire-minutes: 10 + max-bucket-size: 100000 + tiers: + CRITICAL: + capacity: 10 + refill-per-minute: 10 + HEAVY: + capacity: 10 + refill-per-minute: 10 + WRITE: + capacity: 20 + refill-per-minute: 20 + STANDARD: + capacity: 10 + refill-per-minute: 10 + READ: + capacity: 60 + refill-per-minute: 60 diff --git a/app/src/main/resources/config/springdoc.yml b/app/src/main/resources/config/spring-doc.yml similarity index 100% rename from app/src/main/resources/config/springdoc.yml rename to app/src/main/resources/config/spring-doc.yml diff --git a/app/src/main/resources/config/security.yml b/app/src/main/resources/config/spring-security.yml similarity index 100% rename from app/src/main/resources/config/security.yml rename to app/src/main/resources/config/spring-security.yml diff --git a/build.gradle b/build.gradle index 66b5925a..ee7d4d56 100644 --- a/build.gradle +++ b/build.gradle @@ -249,14 +249,13 @@ subprojects { // annotationProcessor에도 동일 BOM 적용 // 예: spring-boot-configuration-processor 버전도 BOM이 관리하므로 버전 명시 불필요 annotationProcessor platform("org.springframework.boot:spring-boot-dependencies:3.5.8") - // AWS SDK BOM: AWS 서비스 클라이언트 버전 통합 관리 - // 예: implementation "software.amazon.awssdk:s3" 선언 시 버전 생략 가능, - // S3, SQS 등 여러 AWS 모듈 간 버전 불일치 방지 - implementation platform("software.amazon.awssdk:bom:2.27.24") // Spring AI BOM: Spring AI 관련 라이브러리 버전 통합 관리 // 예: implementation "org.springframework.ai:spring-ai-starter-model-google-genai" 선언 시 // 버전 생략 가능, Spring AI 모듈 간 호환성 보장 implementation platform("org.springframework.ai:spring-ai-bom:1.1.2") + // Google Cloud BOM: Google Cloud 서비스 클라이언트 버전 통합 관리 + // 예: implementation "com.google.cloud:google-cloud-storage" 선언 시 버전 생략 가능 + implementation platform("com.google.cloud:libraries-bom:26.67.0") // ──────────────────────────────── // 전 모듈 공통 라이브러리 diff --git a/.github/resources/deploy.sh b/infra/blue-green/deploy.sh similarity index 100% rename from .github/resources/deploy.sh rename to infra/blue-green/deploy.sh diff --git a/.github/resources/docker-compose.yml b/infra/blue-green/docker-compose.yml similarity index 81% rename from .github/resources/docker-compose.yml rename to infra/blue-green/docker-compose.yml index a04b10ee..19afc1c0 100644 --- a/.github/resources/docker-compose.yml +++ b/infra/blue-green/docker-compose.yml @@ -8,8 +8,7 @@ services: - "9090:9090" volumes: - ./nginx/conf.d:/etc/nginx/conf.d - - /etc/letsencrypt:/etc/letsencrypt:ro - - /var/www/certbot:/var/www/certbot:ro + - /etc/ssl/cloudflare:/etc/ssl/cloudflare:ro app-blue: image: ${DOCKER_IMAGE} @@ -19,6 +18,7 @@ services: - JASYPT_ENCRYPTOR_PASSWORD=${JASYPT_ENCRYPTOR_PASSWORD} volumes: - ~/.oci:/home/appuser/.oci:ro + - ~/.config/gcloud:/home/appuser/.config/gcloud:ro ports: - "${BLUE_PORT}:8080" @@ -30,5 +30,6 @@ services: - JASYPT_ENCRYPTOR_PASSWORD=${JASYPT_ENCRYPTOR_PASSWORD} volumes: - ~/.oci:/home/appuser/.oci:ro + - ~/.config/gcloud:/home/appuser/.config/gcloud:ro ports: - "${GREEN_PORT}:8080" diff --git a/.github/resources/nginx/conf.d/default.conf b/infra/blue-green/nginx/conf.d/default.conf similarity index 71% rename from .github/resources/nginx/conf.d/default.conf rename to infra/blue-green/nginx/conf.d/default.conf index 9fa292b9..b4a52468 100644 --- a/.github/resources/nginx/conf.d/default.conf +++ b/infra/blue-green/nginx/conf.d/default.conf @@ -1,14 +1,38 @@ -# HTTP → HTTPS 리다이렉트 + ACME challenge +# Cloudflare 실제 클라이언트 IP 복원 +# https://www.cloudflare.com/ips/ +# IPv4 +set_real_ip_from 173.245.48.0/20; +set_real_ip_from 103.21.244.0/22; +set_real_ip_from 103.22.200.0/22; +set_real_ip_from 103.31.4.0/22; +set_real_ip_from 141.101.64.0/18; +set_real_ip_from 108.162.192.0/18; +set_real_ip_from 190.93.240.0/20; +set_real_ip_from 188.114.96.0/20; +set_real_ip_from 197.234.240.0/22; +set_real_ip_from 198.41.128.0/17; +set_real_ip_from 162.158.0.0/15; +set_real_ip_from 104.16.0.0/13; +set_real_ip_from 104.24.0.0/14; +set_real_ip_from 172.64.0.0/13; +set_real_ip_from 131.0.72.0/22; + +# IPv6 +set_real_ip_from 2400:cb00::/32; +set_real_ip_from 2606:4700::/32; +set_real_ip_from 2803:f800::/32; +set_real_ip_from 2405:b500::/32; +set_real_ip_from 2405:8100::/32; +set_real_ip_from 2a06:98c0::/29; +set_real_ip_from 2c0f:f248::/32; + +real_ip_header CF-Connecting-IP; + +# HTTP → HTTPS 리다이렉트 server { listen 80; server_name api.q-asker.com; - # Let's Encrypt 인증서 발급/갱신용 - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - # 나머지 요청은 HTTPS로 리다이렉트 location / { return 301 https://$host$request_uri; } @@ -19,9 +43,9 @@ server { listen 443 ssl; server_name api.q-asker.com; - # SSL 인증서 - ssl_certificate /etc/letsencrypt/live/api.q-asker.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.q-asker.com/privkey.pem; + # SSL 인증서 (Cloudflare Origin CA) + ssl_certificate /etc/ssl/cloudflare/api.q-asker.com.pem; + ssl_certificate_key /etc/ssl/cloudflare/api.q-asker.com.key; # SSL 보안 설정 ssl_protocols TLSv1.2 TLSv1.3; diff --git a/.github/resources/nginx/conf.d/service-url.inc b/infra/blue-green/nginx/conf.d/service-url.inc similarity index 100% rename from .github/resources/nginx/conf.d/service-url.inc rename to infra/blue-green/nginx/conf.d/service-url.inc diff --git a/infra/terraform/gcp/.terraform.lock.hcl b/infra/terraform/gcp/.terraform.lock.hcl new file mode 100644 index 00000000..78b8795f --- /dev/null +++ b/infra/terraform/gcp/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = "~> 6.0" + hashes = [ + "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} diff --git a/infra/terraform/gcp/apis.tf b/infra/terraform/gcp/apis.tf new file mode 100644 index 00000000..66c56359 --- /dev/null +++ b/infra/terraform/gcp/apis.tf @@ -0,0 +1,11 @@ +# Vertex AI API 활성화 +resource "google_project_service" "aiplatform" { + service = "aiplatform.googleapis.com" + disable_on_destroy = false +} + +# Cloud Storage API 활성화 +resource "google_project_service" "storage" { + service = "storage.googleapis.com" + disable_on_destroy = false +} diff --git a/infra/terraform/gcp/iam.tf b/infra/terraform/gcp/iam.tf new file mode 100644 index 00000000..01369a6a --- /dev/null +++ b/infra/terraform/gcp/iam.tf @@ -0,0 +1,19 @@ +# AI 전용 서비스 계정 +resource "google_service_account" "ai" { + account_id = var.service_account_id + display_name = "Q-Asker AI Service" +} + +# GCS 파일 업로드/삭제 권한 +resource "google_project_iam_member" "ai_storage" { + project = var.project_id + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.ai.email}" +} + +# Vertex AI 호출 권한 +resource "google_project_iam_member" "ai_vertex" { + project = var.project_id + role = "roles/aiplatform.user" + member = "serviceAccount:${google_service_account.ai.email}" +} diff --git a/infra/terraform/gcp/main.tf b/infra/terraform/gcp/main.tf new file mode 100644 index 00000000..ab464dc7 --- /dev/null +++ b/infra/terraform/gcp/main.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + } + + # 로컬 state (1인 프로젝트) + backend "local" {} +} + +provider "google" { + project = var.project_id + region = var.region +} diff --git a/infra/terraform/gcp/outputs.tf b/infra/terraform/gcp/outputs.tf new file mode 100644 index 00000000..1018c651 --- /dev/null +++ b/infra/terraform/gcp/outputs.tf @@ -0,0 +1,19 @@ +output "gcs_bucket_name" { + description = "GCS 버킷 이름" + value = google_storage_bucket.ai_files.name +} + +output "service_account_email" { + description = "AI 서비스 계정 이메일" + value = google_service_account.ai.email +} + +output "project_id" { + description = "GCP 프로젝트 ID" + value = var.project_id +} + +output "region" { + description = "GCP 리전" + value = var.region +} \ No newline at end of file diff --git a/infra/terraform/gcp/storage.tf b/infra/terraform/gcp/storage.tf new file mode 100644 index 00000000..2c5ce09e --- /dev/null +++ b/infra/terraform/gcp/storage.tf @@ -0,0 +1,21 @@ +# Vertex AI 컨텍스트 캐싱용 PDF 임시 저장 버킷 +resource "google_storage_bucket" "ai_files" { + name = var.gcs_bucket_name + location = var.region + + storage_class = "STANDARD" + uniform_bucket_level_access = true + + # 1일 후 자동 삭제 (GCS 최소 단위가 일 단위) + # 애플리케이션에서 캐시 삭제 시 즉시 삭제도 병행 + lifecycle_rule { + condition { + age = 1 + } + action { + type = "Delete" + } + } + + depends_on = [google_project_service.storage] +} diff --git a/infra/terraform/gcp/variables.tf b/infra/terraform/gcp/variables.tf new file mode 100644 index 00000000..33968840 --- /dev/null +++ b/infra/terraform/gcp/variables.tf @@ -0,0 +1,23 @@ +variable "project_id" { + description = "GCP 프로젝트 ID" + type = string + default = "project-e9d67c94-3157-456d-83b" +} + +variable "region" { + description = "GCP 리전" + type = string + default = "asia-northeast3" +} + +variable "gcs_bucket_name" { + description = "Vertex AI 컨텍스트 캐싱용 PDF 임시 저장 버킷" + type = string + default = "q-asker-ai-files" +} + +variable "service_account_id" { + description = "AI 서비스 계정 ID" + type = string + default = "q-asker-ai" +} diff --git a/infra/terraform/oci/.terraform.lock.hcl b/infra/terraform/oci/.terraform.lock.hcl new file mode 100644 index 00000000..c0aaa8da --- /dev/null +++ b/infra/terraform/oci/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/oracle/oci" { + version = "6.37.0" + constraints = "~> 6.0" + hashes = [ + "h1:S3gkcr6ZMGmrU0SQencNQ+j2L2DkOgi+InVKAVOWZWI=", + "zh:2b1ba7863e162f3f2e5929c6a43db6b0d44bb24032bf7d1b4fe27c36e39d512f", + "zh:2d2ccd7eaab45c0b35a52b7dd6e315a38a9e32d72827003194786dbae8004240", + "zh:3e3017f035fac18114e0b1d29c72430958054def0f800c22d36e1144d0c76422", + "zh:504eb43e31cead3c4ff9b3649b51b62e59f91cb94f622e7df110f31bb95daf20", + "zh:86e2ad61fa0c56a7e17b28ac79558ffd462cd086dd2f62b79988596792aa45c8", + "zh:92436cd5326a587e8fa927bee2d42800ff9ef93a782ccbea5c6ebd11e06cf786", + "zh:94aa72b19bbf5ccc4778c9154d615444184d63e782ded54741e18969ab00cb61", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b8d7e2d181acda6b8836071610fd292cc5c8ef659cfb92aac33bd6bfda71c24f", + "zh:ccb95a6b390f4b161532e9b14708a6cb989ca2fc51f1ae00eae9db4dd7ca70d4", + "zh:d385763af4eda8aa6bf906773b461efce4cab82670826d990fdd5e05fb8a2afa", + "zh:df0cd594595c16b33b1215543853f5e3671d16e665090b3f48f19ff288029dd4", + "zh:e5266e51a70ae31af36a3b58707f77c4189c956ec6f982fcce8aafcec36de3d7", + "zh:e5da4389063cddf4e7424ce0df1c218c1508672b45470d66adc81c52d5ccf46d", + "zh:e608694f226bb18cd93128b8dac910127cdf457f7baf37e472d0d18b531e9f84", + ] +} diff --git a/infra/terraform/oci/main.tf b/infra/terraform/oci/main.tf new file mode 100644 index 00000000..dcc18226 --- /dev/null +++ b/infra/terraform/oci/main.tf @@ -0,0 +1,102 @@ +terraform { + required_providers { + oci = { + source = "oracle/oci" + version = "~> 6.0" + } + } +} + +provider "oci" { + config_file_profile = "DEFAULT" +} + +locals { + api_nsg_id = "ocid1.networksecuritygroup.oc1.ap-chuncheon-1.aaaaaaaap6rb3pbbxgfofgdpx72xtbgqjl5ws2w3bnenkl3mf7u43gzevomq" + mon_nsg_id = "ocid1.networksecuritygroup.oc1.ap-chuncheon-1.aaaaaaaaamr2hlzkkpi76gg5dqufgszh6yzwf7rnd3ylhiyswaiwa3mscinq" + + # Cloudflare IPv4 대역 (https://www.cloudflare.com/ips-v4/) + cloudflare_ipv4 = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + ] + + # HTTP(80) + HTTPS(443) 포트 + web_ports = [80, 443] + + # Cloudflare IP × 포트 조합 + cloudflare_rules = flatten([ + for port in local.web_ports : [ + for cidr in local.cloudflare_ipv4 : { + port = port + cidr = cidr + } + ] + ]) +} + +# 기존 NSG를 data source로 참조 (Terraform이 관리하지 않는 기존 리소스) +data "oci_core_network_security_group" "api_nsg" { + network_security_group_id = local.api_nsg_id +} + +data "oci_core_network_security_group" "mon_nsg" { + network_security_group_id = local.mon_nsg_id +} + +# API 서버: Cloudflare IP 대역에서 80/443 허용 +resource "oci_core_network_security_group_security_rule" "cloudflare_ingress" { + for_each = { for idx, rule in local.cloudflare_rules : "${rule.port}-${replace(rule.cidr, "/", "_")}" => rule } + + network_security_group_id = local.api_nsg_id + direction = "INGRESS" + protocol = "6" # TCP + + source = each.value.cidr + source_type = "CIDR_BLOCK" + stateless = false + + tcp_options { + destination_port_range { + min = each.value.port + max = each.value.port + } + } + + description = "Cloudflare ${each.value.port} from ${each.value.cidr}" +} + +# MON 서버: Cloudflare IP 대역에서 3000 허용 (Grafana) +resource "oci_core_network_security_group_security_rule" "mon_cloudflare_ingress" { + for_each = { for cidr in local.cloudflare_ipv4 : replace(cidr, "/", "_") => cidr } + + network_security_group_id = local.mon_nsg_id + direction = "INGRESS" + protocol = "6" # TCP + + source = each.value + source_type = "CIDR_BLOCK" + stateless = false + + tcp_options { + destination_port_range { + min = 3000 + max = 3000 + } + } + + description = "Cloudflare 3000 from ${each.value}" +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java index 5dbd70d3..0b7ebaab 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java @@ -20,8 +20,7 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry .addMapping("/**") - .allowedOrigins( - qAskerProperties.getFrontendDevUrl(), qAskerProperties.getFrontendDeployUrl()) + .allowedOrigins(qAskerProperties.getFrontendDeployUrl()) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowCredentials(true) .maxAge(3600); diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/CustomException.java b/modules/global/src/main/java/com/icc/qasker/global/error/CustomException.java index d82c5f5b..1c528df3 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/CustomException.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/CustomException.java @@ -66,7 +66,7 @@ public CustomException(ExceptionMessage exceptionMessage, Throwable cause) { // 로그) ERROR [bucket=my-bucket] 파일 업로드에 실패했습니다 // com.icc.qasker.global.error.CustomException: 파일 업로드에 실패했습니다 // at ...Service.method(Service.java:30) - // Caused by: software.amazon.awssdk.services.s3.model.S3Exception: Access Denied + // Caused by: RuntimeException: OCI Object Storage 업로드 실패 // at ...S3Client.putObject(S3Client.java:120) public CustomException(ExceptionMessage exceptionMessage, String context, Throwable cause) { super(exceptionMessage.getMessage(), cause); diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java index 9b30270f..fd65cd86 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java @@ -11,7 +11,7 @@ public enum ExceptionMessage { DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), - // ## 파일 업로드/변환 (aws, quiz-make, util) + // ## 파일 업로드/변환 (oci, quiz-make, util) OUT_OF_FILE_SIZE(HttpStatus.BAD_REQUEST, "허용되지 않은 파일 크기입니다."), FILE_NAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "파일 이름이 존재하지 않습니다"), FILE_NAME_TOO_LONG(HttpStatus.BAD_REQUEST, "파일 이름이 깁니다"), diff --git a/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java b/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java index 50547655..7cbaf1d4 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java +++ b/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java @@ -10,7 +10,6 @@ public class QAskerProperties { private final String frontendDeployUrl; - private final String frontendDevUrl; private final String aiServerUrl; private final String aiMockingServerUrl; } diff --git a/modules/oci/api/src/main/java/com/icc/qasker/oci/FileValidateService.java b/modules/oci/api/src/main/java/com/icc/qasker/oci/FileValidateService.java index 7c8ca128..add328c1 100644 --- a/modules/oci/api/src/main/java/com/icc/qasker/oci/FileValidateService.java +++ b/modules/oci/api/src/main/java/com/icc/qasker/oci/FileValidateService.java @@ -2,7 +2,7 @@ public interface FileValidateService { - void checkCloudFrontUrlWithThrowing(String url); + void checkCdnUrlWithThrowing(String url); void validateFileWithThrowing(String fileName, long fileSize, String contentType); } diff --git a/modules/oci/api/src/main/java/com/icc/qasker/oci/ObjectStorageService.java b/modules/oci/api/src/main/java/com/icc/qasker/oci/ObjectStorageService.java index fe056702..b3e69a79 100644 --- a/modules/oci/api/src/main/java/com/icc/qasker/oci/ObjectStorageService.java +++ b/modules/oci/api/src/main/java/com/icc/qasker/oci/ObjectStorageService.java @@ -4,6 +4,6 @@ public interface ObjectStorageService { - /** PDF 파일을 Object Storage에 업로드하고 CloudFront URL을 반환한다. */ + /** PDF 파일을 Object Storage에 업로드하고 CDN URL을 반환한다. */ String uploadPdf(Path pdfFile, String originalFileName); } diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/config/MockOciClientConfig.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/config/MockOciClientConfig.java index 83320ffa..f9b37d9b 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/config/MockOciClientConfig.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/config/MockOciClientConfig.java @@ -1,7 +1,7 @@ package com.icc.qasker.oci.config; import com.icc.qasker.oci.ObjectStorageService; -import com.icc.qasker.oci.properties.AwsCloudFrontProperties; +import com.icc.qasker.oci.properties.CdnProperties; import java.nio.file.Path; import java.util.UUID; import lombok.extern.slf4j.Slf4j; @@ -18,8 +18,7 @@ public class MockOciClientConfig { @Bean @Primary - public ObjectStorageService mockObjectStorageService( - AwsCloudFrontProperties cloudFrontProperties) { + public ObjectStorageService mockObjectStorageService(CdnProperties cdnProperties) { return new ObjectStorageService() { @Override public String uploadPdf(Path pdfFile, String originalFileName) { @@ -29,7 +28,7 @@ public String uploadPdf(Path pdfFile, String originalFileName) { } catch (InterruptedException e) { throw new RuntimeException(e); } - return cloudFrontProperties.baseUrl() + "/" + UUID.randomUUID() + ".pdf"; + return cdnProperties.baseUrl() + "/" + UUID.randomUUID() + ".pdf"; } }; } diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/AwsCloudFrontProperties.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/CdnProperties.java similarity index 51% rename from modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/AwsCloudFrontProperties.java rename to modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/CdnProperties.java index c03af0b3..c73a9667 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/AwsCloudFrontProperties.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/CdnProperties.java @@ -2,6 +2,5 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "aws.cloudfront") -public record AwsCloudFrontProperties(String baseUrl) {} -; +@ConfigurationProperties(prefix = "cdn") +public record CdnProperties(String baseUrl) {} diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/FileValidationProperties.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/FileValidationProperties.java new file mode 100644 index 00000000..d1b625e8 --- /dev/null +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/FileValidationProperties.java @@ -0,0 +1,8 @@ +package com.icc.qasker.oci.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** 파일 업로드 검증 설정 */ +@ConfigurationProperties(prefix = "q-asker.file-validation") +public record FileValidationProperties( + long maxFileSize, int maxFileNameLength, String allowedExtensions) {} diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/OciObjectStorageProperties.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/OciObjectStorageProperties.java index 4b9e4ddd..d9979448 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/OciObjectStorageProperties.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/properties/OciObjectStorageProperties.java @@ -4,11 +4,4 @@ @ConfigurationProperties(prefix = "oci.object-storage") public record OciObjectStorageProperties( - String namespace, - String bucketName, - String region, - String configFilePath, - String profile, - long maxFileSize, - int maxFileNameLength, - String allowedExtensions) {} + String namespace, String bucketName, String region, String configFilePath, String profile) {} diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java index 628a38ee..fd4256c3 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java @@ -3,8 +3,8 @@ import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.oci.FileValidateService; -import com.icc.qasker.oci.properties.AwsCloudFrontProperties; -import com.icc.qasker.oci.properties.OciObjectStorageProperties; +import com.icc.qasker.oci.properties.CdnProperties; +import com.icc.qasker.oci.properties.FileValidationProperties; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; @@ -12,22 +12,22 @@ @AllArgsConstructor public class FileValidateServiceImpl implements FileValidateService { - private final AwsCloudFrontProperties awsCloudFrontProperties; - private final OciObjectStorageProperties ociObjectStorageProperties; + private final CdnProperties cdnProperties; + private final FileValidationProperties fileValidationProperties; @Override - public void checkCloudFrontUrlWithThrowing(String url) { - if (!url.startsWith(awsCloudFrontProperties.baseUrl())) { + public void checkCdnUrlWithThrowing(String url) { + if (!url.startsWith(cdnProperties.baseUrl())) { throw new CustomException(ExceptionMessage.INVALID_URL_REQUEST); } } @Override public void validateFileWithThrowing(String fileName, long fileSize, String contentType) { - int maxFileNameLength = ociObjectStorageProperties.maxFileNameLength(); - String allowedExtensions = ociObjectStorageProperties.allowedExtensions(); + int maxFileNameLength = fileValidationProperties.maxFileNameLength(); + String allowedExtensions = fileValidationProperties.allowedExtensions(); - if (fileSize > ociObjectStorageProperties.maxFileSize()) { + if (fileSize > fileValidationProperties.maxFileSize()) { throw new CustomException(ExceptionMessage.OUT_OF_FILE_SIZE); } if (fileName == null) { diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java index 1e10b7c3..03553318 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java @@ -1,7 +1,7 @@ package com.icc.qasker.oci.service; import com.icc.qasker.oci.ObjectStorageService; -import com.icc.qasker.oci.properties.AwsCloudFrontProperties; +import com.icc.qasker.oci.properties.CdnProperties; import com.icc.qasker.oci.properties.OciObjectStorageProperties; import com.oracle.bmc.objectstorage.requests.PutObjectRequest; import com.oracle.bmc.objectstorage.transfer.UploadManager; @@ -24,17 +24,17 @@ @ConditionalOnBean(UploadManager.class) public class OciObjectStorageServiceImpl implements ObjectStorageService { - private final AwsCloudFrontProperties awsCloudFrontProperties; + private final CdnProperties cdnProperties; private final OciObjectStorageProperties ociProperties; private final UploadManager uploadManager; private final Timer uploadTimer; public OciObjectStorageServiceImpl( - AwsCloudFrontProperties awsCloudFrontProperties, + CdnProperties cdnProperties, OciObjectStorageProperties ociProperties, UploadManager uploadManager, MeterRegistry registry) { - this.awsCloudFrontProperties = awsCloudFrontProperties; + this.cdnProperties = cdnProperties; this.ociProperties = ociProperties; this.uploadManager = uploadManager; this.uploadTimer = @@ -75,7 +75,7 @@ public String uploadPdf(Path pdfFile, String originalFileName) { throw new RuntimeException("OCI Object Storage 업로드 실패: " + originalFileName, e); } - String finalUrl = awsCloudFrontProperties.baseUrl() + "/" + objectName; + String finalUrl = cdnProperties.baseUrl() + "/" + objectName; log.info("PDF OCI 업로드 완료: {} -> {}", originalFileName, finalUrl); return finalUrl; }); diff --git a/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java index 1a1ee222..a6c2714b 100644 --- a/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java +++ b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java @@ -6,7 +6,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.icc.qasker.oci.properties.AwsCloudFrontProperties; +import com.icc.qasker.oci.properties.CdnProperties; import com.icc.qasker.oci.properties.OciObjectStorageProperties; import com.oracle.bmc.objectstorage.transfer.UploadManager; import com.oracle.bmc.objectstorage.transfer.UploadManager.UploadRequest; @@ -30,26 +30,18 @@ void setUp() { OciObjectStorageProperties ociProperties = new OciObjectStorageProperties( - "test-namespace", - "test-bucket", - "ap-chuncheon-1", - "~/.oci/config", - "DEFAULT", - 36_700_160L, - 255, - "application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation"); + "test-namespace", "test-bucket", "ap-chuncheon-1", "~/.oci/config", "DEFAULT"); - AwsCloudFrontProperties cloudFrontProperties = - new AwsCloudFrontProperties("https://files.test.com"); + CdnProperties cdnProperties = new CdnProperties("https://files.test.com"); service = new OciObjectStorageServiceImpl( - cloudFrontProperties, ociProperties, uploadManager, new SimpleMeterRegistry()); + cdnProperties, ociProperties, uploadManager, new SimpleMeterRegistry()); } @Test - @DisplayName("PDF 업로드 시 OCI에 저장하고 CloudFront URL을 반환한다") - void uploadPdf_pdfFile_returnsCloudFrontUrl() throws IOException { + @DisplayName("PDF 업로드 시 OCI에 저장하고 CDN URL을 반환한다") + void uploadPdf_pdfFile_returnsCdnUrl() throws IOException { // Given: 임시 PDF 파일을 생성한다 Path pdfFile = Files.createTempFile("test", ".pdf"); Files.writeString(pdfFile, "dummy pdf content"); @@ -59,11 +51,11 @@ void uploadPdf_pdfFile_returnsCloudFrontUrl() throws IOException { try { // When: PDF 파일 업로드를 실행한다 - String cloudFrontUrl = service.uploadPdf(pdfFile, "document.pdf"); + String cdnUrl = service.uploadPdf(pdfFile, "document.pdf"); - // Then: CloudFront 도메인으로 시작하고 .pdf로 끝나야 한다 - assertThat(cloudFrontUrl).startsWith("https://files.test.com/"); - assertThat(cloudFrontUrl).endsWith(".pdf"); + // Then: CDN 도메인으로 시작하고 .pdf로 끝나야 한다 + assertThat(cdnUrl).startsWith("https://files.test.com/"); + assertThat(cdnUrl).endsWith(".pdf"); // Then: UploadManager.upload()이 호출되었는지 검증한다 verify(uploadManager).upload(any(UploadRequest.class)); diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/GeminiFileService.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/GeminiFileService.java index a3896f9d..381f052e 100644 --- a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/GeminiFileService.java +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/GeminiFileService.java @@ -7,7 +7,7 @@ public interface GeminiFileService { - /** CloudFront URL에서 PDF를 다운로드하여 Gemini에 업로드한다. */ + /** CDN URL에서 PDF를 다운로드하여 Gemini에 업로드한다. */ FileMetadata uploadPdf(String pdfUrl); /** 로컬 파일을 직접 Gemini에 업로드한다. */ @@ -18,8 +18,8 @@ public interface GeminiFileService { FileMetadata waitForProcessing(String fileName) throws InterruptedException; /** 진행 중인 Gemini 업로드 Future를 캐시에 저장한다. */ - void cacheUploadFuture(String cloudFrontUrl, CompletableFuture future); + void cacheUploadFuture(String cdnUrl, CompletableFuture future); /** 캐시에서 Gemini 파일 메타데이터를 조회한다. 업로드가 진행 중이면 완료될 때까지 대기하고, 실패 시 캐시에서 제거하고 empty를 반환한다. */ - Optional awaitCachedFileMetadata(String cloudFrontUrl); + Optional awaitCachedFileMetadata(String cdnUrl); } diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AIProblem.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AIProblem.java index 8db3880d..a8b70d13 100644 --- a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AIProblem.java +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AIProblem.java @@ -3,7 +3,6 @@ import java.util.List; public record AIProblem( - int number, String content, String quizExplanation, List selections, diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/GeminiFileUploadResponse.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/GeminiFileUploadResponse.java index 7459e391..b7e248bf 100644 --- a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/GeminiFileUploadResponse.java +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/GeminiFileUploadResponse.java @@ -1,19 +1,19 @@ package com.icc.qasker.ai.dto; -/** Gemini File API 업로드/조회 응답 매핑. JSON 구조: { "file": { ... } } */ +/** 파일 업로드 응답 매핑. */ public record GeminiFileUploadResponse(FileMetadata file) { /** - * Gemini 파일 메타데이터. + * 파일 메타데이터. * - * @param name 파일 리소스 이름 (예: "files/abc123") — 조회/삭제 시 식별자 + * @param name GCS blob 경로 — 삭제 시 식별자 * @param displayName 업로드 시 지정한 표시 이름 * @param mimeType MIME 타입 (예: "application/pdf") - * @param sizeBytes 파일 크기 (Gemini가 문자열로 반환) - * @param createTime 생성 시각 (ISO 8601) - * @param updateTime 수정 시각 (ISO 8601) - * @param state 처리 상태: "PROCESSING" | "ACTIVE" | "FAILED" - * @param uri 파일 URI — generateContent/Cache에서 fileUri로 참조 + * @param sizeBytes 파일 크기 + * @param createTime 생성 시각 + * @param updateTime 수정 시각 + * @param state 처리 상태 + * @param uri 파일 URI (예: "gs://bucket/path") — 캐시에서 fileUri로 참조 */ public record FileMetadata( String name, diff --git a/modules/quiz-ai/impl/build.gradle b/modules/quiz-ai/impl/build.gradle index 2fc764e4..ab3713c9 100644 --- a/modules/quiz-ai/impl/build.gradle +++ b/modules/quiz-ai/impl/build.gradle @@ -18,14 +18,16 @@ dependencies { // ──────────────────────────────── // Spring AI // ──────────────────────────────── - // Spring AI Google GenAI Starter: Google Gemini 모델 연동을 위한 자동 구성 및 클라이언트 제공 - // 예: ChatClient.create(geminiModel).prompt("질문을 생성해줘").call()로 Gemini API 호출, - // application.yml에 spring.ai.google.genai.api-key 설정만으로 자동 연결 + // Spring AI Google GenAI Starter: Vertex AI 모드로 Gemini 모델 연동 + // Client.builder().vertexAI(true)로 Vertex AI 엔드포인트 사용 implementation "org.springframework.ai:spring-ai-starter-model-google-genai" - // Caffeine: Gemini 파일 메타데이터 인메모리 캐시 (업로드 결과 재사용) + // Caffeine: GCS 파일 메타데이터 인메모리 캐시 (업로드 결과 재사용) implementation "com.github.ben-manes.caffeine:caffeine" + // Google Cloud Storage: Vertex AI 컨텍스트 캐싱을 위한 PDF 임시 저장소 + implementation "com.google.cloud:google-cloud-storage" + // ──────────────────────────────── // 테스트 // ──────────────────────────────── diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GcsClientConfig.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GcsClientConfig.java new file mode 100644 index 00000000..8957b6d1 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GcsClientConfig.java @@ -0,0 +1,16 @@ +package com.icc.qasker.ai.config; + +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Google Cloud Storage 클라이언트 설정. ADC(Application Default Credentials)로 인증한다. */ +@Configuration +public class GcsClientConfig { + + @Bean + public Storage gcsStorage() { + return StorageOptions.getDefaultInstance().getService(); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java index 1d120570..8dd66fc2 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java @@ -20,7 +20,9 @@ public class GeminiClientConfig { @Bean public Client googleGenAiClient(GoogleGenAiConnectionProperties properties) { return Client.builder() - .apiKey(properties.getApiKey()) + .project(properties.getProjectId()) + .location(properties.getLocation()) + .vertexAI(true) .httpOptions(HttpOptions.builder().timeout(aiProperties.getChatTimeoutMs()).build()) .build(); } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiFileRestClientConfig.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiFileRestClientConfig.java deleted file mode 100644 index 6b59142d..00000000 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiFileRestClientConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.icc.qasker.ai.config; - -import com.icc.qasker.ai.properties.QAskerAiProperties; -import java.time.Duration; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -@Configuration -@RequiredArgsConstructor -public class GeminiFileRestClientConfig { - - private final QAskerAiProperties aiProperties; - - @Bean("geminiFileRestClient") - public RestClient geminiRestClient() { - QAskerAiProperties.FileClient fileClient = aiProperties.getFileClient(); - - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofMillis(fileClient.getConnectTimeoutMs())); - factory.setReadTimeout(Duration.ofMillis(fileClient.getReadTimeoutMs())); - - return RestClient.builder().baseUrl(fileClient.getBaseUrl()).requestFactory(factory).build(); - } -} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiQuestionMapper.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiQuestionMapper.java index ab41b608..5100bff0 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiQuestionMapper.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiQuestionMapper.java @@ -4,9 +4,7 @@ import com.icc.qasker.ai.dto.AIProblemSet; import com.icc.qasker.ai.dto.AISelection; import com.icc.qasker.ai.structure.GeminiQuestion; -import java.util.ArrayList; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -15,31 +13,28 @@ public class GeminiQuestionMapper { /** - * questions 목록을 AIProblemSet으로 변환하고, 전역 번호를 할당한다. + * questions 목록을 AIProblemSet으로 변환한다. 번호는 소비자 측에서 할당한다. * * @param questions 파싱된 문제 목록 * @param referencedPages 참조 페이지 목록 - * @param numberCounter 스레드 안전 전역 번호 카운터 * @return 변환된 AIProblemSet (해설 원본 데이터 포함) */ - public static AIProblemSet toDto( - List questions, List referencedPages, AtomicInteger numberCounter) { - List result = new ArrayList<>(questions.size()); - - for (GeminiQuestion q : questions) { - int globalNumber = numberCounter.getAndIncrement(); - - List selections = - q.selections() == null - ? List.of() - : q.selections().stream() - .map(s -> new AISelection(s.content(), s.explanation(), s.correct())) - .toList(); - - result.add( - new AIProblem( - globalNumber, q.content(), q.quizExplanation(), selections, referencedPages)); - } + public static AIProblemSet toDto(List questions, List referencedPages) { + List result = + questions.stream() + .map( + q -> + new AIProblem( + q.content(), + q.quizExplanation(), + q.selections() == null + ? List.of() + : q.selections().stream() + .map( + s -> new AISelection(s.content(), s.explanation(), s.correct())) + .toList(), + referencedPages)) + .toList(); return new AIProblemSet(result); } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java index 074b99e0..e87f5f79 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java @@ -21,8 +21,8 @@ public class QAskerAiProperties { /** 청크 분할 설정 */ private Chunk chunk = new Chunk(); - /** Gemini File REST Client 설정 */ - private FileClient fileClient = new FileClient(); + /** Google Cloud Storage 설정 */ + private Gcs gcs = new Gcs(); @Getter @Setter @@ -42,15 +42,9 @@ public int pickMaxCount() { @Getter @Setter - public static class FileClient { + public static class Gcs { - /** Gemini File API 베이스 URL */ - private String baseUrl = "https://generativelanguage.googleapis.com"; - - /** 연결 타임아웃 (ms) */ - private int connectTimeoutMs = 5_000; - - /** 읽기 타임아웃 (ms) */ - private int readTimeoutMs = 30_000; + /** GCS 버킷 이름 */ + private String bucketName; } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/GeminiFileServiceImpl.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/GeminiFileServiceImpl.java index 78fb2ebf..2640f74a 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/GeminiFileServiceImpl.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/GeminiFileServiceImpl.java @@ -2,9 +2,12 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; import com.icc.qasker.ai.GeminiFileService; -import com.icc.qasker.ai.dto.GeminiFileUploadResponse; import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; +import com.icc.qasker.ai.properties.QAskerAiProperties; import com.icc.qasker.ai.util.PdfUtils; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; @@ -12,43 +15,31 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import java.io.IOException; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.model.google.genai.autoconfigure.chat.GoogleGenAiConnectionProperties; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.io.FileSystemResource; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; @Slf4j @Service public class GeminiFileServiceImpl implements GeminiFileService { - private static final int POLL_INTERVAL_MS = 1_000; - private static final int MAX_POLL_ATTEMPTS = 30; - private static final String STATE_ACTIVE = "ACTIVE"; - private static final String STATE_FAILED = "FAILED"; - - private final RestClient restClient; - private final String apiKey; + private final Storage storage; + private final String bucketName; private final PdfUtils pdfUtils; private final MeterRegistry registry; private final Timer uploadTimer; private final Counter fileRequestNew; private final Counter fileRequestRepeat; - // Gemini 파일 업로드 Future 캐시 (CloudFront URL → CompletableFuture) - // TTL 47시간: Gemini Files API 48시간 자동 삭제 전 안전 마진 - // 업로드 진행 중인 Future도 저장되므로 중복 업로드를 방지한다 + // GCS 업로드 Future 캐시 (CDN URL → CompletableFuture) + // TTL 47시간: GCS 수명주기 정책(1일)과 정합 private final Cache> uploadFutureCache = Caffeine.newBuilder().maximumSize(1_000).expireAfterWrite(Duration.ofHours(47)).build(); @@ -57,26 +48,23 @@ public class GeminiFileServiceImpl implements GeminiFileService { ConcurrentHashMap.newKeySet(); public GeminiFileServiceImpl( - @Qualifier("geminiFileRestClient") RestClient restClient, - GoogleGenAiConnectionProperties properties, - PdfUtils pdfUtils, - MeterRegistry registry) { - this.restClient = restClient; - this.apiKey = properties.getApiKey(); + Storage storage, QAskerAiProperties aiProperties, PdfUtils pdfUtils, MeterRegistry registry) { + this.storage = storage; + this.bucketName = aiProperties.getGcs().getBucketName(); this.pdfUtils = pdfUtils; this.registry = registry; this.uploadTimer = - Timer.builder("file.upload.gemini.duration") - .description("Gemini 파일 업로드 소요 시간") + Timer.builder("file.upload.gcs.duration") + .description("GCS 파일 업로드 소요 시간") .register(registry); this.fileRequestNew = - Counter.builder("gemini.file.request") + Counter.builder("gcs.file.request") .tag("type", "new") .description("새로운 파일로 퀴즈 생성 요청 수") .register(registry); this.fileRequestRepeat = - Counter.builder("gemini.file.request") + Counter.builder("gcs.file.request") .tag("type", "repeat") .description("같은 파일로 퀴즈 재생성 요청 수") .register(registry); @@ -90,20 +78,14 @@ public FileMetadata uploadPdf(String pdfUrl) { tempFile = pdfUtils.downloadToTemp(pdfUrl); FileMetadata metadata = doUpload(tempFile, extractFileName(pdfUrl)); - // 완료된 Future로 캐시에 저장 uploadFutureCache.put(pdfUrl, CompletableFuture.completedFuture(metadata)); return metadata; } catch (java.io.FileNotFoundException e) { - // S3/CloudFront URL이 404인 경우 — AI 인프라 문제가 아닌 클라이언트 오류 throw new CustomException( ExceptionMessage.INVALID_URL_REQUEST, "PDF 파일을 찾을 수 없습니다: " + e.getMessage(), e); } catch (IOException e) { throw new CustomException( ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR, "PDF 업로드 중 I/O 오류", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new CustomException( - ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR, "PDF 처리 대기 중 인터럽트 발생", e); } finally { pdfUtils.deleteTempFile(tempFile); } @@ -116,149 +98,93 @@ public FileMetadata uploadPdfFromFile(Path pdfFile) { } catch (IOException e) { throw new CustomException( ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR, "PDF 업로드 중 I/O 오류", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new CustomException( - ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR, "PDF 처리 대기 중 인터럽트 발생", e); } } @Override - public void cacheUploadFuture(String cloudFrontUrl, CompletableFuture future) { - uploadFutureCache.put(cloudFrontUrl, future); - log.info("Gemini 업로드 Future 캐시 저장: url={}", cloudFrontUrl); + public void cacheUploadFuture(String cdnUrl, CompletableFuture future) { + uploadFutureCache.put(cdnUrl, future); + log.info("GCS 업로드 Future 캐시 저장: url={}", cdnUrl); } @Override - public Optional awaitCachedFileMetadata(String cloudFrontUrl) { - // 같은 파일 URL 재요청 여부 추적 - boolean isNew = seenFileUrls.add(cloudFrontUrl); + public Optional awaitCachedFileMetadata(String cdnUrl) { + boolean isNew = seenFileUrls.add(cdnUrl); if (isNew) { fileRequestNew.increment(); } else { fileRequestRepeat.increment(); } - CompletableFuture future = uploadFutureCache.getIfPresent(cloudFrontUrl); + CompletableFuture future = uploadFutureCache.getIfPresent(cdnUrl); if (future == null) { return Optional.empty(); } try { FileMetadata metadata = future.join(); - log.info("Gemini 파일 캐시 히트: url={}, name={}", cloudFrontUrl, metadata.name()); + log.info("GCS 파일 캐시 히트: url={}, name={}", cdnUrl, metadata.name()); return Optional.of(metadata); } catch (CompletionException e) { - uploadFutureCache.invalidate(cloudFrontUrl); - log.warn("캐시된 Gemini 업로드 실패, 캐시 제거: url={}, error={}", cloudFrontUrl, e.getMessage()); + uploadFutureCache.invalidate(cdnUrl); + log.warn("캐시된 GCS 업로드 실패, 캐시 제거: url={}, error={}", cdnUrl, e.getMessage()); return Optional.empty(); } } - private FileMetadata doUpload(Path pdfFile, String displayName) - throws IOException, InterruptedException { + private FileMetadata doUpload(Path pdfFile, String displayName) throws IOException { Timer.Sample sample = Timer.start(); - long fileSize = Files.size(pdfFile); - String uploadSessionUrl = initiateUpload(fileSize, displayName); + String blobName = UUID.randomUUID() + "/" + displayName; + byte[] pdfBytes = Files.readAllBytes(pdfFile); - GeminiFileUploadResponse response = uploadBytes(uploadSessionUrl, pdfFile, fileSize); + BlobInfo blobInfo = + BlobInfo.newBuilder(bucketName, blobName).setContentType("application/pdf").build(); + storage.create(blobInfo, pdfBytes); - String fileName = response.file().name(); + String gcsUri = "gs://" + bucketName + "/" + blobName; - log.info("PDF 업로드 완료: name={}, state={}", fileName, response.file().state()); - - FileMetadata metadata = waitForProcessing(fileName); sample.stop(uploadTimer); - log.info("파일 처리 완료: name={}, uri={}", metadata.name(), metadata.uri()); - return metadata; + log.info( + "GCS PDF 업로드 완료: bucket={}, blob={}, size={}bytes", bucketName, blobName, pdfBytes.length); + + return new FileMetadata( + blobName, + displayName, + "application/pdf", + String.valueOf(pdfBytes.length), + null, + null, + "ACTIVE", + gcsUri); } @Override public void deleteFile(String fileName) { try { - restClient - .delete() - .uri("/v1beta/" + fileName + "?key={key}", apiKey) - .retrieve() - .toBodilessEntity(); - - log.info("Gemini 파일 삭제 완료: name={}", fileName); + boolean deleted = storage.delete(BlobId.of(bucketName, fileName)); + if (deleted) { + log.info("GCS 파일 삭제 완료: name={}", fileName); + } else { + log.warn("GCS 파일 삭제 대상 없음: name={}", fileName); + } } catch (Exception e) { - log.warn("Gemini 파일 삭제 실패 (무시): name={}, error={}", fileName, e.getMessage()); + log.warn("GCS 파일 삭제 실패 (무시): name={}, error={}", fileName, e.getMessage()); } } - private String initiateUpload(long fileSize, String displayName) { - Map> requestBody = - Map.of("file", Map.of("display_name", displayName)); - - // exchange()를 사용하는 이유: 응답 헤더에서 업로드 세션 URL을 추출해야 하기 때문 - // retrieve()는 body만 반환하므로 헤더 접근 불가 - return restClient - .post() - .uri("/upload/v1beta/files?key={key}", apiKey) - .header("X-Goog-Upload-Protocol", "resumable") - .header("X-Goog-Upload-Command", "start") - .header("X-Goog-Upload-Header-Content-Type", "application/pdf") - .header("X-Goog-Upload-Header-Content-Length", String.valueOf(fileSize)) - .contentType(MediaType.APPLICATION_JSON) - .exchange( - (req, res) -> { - if (!res.getStatusCode().is2xxSuccessful()) { - throw new CustomException(ExceptionMessage.AI_SERVER_RESPONSE_ERROR); - } - String url = res.getHeaders().getFirst("x-goog-upload-url"); - if (url == null || url.isBlank()) { - throw new CustomException(ExceptionMessage.AI_SERVER_RESPONSE_ERROR); - } - return url; - }); - } - - private GeminiFileUploadResponse uploadBytes( - String uploadSessionUrl, Path pdfFile, long fileSize) { - return restClient - .post() - .uri(URI.create(uploadSessionUrl)) - .header("X-Goog-Upload-Command", "upload, finalize") - .header("X-Goog-Upload-Offset", "0") - .header("Content-Length", String.valueOf(fileSize)) - .body(new FileSystemResource(pdfFile)) - .retrieve() - .body(GeminiFileUploadResponse.class); - } - @Override public FileMetadata waitForProcessing(String fileName) throws InterruptedException { - for (int attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) { - FileMetadata metadata = getFile(fileName); - String state = metadata.state(); - - log.debug("파일 상태 폴링 [{}/{}]: name={}, state={}", attempt, MAX_POLL_ATTEMPTS, fileName, state); - - if (STATE_ACTIVE.equals(state)) { - return metadata; - } - if (STATE_FAILED.equals(state)) { - throw new CustomException( - ExceptionMessage.AI_SERVER_RESPONSE_ERROR, "Gemini 파일 처리 실패: name=" + fileName); - } - Thread.sleep(POLL_INTERVAL_MS); - } - - throw new CustomException( - ExceptionMessage.AI_SERVER_TIMEOUT, - "파일 처리 타임아웃: name=%s, %dms * %d attempts" - .formatted(fileName, POLL_INTERVAL_MS, MAX_POLL_ATTEMPTS)); - } - - private FileMetadata getFile(String fileName) { - return restClient - .get() - .uri("/v1beta/" + fileName + "?key={key}", apiKey) - .retrieve() - .body(FileMetadata.class); + // GCS는 업로드 즉시 사용 가능하므로 폴링 불필요 + return new FileMetadata( + fileName, + null, + "application/pdf", + null, + null, + null, + "ACTIVE", + "gs://" + bucketName + "/" + fileName); } private String extractFileName(String url) { diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/QuizOrchestrationServiceImpl.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/QuizOrchestrationServiceImpl.java index c3a04e02..09953373 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/QuizOrchestrationServiceImpl.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/QuizOrchestrationServiceImpl.java @@ -86,7 +86,6 @@ public int generateQuiz(GenerationRequestToAI request) { request.referencePages(), request.quizCount(), maxChunkCount); log.info("청크 분할 완료: {}개 청크 (maxChunkCount={})", chunks.size(), maxChunkCount); - AtomicInteger numberCounter = new AtomicInteger(1); AtomicInteger remainingQuota = new AtomicInteger(request.quizCount()); try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { @@ -154,10 +153,9 @@ public int generateQuiz(GenerationRequestToAI request) { validated = validated.subList(0, claimed); } - // 문제+해설 원본 데이터 전송 (번호는 이미 확보된 슬롯 기반) + // 문제+해설 원본 데이터 전송 (번호는 소비자 측에서 할당) AIProblemSet result = - GeminiQuestionMapper.toDto( - validated, chunk.referencedPages(), numberCounter); + GeminiQuestionMapper.toDto(validated, chunk.referencedPages()); request.questionsConsumer().accept(result); // 첫 번째/마지막 퀴즈 응답 시각 기록 @@ -177,7 +175,7 @@ public int generateQuiz(GenerationRequestToAI request) { CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); } - log.info("전체 병렬 생성 완료: 총 {}번까지 번호 할당됨", numberCounter.get() - 1); + log.info("전체 병렬 생성 완료"); // 요청 단위 응답 시간 메트릭 기록 (A/B 테스트 태그 포함) Long firstNanos = firstQuizNanos.get() == 0 ? null : firstQuizNanos.get(); diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/util/PdfUtils.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/util/PdfUtils.java index 15f5494e..f65e29ac 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/util/PdfUtils.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/util/PdfUtils.java @@ -19,7 +19,7 @@ public class PdfUtils { private final FileValidateService fileValidateService; public Path downloadToTemp(String pdfUrl) throws IOException { - fileValidateService.checkCloudFrontUrlWithThrowing(pdfUrl); + fileValidateService.checkCdnUrlWithThrowing(pdfUrl); Path tempFile = Files.createTempFile("gemini-upload-", ".pdf"); log.debug("PDF 다운로드 시작: {} -> {}", pdfUrl, tempFile); diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAIServerAdapter.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAIServerAdapter.java index 3b2b15e2..cbec126e 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAIServerAdapter.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAIServerAdapter.java @@ -39,7 +39,6 @@ public int streamRequest(GenerationRequestToAI request) { for (int i = range[0]; i <= range[1]; i++) { problems.add( new AIProblem( - i, "Mock question " + i, "Mock explanation for question " + i, List.of( diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/mapper/AIProblemSetMapper.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/mapper/AIProblemSetMapper.java index a8532491..cb54c47a 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/mapper/AIProblemSetMapper.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/mapper/AIProblemSetMapper.java @@ -24,7 +24,6 @@ public static ProblemSetGeneratedEvent toEvent(AIProblemSet source) { private static QuizGeneratedFromAI toQuizGeneratedFromAI(AIProblem problem) { QuizGeneratedFromAI quiz = new QuizGeneratedFromAI(); - quiz.setNumber(problem.number()); quiz.setTitle(problem.content()); quiz.setQuizExplanation(problem.quizExplanation()); quiz.setReferencedPages(problem.referencedPages()); diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/generation/GenerationCommandServiceImpl.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/generation/GenerationCommandServiceImpl.java index 81dff53a..3bec273f 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/generation/GenerationCommandServiceImpl.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/generation/GenerationCommandServiceImpl.java @@ -27,6 +27,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -81,6 +82,8 @@ private void processAsyncGeneration( String sessionId, Long problemSetId, GenerationRequest request) { AtomicInteger atomicGeneratedCount = new AtomicInteger(0); + AtomicInteger numberCounter = new AtomicInteger(1); + ReentrantLock consumerLock = new ReentrantLock(); GenerationRequestToAI requestToAI = GenerationRequestToAI.builder() @@ -91,59 +94,69 @@ private void processAsyncGeneration( .referencePages(request.pageNumbers()) .questionsConsumer( aiProblemSet -> { - // 1. 전송용 DTO로 변환 - ProblemSetGeneratedEvent problemSet = AIProblemSetMapper.toEvent(aiProblemSet); - if (CollectionUtils.isEmpty(problemSet.getQuiz())) { - log.warn("빈 배치 수신, 건너뜀: sessionId={}", sessionId); - return; - } + consumerLock.lock(); + try { + // 1. 전송용 DTO로 변환 + ProblemSetGeneratedEvent problemSet = AIProblemSetMapper.toEvent(aiProblemSet); + if (CollectionUtils.isEmpty(problemSet.getQuiz())) { + log.warn("빈 배치 수신, 건너뜀: sessionId={}", sessionId); + return; + } - // 2. 선택지 셔플 - QuizType quizType = request.quizType(); - if (quizType == QuizType.MULTIPLE || quizType == QuizType.BLANK) { - for (var quiz : problemSet.getQuiz()) { - if (!CollectionUtils.isEmpty(quiz.getSelections())) { - List shuffled = - new ArrayList<>(quiz.getSelections()); - Collections.shuffle(shuffled); - quiz.setSelections(shuffled); + // 2. 순서 보장 번호 할당 + for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { + quiz.setNumber(numberCounter.getAndIncrement()); + } + + // 3. 선택지 셔플 + QuizType quizType = request.quizType(); + if (quizType == QuizType.MULTIPLE || quizType == QuizType.BLANK) { + for (var quiz : problemSet.getQuiz()) { + if (!CollectionUtils.isEmpty(quiz.getSelections())) { + List + shuffled = new ArrayList<>(quiz.getSelections()); + Collections.shuffle(shuffled); + quiz.setSelections(shuffled); + } } } - } - // 3. 마크다운 포맷팅 - for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { - quiz.setExplanation(ExplanationMarkdownBuilder.build(quiz, request.language())); - } + // 4. 마크다운 포맷팅 + for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { + quiz.setExplanation( + ExplanationMarkdownBuilder.build(quiz, request.language())); + } - // 4. 데이터베이스에 영속화 - List savedNumbers = - quizCommandService.saveBatch(problemSet.getQuiz(), problemSetId); + // 5. 데이터베이스에 영속화 + List savedNumbers = + quizCommandService.saveBatch(problemSet.getQuiz(), problemSetId); - // 5. 데이터베이스에 영속화 - List quizViews = - quizQueryService.getQuizViews(problemSetId, savedNumbers); - if (quizViews.isEmpty()) { - return; - } + // 6. SSE 전송 + List quizViews = + quizQueryService.getQuizViews(problemSetId, savedNumbers); + if (quizViews.isEmpty()) { + return; + } - // - List quizForFeList = - quizViews.stream().map(QuizViewToQuizForFeMapper::toQuizForFe).toList(); - - notificationService.sendCreatedMessageWithId( - sessionId, - String.valueOf(quizViews.getLast().getNumber()), - new ProblemSetResponse( - sessionId, - hashUtil.encode(problemSetId), - request.title(), - GENERATING, - request.quizType(), - request.quizCount(), - quizForFeList)); - - atomicGeneratedCount.addAndGet(quizViews.size()); + List quizForFeList = + quizViews.stream().map(QuizViewToQuizForFeMapper::toQuizForFe).toList(); + + notificationService.sendCreatedMessageWithId( + sessionId, + String.valueOf(quizViews.getLast().getNumber()), + new ProblemSetResponse( + sessionId, + hashUtil.encode(problemSetId), + request.title(), + GENERATING, + request.quizType(), + request.quizCount(), + quizForFeList)); + + atomicGeneratedCount.addAndGet(quizViews.size()); + } finally { + consumerLock.unlock(); + } }) .build(); diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/upload/FileUploadService.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/upload/FileUploadService.java index fe8d199d..63dd5a59 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/upload/FileUploadService.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quiz/service/upload/FileUploadService.java @@ -42,7 +42,7 @@ void eagerRegisterMetrics() { } } - /** 파일(PDF, PPT, DOCX)을 PDF로 변환 후 S3와 Gemini에 동시 업로드한다. */ + /** 파일(PDF, PPT, DOCX)을 PDF로 변환 후 OCI와 Gemini에 동시 업로드한다. */ public FileUploadResponse upload(MultipartFile file) { String originalFileName = file.getOriginalFilename(); @@ -82,8 +82,8 @@ public FileUploadResponse upload(MultipartFile file) { Files.createTempFile("gemini-upload-", ".pdf"), StandardCopyOption.REPLACE_EXISTING); - // 4. S3 + Gemini 동시 시작 - CompletableFuture s3Future = + // 4. OCI + Gemini 동시 시작 + CompletableFuture ociFuture = CompletableFuture.supplyAsync( () -> objectStorageService.uploadPdf(finalPdfFile, originalFileName)); @@ -100,14 +100,14 @@ public FileUploadResponse upload(MultipartFile file) { } }); - // S3 업로드는 필수 — 실패 시 예외 발생 - String cloudFrontUrl = s3Future.join(); + // OCI 업로드는 필수 — 실패 시 예외 발생 + String cdnUrl = ociFuture.join(); // Gemini Future를 캐시에 즉시 저장 — 퀴즈 생성 시 awaitCachedFileMetadata()로 대기/조회 - geminiFileService.cacheUploadFuture(cloudFrontUrl, geminiFuture); + geminiFileService.cacheUploadFuture(cdnUrl, geminiFuture); - log.info("S3 업로드 완료, Gemini는 백그라운드 처리 중: {}", cloudFrontUrl); - return new FileUploadResponse(cloudFrontUrl); + log.info("OCI 업로드 완료, Gemini는 백그라운드 처리 중: {}", cdnUrl); + return new FileUploadResponse(cdnUrl); } catch (Exception e) { throw new CustomException( ExceptionMessage.DEFAULT_ERROR, "파일 업로드 실패: " + originalFileName, e);