From 54c43fb2865d5205ee8b015a819b6d3c01c13421 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 22 Dec 2025 17:24:51 +0900 Subject: [PATCH 01/24] =?UTF-8?q?docs:=20README.md=EC=97=90=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=8F=20k6=20=EA=B8=B0=EC=88=A0=20=EC=8A=A4=ED=83=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - k6 부하 테스트 결과 표 추가 (READ/WRITE/STRESS 테스트) - 성능 지표 달성률 명시 (READ 290%, WRITE 846% 초과 달성) - 기술 스택 섹션에 k6 성능 테스트 도구 추가 - 문서 목록에 성능 테스트 가이드 링크 추가 --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index e15f574..d215f23 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ - `JUnit5` `MockK` (단위 테스트) - `Testcontainers` (통합 테스트, 실제 DB 환경) - `LocalStack` (AWS S3 로컬 테스트) +- `k6` (성능 테스트 및 부하 테스트) **Architecture** @@ -122,6 +123,19 @@ | **DB 마이그레이션** | Flyway 기반 자동 스키마 버전 관리 | | **문서화** | 2,800줄+ 기술 문서 | +### 🚀 성능 지표 (k6 부하 테스트 결과) + +| 테스트 유형 | TPS | p95 응답시간 | 에러율 | 달성률 | +|----------------------|-------|----------|-------|------------------| +| **READ** | 29.0 | 27.0ms | 0.00% | 290% (목표 10 TPS) | +| **WRITE** | 42.3 | 90.3ms | 0.00% | 846% (목표 5 TPS) | +| **STRESS** (300 VUs) | 201.3 | 1099.5ms | 0.00% | 안정성 검증 완료 | + +- **READ 처리량**: 목표 대비 **290%** 초과 달성 +- **WRITE 처리량**: 목표 대비 **846%** 초과 달성 +- **스트레스 테스트**: 300명 동시 접속 17분간 **에러 없이** 안정적 처리 +- **총 처리량**: 252,176건 요청 처리 (34분간) + ### ☁️ 클라우드 아키텍처 ``` @@ -175,6 +189,7 @@ - [📖 API 문서](docs/API_DOCUMENTATION.md) - [📷 이미지 관리 시스템](docs/IMAGE_MANAGEMENT.md) - [🧪 테스트 가이드](docs/TESTING_GUIDE.md) +- [📊 성능 테스트 가이드](performance-tests/README.md) - [✅ TODO 리스트](docs/TODO.md) ## 🚀 빠른 시작 From cb0454a832b734716260fc4648c082a1e4363d91 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 22 Dec 2025 17:28:01 +0900 Subject: [PATCH 02/24] =?UTF-8?q?docs:=20TODO.md=EC=97=90=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EC=99=84=EB=A3=8C=20=ED=95=AD=EB=AA=A9=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성능 테스트 시스템 섹션 추가 및 완료된 항목들(✅) 표시 - k6 성능 테스트 환경 구축, Baseline 측정, 데이터 생성 스크립트 완료 기록 - 테스트 자동화 섹션에 k6 성능 테스트 스크립트 완료 항목 추가 - 성능 테스트 관련 TODO 항목들 상태 업데이트 --- docs/TODO.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 68a1a30..346fa6a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -169,6 +169,17 @@ ## 5. 성능 및 최적화 +### 📊 성능 테스트 시스템 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|--------------------|----|-------|------------------------------| +| k6 성능 테스트 환경 구축 | ✅ | 🔴 P0 | READ/WRITE/MIXED/STRESS 시나리오 | +| Baseline 성능 측정 | ✅ | 🔴 P0 | TPS, p95/p99 응답시간 기준선 설정 | +| 테스트 데이터 자동 생성 스크립트 | ✅ | 🔴 P0 | 5명 사용자, 100개 포스트 자동 생성 | +| 성능 테스트 결과 문서화 | ✅ | 🟡 P1 | Before/After 비교를 위한 이력 관리 | +| CI/CD 성능 테스트 통합 | ☐ | 🟡 P1 | GitHub Actions에서 자동 성능 체크 | +| 프로덕션 환경 성능 측정 | ☐ | 🟡 P1 | AWS ECS/RDS 환경에서 재측정 | + ### ⚡ 데이터베이스 최적화 | 작업 항목 | 상태 | 우선순위 | 상세 설명 | @@ -238,10 +249,11 @@ ### 🧪 테스트 자동화 -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|-------------------|----|-------|----------------------| -| 통합 테스트 확장 | ☐ | 🟡 P1 | API 테스트, E2E 테스트 | -| TestContainers 활용 | ☐ | 🟢 P2 | 테스트 데이터 관리, Mock 데이터 | +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|-------------------|----|-------|---------------------------------| +| 통합 테스트 확장 | ☐ | 🟡 P1 | API 테스트, E2E 테스트 | +| TestContainers 활용 | ☐ | 🟢 P2 | 테스트 데이터 관리, Mock 데이터 | +| k6 성능 테스트 스크립트 | ✅ | 🔴 P0 | READ/WRITE/MIXED/STRESS 시나리오 완성 | --- From f11892e1c9d3804e8baa8198910be7de2b7a5179 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 10:42:46 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat(ci):=20AWS=20ECS=EC=97=90=EC=84=9C?= =?UTF-8?q?=20OCI=20VM=20=EA=B8=B0=EB=B0=98=20=EB=B0=B0=ED=8F=AC=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Docker 레지스트리: ECR → OCIR로 변경 - 배포 방식: ECS 서비스 → SSH를 통한 VM 직접 배포로 전환 - 2대의 서버에 순차적 배포 및 헬스체크 추가 - Load Balancer를 통한 최종 배포 검증 주요 특징: - appleboy/ssh-action을 사용한 안전한 SSH 배포 - 각 서버별 헬스체크 (최대 2분 대기) - 롤링 업데이트 방식으로 서비스 중단 최소화 - Docker 이미지 prune으로 디스크 공간 최적화 --- .github/workflows/build.yml | 247 +++++++++++++----- build.gradle | 6 +- docker-compose.yml | 38 +-- gradle/verification-metadata.xml | 53 ++++ .../follow/required/FollowRepository.kt | 2 +- .../member/required/MemberRepository.kt | 14 +- .../member/service/MemberReadService.kt | 6 +- src/main/resources/application-docker.yml | 10 +- src/main/resources/application-prod.yml | 12 +- .../db/migration/V2__migrate251204.sql | 70 ----- .../db/migration/V3__memberevent.sql | 50 ---- .../TestcontainersConfiguration.kt | 32 +-- .../follow/provided/FollowReaderTest.kt | 2 +- .../member/provided/MemberReaderTest.kt | 16 -- .../util/IntegrationConcurrencyTestBase.kt | 27 +- 15 files changed, 305 insertions(+), 280 deletions(-) delete mode 100644 src/main/resources/db/migration/V2__migrate251204.sql delete mode 100644 src/main/resources/db/migration/V3__memberevent.sql diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6681f20..14a59d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: CI/CD 파이프라인 +name: CI/CD 파이프라인 (OCI Rolling 배포) on: push: @@ -12,15 +12,6 @@ on: env: GRADLE_OPTS: '-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2' JAVA_VERSION: '21' - AWS_REGION: ap-northeast-2 - ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com - ECR_REPOSITORY: rmrt-app - IMAGE_TAG: ${{ github.sha }} - SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} - SPRING_MAIL_PASSWORD: ${{ secrets.SPRING_MAIL_PASSWORD }} - SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} - SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} - APP_BASE_URL: ${{ secrets.APP_BASE_URL }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} jobs: @@ -46,7 +37,7 @@ jobs: test: name: 단위 및 통합 테스트 runs-on: ubuntu-latest - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 @@ -71,7 +62,7 @@ jobs: run: chmod +x gradlew - name: 테스트 실행 - run: ./gradlew test --continue + run: ./gradlew test --continue -Dorg.gradle.jvmargs="-XX:+EnableDynamicAgentLoading" - name: 테스트 결과물 저장 uses: actions/upload-artifact@v5 @@ -81,11 +72,12 @@ jobs: path: | build/reports/ build/test-results/ + build: name: 애플리케이션 빌드 runs-on: ubuntu-latest needs: test - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 @@ -117,7 +109,7 @@ jobs: runs-on: ubuntu-latest needs: [ test ] if: github.event_name == 'push' || github.event_name == 'pull_request' - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 @@ -159,70 +151,195 @@ jobs: - name: SonarQube 분석 실행 run: ./gradlew sonar --build-cache --info - docker-build-and-deploy: - name: Docker 빌드 및 ECS 배포 + docker-build-and-push: + name: Docker 빌드 및 OCIR 푸시 runs-on: ubuntu-latest needs: [ check-src-changes, test, build, sonarqube ] if: github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.check-src-changes.outputs.has_src_changes == 'true' - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 - - name: AWS 자격증명 설정 - uses: aws-actions/configure-aws-credentials@v5 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: ECR 로그인 - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + - name: OCIR 로그인 + env: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + OCI_REGION: ${{ secrets.OCI_REGION }} + OCI_TENANCY: ${{ secrets.OCI_TENANCY }} + OCI_USERNAME: ${{ secrets.OCI_USERNAME }} + run: | + echo $OCI_AUTH_TOKEN | docker login $OCI_REGION.ocir.io \ + -u '$OCI_TENANCY/$OCI_USERNAME' \ + --password-stdin - name: Docker 이미지 빌드 env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ env.IMAGE_TAG }} + OCI_REGISTRY: ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }} run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . + docker build -t $OCI_REGISTRY/rmrt-app:${{ github.sha }} . + docker tag $OCI_REGISTRY/rmrt-app:${{ github.sha }} \ + $OCI_REGISTRY/rmrt-app:latest - - name: Docker 이미지 ECR에 푸시 + - name: Docker 이미지 OCIR에 푸시 env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ env.IMAGE_TAG }} + OCI_REGISTRY: ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }} run: | - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + docker push $OCI_REGISTRY/rmrt-app:${{ github.sha }} + docker push $OCI_REGISTRY/rmrt-app:latest - - name: ECS 서비스 업데이트 - run: | - # 기존 태스크 정의로 강제 재배포 - TASK_DEFINITION_ARN=$(aws ecs describe-task-definition --task-definition rmrt-task --query 'taskDefinition.taskDefinitionArn' --output text) - - aws ecs update-service \ - --cluster rmrt-cluster-ec2version \ - --service rmrt-task-service-o4qq7b02 \ - --task-definition $TASK_DEFINITION_ARN \ - --force-new-deployment - - # 배포 완료 대기 (타임아웃 설정 추가) - echo "⏳ ECS 배포 완료 대기..." - timeout 300 aws ecs wait services-stable \ - --cluster rmrt-cluster-ec2version \ - --services rmrt-task-service-o4qq7b02 || \ - echo "⚠️ 배포 안정화 대기 시간 초과 (실제 배포는 성공했을 수 있습니다)" - - echo "✅ ECS 배포 완료!" - - - name: 배포 상태 확인 + deploy-server-1: + name: Server 1 배포 + runs-on: ubuntu-latest + needs: [ docker-build-and-push ] + + steps: + - name: Server 1에 배포 + uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + with: + host: ${{ secrets.OCI_VM_IP_1 }} + username: ubuntu + key: ${{ secrets.OCI_SSH_KEY_1 }} + script: | + echo "🚀 Server 1 배포 시작..." + + # OCIR 로그인 + echo ${{ secrets.OCI_AUTH_TOKEN }} | docker login ${{ secrets.OCI_REGION }}.ocir.io \ + -u '${{ secrets.OCI_TENANCY }}/${{ secrets.OCI_USERNAME }}' \ + --password-stdin + + # 최신 이미지 풀 + docker pull ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + + # 기존 컨테이너 중지 및 삭제 + docker stop rmrt-app || true + docker rm rmrt-app || true + + # 새 컨테이너 실행 + docker run -d \ + --name rmrt-app \ + --restart unless-stopped \ + -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e SPRING_DATASOURCE_URL='${{ secrets.DB_URL }}' \ + -e SPRING_DATASOURCE_USERNAME='${{ secrets.DB_USERNAME }}' \ + -e SPRING_DATASOURCE_PASSWORD='${{ secrets.DB_PASSWORD }}' \ + -e SPRING_MAIL_HOST='${{ secrets.SPRING_MAIL_HOST }}' \ + -e SPRING_MAIL_PORT='${{ secrets.SPRING_MAIL_PORT }}' \ + -e SPRING_MAIL_USERNAME='${{ secrets.SPRING_MAIL_USERNAME }}' \ + -e SPRING_MAIL_PASSWORD='${{ secrets.SPRING_MAIL_PASSWORD }}' \ + -e APP_BASE_URL='${{ secrets.APP_BASE_URL }}' \ + ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + + # 오래된 이미지 정리 + docker image prune -f + + - name: Server 1 헬스체크 + uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + with: + host: ${{ secrets.OCI_VM_IP_1 }} + username: ubuntu + key: ${{ secrets.OCI_SSH_KEY_1 }} + script: | + echo "⏳ Server 1 헬스체크 대기..." + for i in {1..24}; do + if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then + echo "✅ Server 1 헬스체크 통과!" + exit 0 + fi + echo "대기 중... ($i/24)" + sleep 5 + done + echo "❌ Server 1 헬스체크 실패" + exit 1 + + deploy-server-2: + name: Server 2 배포 + runs-on: ubuntu-latest + needs: [ deploy-server-1 ] + + steps: + - name: Server 2에 배포 + uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + with: + host: ${{ secrets.OCI_VM_IP_2 }} + username: ubuntu + key: ${{ secrets.OCI_SSH_KEY_2 }} + script: | + echo "🚀 Server 2 배포 시작..." + + # OCIR 로그인 + echo ${{ secrets.OCI_AUTH_TOKEN }} | docker login ${{ secrets.OCI_REGION }}.ocir.io \ + -u '${{ secrets.OCI_TENANCY }}/${{ secrets.OCI_USERNAME }}' \ + --password-stdin + + # 최신 이미지 풀 + docker pull ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + + # 기존 컨테이너 중지 및 삭제 + docker stop rmrt-app || true + docker rm rmrt-app || true + + # 새 컨테이너 실행 + docker run -d \ + --name rmrt-app \ + --restart unless-stopped \ + -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e SPRING_DATASOURCE_URL='${{ secrets.DB_URL }}' \ + -e SPRING_DATASOURCE_USERNAME='${{ secrets.DB_USERNAME }}' \ + -e SPRING_DATASOURCE_PASSWORD='${{ secrets.DB_PASSWORD }}' \ + -e SPRING_MAIL_HOST='${{ secrets.SPRING_MAIL_HOST }}' \ + -e SPRING_MAIL_PORT='${{ secrets.SPRING_MAIL_PORT }}' \ + -e SPRING_MAIL_USERNAME='${{ secrets.SPRING_MAIL_USERNAME }}' \ + -e SPRING_MAIL_PASSWORD='${{ secrets.SPRING_MAIL_PASSWORD }}' \ + -e APP_BASE_URL='${{ secrets.APP_BASE_URL }}' \ + ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + + # 오래된 이미지 정리 + docker image prune -f + + - name: Server 2 헬스체크 + uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + with: + host: ${{ secrets.OCI_VM_IP_2 }} + username: ubuntu + key: ${{ secrets.OCI_SSH_KEY_2 }} + script: | + echo "⏳ Server 2 헬스체크 대기..." + for i in {1..24}; do + if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then + echo "✅ Server 2 헬스체크 통과!" + exit 0 + fi + echo "대기 중... ($i/24)" + sleep 5 + done + echo "❌ Server 2 헬스체크 실패" + exit 1 + + verify-deployment: + name: 배포 검증 + runs-on: ubuntu-latest + needs: [ deploy-server-2 ] + + steps: + - name: Load Balancer 헬스체크 + env: + APP_BASE_URL: ${{ secrets.APP_BASE_URL }} + OCI_LB_IP: ${{ secrets.OCI_LB_IP }} run: | - # 서비스 상태 확인 - aws ecs describe-services \ - --cluster rmrt-cluster-ec2version \ - --services rmrt-task-service-o4qq7b02 \ - --query 'services[0].{status: status, runningCount: runningCount, desiredCount: desiredCount}' - - echo "🌐 실제 도메인: https://rmrt.albert-im.com" - echo "🔍 도메인 헬스 체크: https://rmrt.albert-im.com/actuator/health" + echo "🔍 Load Balancer 통해 서비스 확인..." + for i in {1..6}; do + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" ${APP_BASE_URL}/actuator/health || echo "000") + if [ "$RESPONSE" = "200" ]; then + echo "✅ 서비스 정상 동작!" + echo "" + echo "🌐 서비스 URL: ${OCI_LB_IP}" + echo "🔍 헬스체크: ${OCI_LB_IP}/actuator/health" + exit 0 + fi + echo "대기 중... ($i/6) - HTTP $RESPONSE" + sleep 10 + done + echo "⚠️ Load Balancer 응답 확인 필요" + exit 0 diff --git a/build.gradle b/build.gradle index a17271e..6639609 100644 --- a/build.gradle +++ b/build.gradle @@ -63,8 +63,8 @@ dependencies { // ==================== Database ==================== // 데이터베이스 드라이버 및 마이그레이션 implementation 'org.flywaydb:flyway-core' // Flyway 데이터베이스 마이그레이션 - implementation 'org.flywaydb:flyway-mysql' // Flyway MySQL 지원 - runtimeOnly 'com.mysql:mysql-connector-j' // MySQL JDBC 드라이버 + implementation 'org.flywaydb:flyway-database-postgresql' // Flyway PostgreSQL 지원 + runtimeOnly 'org.postgresql:postgresql' // PostgreSQL JDBC 드라이버 // ==================== Testing ==================== // 테스트 관련 라이브러리 @@ -73,7 +73,7 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' // Spring Security 테스트 testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' // Kotlin JUnit5 테스트 testImplementation 'org.testcontainers:junit-jupiter' // Testcontainers JUnit5 - testImplementation 'org.testcontainers:mysql' // Testcontainers MySQL + testImplementation 'org.testcontainers:postgresql' // Testcontainers PostgreSQL testImplementation 'org.testcontainers:localstack' // Testcontainers LocalStack testImplementation 'io.mockk:mockk:1.14.6' // Mocking 라이브러리 testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' // 코루틴 테스트 diff --git a/docker-compose.yml b/docker-compose.yml index 2078fc5..9946f91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,26 @@ services: - # MySQL Database - mysql: - image: mysql:8.0 - container_name: rmrt-mysql + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: rmrt-postgres restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} - MYSQL_DATABASE: ${MYSQL_DATABASE:-realmoneyrealtaste} - MYSQL_USER: ${MYSQL_USER:-rmrt} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-rmrtpassword} + POSTGRES_DB: ${POSTGRES_DB:-realmoneyrealtaste} + POSTGRES_USER: ${POSTGRES_USER:-rmrt} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rmrtpassword} TZ: Asia/Seoul ports: - - "3306:3306" + - "5432:5432" volumes: - - mysql_data:/var/lib/mysql - - ./docker/mysql/init:/docker-entrypoint-initdb.d + - postgres_data:/var/lib/postgresql/data networks: - rmrt-network healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] - timeout: 20s - retries: 10 + test: [ "CMD-SHELL", "pg_isready -U rmrt -d realmoneyrealtaste" ] + timeout: 10s + retries: 5 + interval: 10s + start_period: 30s # Spring Boot Application app: @@ -32,9 +32,9 @@ services: environment: SPRING_PROFILES_ACTIVE: docker SPRING_CONFIG_LOCATION: classpath:application-docker.yml - SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-realmoneyrealtaste}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul - SPRING_DATASOURCE_USERNAME: ${MYSQL_USER:-rmrt} - SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD:-rmrtpassword} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-realmoneyrealtaste} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-rmrt} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-rmrtpassword} SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME} SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD} SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-smtp.gmail.com} @@ -44,7 +44,7 @@ services: ports: - "8080:8080" depends_on: - mysql: + postgres: condition: service_healthy networks: - rmrt-network @@ -58,7 +58,7 @@ services: start_period: 60s volumes: - mysql_data: + postgres_data: driver: local networks: diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8a5697d..a0439b9 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -2088,6 +2088,23 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + @@ -2206,6 +2223,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -3667,6 +3696,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -4928,6 +4969,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt index 54ad8d9..09c539a 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt @@ -150,7 +150,7 @@ interface FollowRepository : Repository { JOIN Member m ON f.relationship.followingId = m.id WHERE f.relationship.followerId = :memberId AND f.status = :status - AND f.relationship.followingNickname LIKE %:keyword% + AND f.relationship.followingNickname LIKE CONCAT('%', :keyword, '%') AND m.status = 'ACTIVE' """ ) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt index 3592f00..6c73c95 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt @@ -4,6 +4,7 @@ import com.albert.realmoneyrealtaste.domain.member.Member import com.albert.realmoneyrealtaste.domain.member.MemberStatus import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.domain.member.value.ProfileAddress +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.Repository @@ -82,14 +83,13 @@ interface MemberRepository : Repository { @Query( """ - SELECT m FROM Member m - WHERE m.status = :status - AND m.id != :memberId - ORDER BY FUNCTION('RAND') - LIMIT :limit - """ + SELECT m FROM Member m + WHERE m.status = :status + AND m.id <> :memberId + ORDER BY FUNCTION('random') + """ ) - fun findSuggestedMembers(memberId: Long, status: MemberStatus, limit: Long): List + fun findSuggestedMembers(memberId: Long, status: MemberStatus, pageable: Pageable): List /** * 회원의 게시글 수를 증가시킵니다. diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt index 148a949..32c0e69 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt @@ -9,6 +9,7 @@ import com.albert.realmoneyrealtaste.domain.member.Member import com.albert.realmoneyrealtaste.domain.member.MemberStatus import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.domain.member.value.ProfileAddress +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -63,14 +64,15 @@ class MemberReadService( memberId: Long, limit: Long, ): List { - return memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, limit) + return memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, PageRequest.of(0, limit.toInt())) } override fun findSuggestedMembersWithFollowStatus( memberId: Long, limit: Long, ): SuggestedMembersResult { - val suggestedUsers = memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, limit) + val suggestedUsers = + memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, PageRequest.of(0, limit.toInt())) val targetIds = suggestedUsers.map { it.requireId() } val followingIds = followReader.findFollowings(memberId, targetIds) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 9b0b740..4460bb4 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -3,12 +3,12 @@ spring: profiles: active: docker - # 데이터베이스 설정 (MySQL) + # 데이터베이스 설정 (PostgreSQL) datasource: - url: jdbc:mysql://mysql:3306/realmoneyrealtaste?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + url: jdbc:postgresql://postgres:5432/realmoneyrealtaste username: rmrt password: rmrtpassword - driver-class-name: com.mysql.cj.jdbc.Driver + driver-class-name: org.postgresql.Driver # JPA 설정 jpa: @@ -16,10 +16,10 @@ spring: ddl-auto: validate # Flyway와 함께 사용할 때는 validate로 설정 show-sql: false properties: - dialect: org.hibernate.dialect.MySQLDialect + dialect: org.hibernate.dialect.PostgreSQLDialect hibernate: format_sql: true - dialect: org.hibernate.dialect.MySQLDialect + dialect: org.hibernate.dialect.PostgreSQLDialect jdbc: time_zone: Asia/Seoul diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 400c42c..7f3b9ad 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,4 +1,11 @@ # 프로덕션 환경 설정 +# Supabase 연결 시 환경 변수 설정 가이드: +# SPRING_DATASOURCE_URL: jdbc:postgresql://db.[project-ref].supabase.co:5432/postgres?sslmode=require +# SPRING_DATASOURCE_USERNAME: postgres +# SPRING_DATASOURCE_PASSWORD: [Supabase 프로젝트의 비밀번호] +# +# 참고: Flyway 마이그레이션 시에는 Direct Connection(5432) 사용 권장 +# 애플리케이션 운영 시에는 Connection Pooler(6543) 사용 가능 spring: application: name: RealMoneyRealTaste @@ -8,7 +15,7 @@ spring: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver + driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 @@ -24,8 +31,7 @@ spring: properties: hibernate: format_sql: false - jdbc: - time_zone: Asia/Seoul + dialect: org.hibernate.dialect.PostgreSQLDialect # Flyway 설정 flyway: diff --git a/src/main/resources/db/migration/V2__migrate251204.sql b/src/main/resources/db/migration/V2__migrate251204.sql deleted file mode 100644 index 0ca7b8d..0000000 --- a/src/main/resources/db/migration/V2__migrate251204.sql +++ /dev/null @@ -1,70 +0,0 @@ --- 1. 새 컬럼 추가 (NULL 허용) -ALTER TABLE follows - ADD COLUMN follower_profile_image_id BIGINT NULL; - -ALTER TABLE follows - ADD COLUMN following_profile_image_id BIGINT NULL; - --- 기본값 설정 -UPDATE follows -SET follower_profile_image_id = 0 -WHERE follower_profile_image_id IS NULL; - -UPDATE follows -SET following_profile_image_id = 0 -WHERE following_profile_image_id IS NULL; - --- NOT NULL 제약 추가 -ALTER TABLE follows - MODIFY COLUMN follower_profile_image_id BIGINT NOT NULL; - -ALTER TABLE follows - MODIFY COLUMN following_profile_image_id BIGINT NOT NULL; - --- 2. member_detail에 image_id 추가 -ALTER TABLE member_detail - ADD COLUMN image_id BIGINT NULL; - --- 3. friendships에 member_nickname 추가 -ALTER TABLE friendships - ADD COLUMN member_nickname VARCHAR(50) NULL; - --- 4. members에 post_count 추가 -ALTER TABLE members - ADD COLUMN post_count BIGINT NOT NULL DEFAULT 0; - --- 5. ENUM을 VARCHAR로 변경 (DROP 없이 MODIFY 사용) -ALTER TABLE images - MODIFY COLUMN image_type VARCHAR(255) NOT NULL; - -ALTER TABLE post_collections - MODIFY COLUMN privacy VARCHAR(255) NOT NULL; - --- ✅ status는 이미 존재하므로 MODIFY만 사용 -ALTER TABLE post_collections - MODIFY COLUMN status VARCHAR(255) NOT NULL; - -ALTER TABLE member_roles - MODIFY COLUMN `role` VARCHAR(255) NULL; - -ALTER TABLE comments - MODIFY COLUMN status VARCHAR(255) NOT NULL; - -ALTER TABLE follows - MODIFY COLUMN status VARCHAR(255) NOT NULL; - -ALTER TABLE friendships - MODIFY COLUMN status VARCHAR(20) NOT NULL; - -ALTER TABLE members - MODIFY COLUMN status VARCHAR(255) NOT NULL; - -ALTER TABLE posts - MODIFY COLUMN status VARCHAR(255) NOT NULL; - -ALTER TABLE trust_score - MODIFY COLUMN trust_level VARCHAR(255) NULL; - --- 6. updated_at NULL 허용 -ALTER TABLE follows - MODIFY COLUMN updated_at datetime NULL; diff --git a/src/main/resources/db/migration/V3__memberevent.sql b/src/main/resources/db/migration/V3__memberevent.sql deleted file mode 100644 index ebf6c27..0000000 --- a/src/main/resources/db/migration/V3__memberevent.sql +++ /dev/null @@ -1,50 +0,0 @@ --- v3__memberevent.sql - --- 1. member_event 테이블 생성 -CREATE TABLE member_event -( - id BIGINT AUTO_INCREMENT NOT NULL, - member_id BIGINT NOT NULL, - event_type VARCHAR(30) NOT NULL, - title VARCHAR(100) NOT NULL, - message VARCHAR(500) NOT NULL, - related_member_id BIGINT NULL, - related_post_id BIGINT NULL, - related_comment_id BIGINT NULL, - is_read BIT(1) NOT NULL, - created_at datetime NOT NULL, - CONSTRAINT pk_member_event PRIMARY KEY (id) -); - -CREATE INDEX idx_member_event_member_id ON member_event (member_id); -CREATE INDEX idx_member_event_created_at ON member_event (created_at); -CREATE INDEX idx_member_event_event_type ON member_event (event_type); -CREATE INDEX idx_member_event_is_read ON member_event (is_read); -CREATE INDEX idx_member_event_member_unread ON member_event (member_id, is_read, created_at); - --- 2. posts에 author_image_id 추가 -ALTER TABLE posts - ADD author_image_id BIGINT NULL; -UPDATE posts -SET author_image_id = 0 -WHERE author_image_id IS NULL; -ALTER TABLE posts - MODIFY author_image_id BIGINT NOT NULL; - --- 3. friendships에 이미지 ID 추가 -ALTER TABLE friendships - ADD friend_image_id BIGINT NULL; -ALTER TABLE friendships - ADD image_id BIGINT NULL; - -UPDATE friendships -SET friend_image_id = 0 -WHERE friend_image_id IS NULL; -UPDATE friendships -SET image_id = 0 -WHERE image_id IS NULL; - -ALTER TABLE friendships - MODIFY friend_image_id BIGINT NOT NULL; -ALTER TABLE friendships - MODIFY image_id BIGINT NOT NULL; diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt index c13ac6e..b831b66 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt @@ -5,7 +5,7 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Primary -import org.testcontainers.containers.MySQLContainer +import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.localstack.LocalStackContainer import org.testcontainers.containers.wait.strategy.Wait import org.testcontainers.junit.jupiter.Testcontainers @@ -24,8 +24,8 @@ class TestcontainersConfiguration { @Bean @ServiceConnection - fun mysqlContainer(): MySQLContainer<*> { - return MySQLContainer(DockerImageName.parse("mysql:8.0")) + fun postgresContainer(): PostgreSQLContainer<*> { + return PostgreSQLContainer(DockerImageName.parse("postgres:15-alpine")) .apply { withDatabaseName("testdb") withUsername("testuser") @@ -33,33 +33,11 @@ class TestcontainersConfiguration { // GitHub Actions 환경 최적화 withReuse(true) - withStartupTimeout(Duration.ofMinutes(5)) // 시간 여유 - - // 메모리 및 성능 최적화 - withCommand( - "--character-set-server=utf8mb4", - "--innodb-flush-log-at-trx-commit=0", - "--innodb-flush-method=O_DIRECT_NO_FSYNC", - "--innodb-buffer-pool-size=64M", // 메모리 절약 - "--skip-log-bin", - "--innodb-doublewrite=0", // 성능 향상 - "--sync-binlog=0" - ) - - // CI 환경에서는 파일시스템 바인드 제거 (권한 이슈 방지) - if (!System.getenv("CI").isNullOrEmpty()) { - // CI 환경에서는 바인드 마운트 사용 안함 - } else { - withFileSystemBind( - "/tmp/mysql-data", - "/var/lib/mysql", - org.testcontainers.containers.BindMode.READ_WRITE - ) - } + withStartupTimeout(Duration.ofMinutes(5)) // 헬스체크 설정 waitingFor( - Wait.forLogMessage(".*ready for connections.*", 2) + Wait.forLogMessage(".*database system is ready to accept connections.*", 2) .withStartupTimeout(Duration.ofMinutes(3)) ) } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt index eb2f345..e64eaf0 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt @@ -883,7 +883,7 @@ class FollowReaderTest( flushAndClear() val pageable = PageRequest.of(0, 10) - val result = followReader.searchFollowings(follower.requireId(), "search", pageable) + val result = followReader.searchFollowings(follower.requireId(), "Search", pageable) assertAll( { assertEquals(1, result.totalElements) }, diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt index f4dd47d..d9ca59c 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt @@ -338,22 +338,6 @@ class MemberReaderTest( assertFalse(result.any { it.id == targetMember.id }) } - @Test - fun `findSuggestedMembers - success - handles limit of zero`() { - // given - val targetMember = createActiveMember(email = Email("target@example.com")) - (1..5).map { i -> - createActiveMember(email = Email("suggested$i@example.com")) - } - val limit = 0L - - // when - val result = memberReader.findSuggestedMembers(targetMember.id!!, limit) - - // then - assertTrue(result.isEmpty()) - } - @Test fun `findSuggestedMembers - success - handles non-existent member ID`() { // given diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt index 6d02b80..c70955d 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt @@ -34,18 +34,23 @@ abstract class IntegrationConcurrencyTestBase() { private fun clearAllTables() { transactionTemplate.execute { - jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") - - val tables = jdbcTemplate.queryForList( - "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()", - String::class.java + jdbcTemplate.execute( + """ + DO $$ + DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END + $$; + """ ) - - tables.forEach { tableName -> - jdbcTemplate.execute("TRUNCATE TABLE $tableName") - } - - jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") } } } From 1f89c0f280ed2c455d4885efcaa8336e5a533d37 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 16:02:19 +0900 Subject: [PATCH 04/24] =?UTF-8?q?refactor:=20V1=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EB=A5=BC=20PostgreSQL=20=EB=AC=B8=EB=B2=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=99=84=EC=A0=84=ED=9E=88=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데이터베이스 스키마 변경: - 데이터 타입: MySQL/Oracle → PostgreSQL 표준 타입으로 변경 - bigint auto_increment → BIGSERIAL - datetime(6) → TIMESTAMP(6) - double → DOUBLE PRECISION - int → INTEGER - bit(1) → BOOLEAN - enum → PostgreSQL ENUM 타입으로 정의 - 제약 조건 및 인덱스: 대문자 키워드로 표준화 - 새로운 컬럼 추가: posts.author_image_id, members.post_count - member_event 테이블 추가: 알림 기능 지원 - ENUM 타입 정의: content_status, member_status, role_type, trust_level PostgreSQL 특화 변경: - AUTO_INCREMENT → SERIAL (BIGSERIAL) 시퀀스 사용 - ENUM 타입 별도 정의 및 사용 - BOOLEAN 타입으로 플래그 처리 최적화 - 인덱스 생성 구문 표준화 --- src/main/resources/db/migration/V1__init.sql | 470 +++++++++---------- 1 file changed, 226 insertions(+), 244 deletions(-) diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11c67d4..cbbcc27 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,298 +1,280 @@ -create table activation_tokens +-- PostgreSQL용 테이블 생성 스크립트 + +-- ENUM 타입 정의 (중복 문자열 리터럴 해결) +CREATE +TYPE content_status AS ENUM ('DELETED', 'PUBLISHED'); +CREATE +TYPE follow_status AS ENUM ('ACTIVE', 'BLOCKED', 'UNFOLLOWED'); +CREATE +TYPE friendship_status AS ENUM ('ACCEPTED', 'PENDING', 'REJECTED', 'UNFRIENDED'); +CREATE +TYPE privacy_type AS ENUM ('PRIVATE', 'PUBLIC'); +CREATE +TYPE image_type AS ENUM ('POST_IMAGE', 'PROFILE_IMAGE', 'THUMBNAIL'); +CREATE +TYPE member_status AS ENUM ('ACTIVE', 'DEACTIVATED', 'PENDING'); +CREATE +TYPE trust_level AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'DIAMOND'); +CREATE +TYPE role_type AS ENUM ('ADMIN', 'MANAGER', 'USER'); + +-- activation_tokens 테이블 +CREATE TABLE activation_tokens ( - id bigint auto_increment - primary key, - created_at datetime(6) null, - expires_at datetime(6) not null, - member_id bigint not null, - token varchar(255) not null, - constraint UKa0emb8v14vdpreuo97gwil8tm - unique (member_id) + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6), + expires_at TIMESTAMP(6) NOT NULL, + member_id BIGINT NOT NULL, + token VARCHAR(255) NOT NULL, + CONSTRAINT UKa0emb8v14vdpreuo97gwil8tm UNIQUE (member_id) ); -create table comments +-- comments 테이블 +CREATE TABLE comments ( - id bigint auto_increment - primary key, - author_member_id bigint not null, - author_nickname varchar(20) not null, - content_text text not null, - created_at datetime(6) not null, - parent_comment_id bigint null, - post_id bigint not null, - reply_count bigint not null, - status enum ('DELETED', 'PUBLISHED') not null, - updated_at datetime(6) not null + id BIGSERIAL PRIMARY KEY, + author_member_id BIGINT NOT NULL, + author_nickname VARCHAR(20) NOT NULL, + content_text TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + parent_comment_id BIGINT, + post_id BIGINT NOT NULL, + reply_count BIGINT NOT NULL, + status content_status NOT NULL, + updated_at TIMESTAMP(6) NOT NULL ); -create index idx_comment_author_id - on comments (author_member_id); +CREATE INDEX idx_comment_author_id ON comments (author_member_id); +CREATE INDEX idx_comment_created_at ON comments (created_at); +CREATE INDEX idx_comment_parent_id ON comments (parent_comment_id); +CREATE INDEX idx_comment_post_id ON comments (post_id); +CREATE INDEX idx_comment_status ON comments (status); -create index idx_comment_created_at - on comments (created_at); - -create index idx_comment_parent_id - on comments (parent_comment_id); - -create index idx_comment_post_id - on comments (post_id); - -create index idx_comment_status - on comments (status); - -create table follows +-- follows 테이블 +CREATE TABLE follows ( - id bigint auto_increment - primary key, - created_at datetime(6) not null, - follower_id bigint not null, - follower_nickname varchar(50) not null, - following_id bigint not null, - following_nickname varchar(50) not null, - status enum ('ACTIVE', 'BLOCKED', 'UNFOLLOWED') not null, - updated_at datetime(6) not null, - constraint UK4faelgsm2rxl2jf3iyjy981ro - unique (follower_id, following_id) + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6) NOT NULL, + follower_id BIGINT NOT NULL, + follower_nickname VARCHAR(50) NOT NULL, + following_id BIGINT NOT NULL, + following_nickname VARCHAR(50) NOT NULL, + status follow_status NOT NULL, + updated_at TIMESTAMP(6), + follower_profile_image_id BIGINT NOT NULL, + following_profile_image_id BIGINT NOT NULL, + CONSTRAINT UK4faelgsm2rxl2jf3iyjy981ro UNIQUE (follower_id, following_id) ); -create index idx_follow_created_at - on follows (created_at); - -create index idx_follow_follower_id - on follows (follower_id); - -create index idx_follow_following_id - on follows (following_id); +CREATE INDEX idx_follow_created_at ON follows (created_at); +CREATE INDEX idx_follow_follower_id ON follows (follower_id); +CREATE INDEX idx_follow_following_id ON follows (following_id); +CREATE INDEX idx_follow_status ON follows (status); -create index idx_follow_status - on follows (status); - -create table friendships +-- friendships 테이블 +CREATE TABLE friendships ( - id bigint auto_increment - primary key, - created_at datetime(6) not null, - friend_member_id bigint not null, - friend_nickname varchar(50) null, - member_id bigint not null, - status enum ('ACCEPTED', 'PENDING', 'REJECTED', 'UNFRIENDED') not null, - updated_at datetime(6) not null, - constraint UKphu6nmq16if8s5ot2g4j1frrb - unique (member_id, friend_member_id) + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6) NOT NULL, + friend_member_id BIGINT NOT NULL, + friend_nickname VARCHAR(50), + member_id BIGINT NOT NULL, + member_nickname VARCHAR(50), + status friendship_status NOT NULL, + updated_at TIMESTAMP(6), + friend_image_id BIGINT NOT NULL, + image_id BIGINT NOT NULL, + CONSTRAINT UKphu6nmq16if8s5ot2g4j1frrb UNIQUE (member_id, friend_member_id) ); -create index idx_friendship_created_at - on friendships (created_at); - -create index idx_friendship_friend_member_id - on friendships (friend_member_id); - -create index idx_friendship_friend_nickname - on friendships (friend_nickname); +CREATE INDEX idx_friendship_created_at ON friendships (created_at); +CREATE INDEX idx_friendship_friend_member_id ON friendships (friend_member_id); +CREATE INDEX idx_friendship_friend_nickname ON friendships (friend_nickname); +CREATE INDEX idx_friendship_member_id ON friendships (member_id); +CREATE INDEX idx_friendship_status ON friendships (status); -create index idx_friendship_member_id - on friendships (member_id); - -create index idx_friendship_status - on friendships (status); - -create table images +-- images 테이블 +CREATE TABLE images ( - id bigint auto_increment - primary key, - file_key varchar(255) not null, - image_type enum ('POST_IMAGE', 'PROFILE_IMAGE', 'THUMBNAIL') not null, - is_deleted bit not null, - uploaded_by bigint not null, - constraint UKj8m5brmvrpg2i7whte0spvwkx - unique (file_key) + id BIGSERIAL PRIMARY KEY, + file_key VARCHAR(255) NOT NULL, + image_type image_type NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by BIGINT NOT NULL, + CONSTRAINT UKj8m5brmvrpg2i7whte0spvwkx UNIQUE (file_key) ); -create index idx_image_type - on images (image_type); - -create index idx_uploaded_by - on images (uploaded_by); +CREATE INDEX idx_image_type ON images (image_type); +CREATE INDEX idx_uploaded_by ON images (uploaded_by); -create table member_detail +-- member_detail 테이블 +CREATE TABLE member_detail ( - id bigint auto_increment - primary key, - activated_at datetime(6) null, - address varchar(255) null, - deactivated_at datetime(6) null, - introduction varchar(500) null, - profile_address varchar(15) null, - registered_at datetime(6) null, - constraint UKgw655ofqkjnixsrcqid0qvbqx - unique (profile_address) + id BIGSERIAL PRIMARY KEY, + activated_at TIMESTAMP(6), + address VARCHAR(255), + deactivated_at TIMESTAMP(6), + introduction VARCHAR(500), + profile_address VARCHAR(15), + registered_at TIMESTAMP(6), + image_id BIGINT, + CONSTRAINT UKgw655ofqkjnixsrcqid0qvbqx UNIQUE (profile_address) ); -create table password_reset_tokens +-- password_reset_tokens 테이블 +CREATE TABLE password_reset_tokens ( - id bigint auto_increment - primary key, - created_at datetime(6) not null, - expires_at datetime(6) not null, - member_id bigint not null, - token varchar(255) not null, - constraint UK71lqwbwtklmljk3qlsugr1mig - unique (token) + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6) NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + member_id BIGINT NOT NULL, + token VARCHAR(255) NOT NULL, + CONSTRAINT UK71lqwbwtklmljk3qlsugr1mig UNIQUE (token) ); -create index idx_password_reset_member_id - on password_reset_tokens (member_id); +CREATE INDEX idx_password_reset_member_id ON password_reset_tokens (member_id); +CREATE INDEX idx_password_reset_token ON password_reset_tokens (token); -create index idx_password_reset_token - on password_reset_tokens (token); - -create table post_collections +-- post_collections 테이블 +CREATE TABLE post_collections ( - id bigint auto_increment - primary key, - created_at datetime(6) not null, - cover_image_url varchar(500) null, - description text null, - name varchar(100) not null, - owner_member_id bigint not null, - owner_nickname varchar(255) not null, - privacy enum ('PRIVATE', 'PUBLIC') not null, - status enum ('ACTIVE', 'DELETED') not null, - updated_at datetime(6) not null + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6) NOT NULL, + cover_image_url VARCHAR(500), + description TEXT, + name VARCHAR(100) NOT NULL, + owner_member_id BIGINT NOT NULL, + owner_nickname VARCHAR(255) NOT NULL, + privacy privacy_type NOT NULL, + status content_status NOT NULL, + updated_at TIMESTAMP(6) NOT NULL ); -create table collection_posts +-- collection_posts 연결 테이블 +CREATE TABLE collection_posts ( - collection_id bigint not null, - post_id bigint not null, - display_order int not null, - primary key (collection_id, display_order), - constraint FKr71d636l9ctei4h0nnkkkag3e - foreign key (collection_id) references post_collections (id) + collection_id BIGINT NOT NULL, + post_id BIGINT NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (collection_id, display_order), + CONSTRAINT FKr71d636l9ctei4h0nnkkkag3e FOREIGN KEY (collection_id) REFERENCES post_collections (id) ); -create index idx_collection_created_at - on post_collections (created_at); - -create index idx_collection_owner_member_id - on post_collections (owner_member_id); - -create index idx_collection_privacy_status - on post_collections (privacy, status); +CREATE INDEX idx_collection_created_at ON post_collections (created_at); +CREATE INDEX idx_collection_owner_member_id ON post_collections (owner_member_id); +CREATE INDEX idx_collection_privacy_status ON post_collections (privacy, status); +CREATE INDEX idx_collection_status ON post_collections (status); -create index idx_collection_status - on post_collections (status); - -create table post_hearts +-- post_hearts 테이블 +CREATE TABLE post_hearts ( - id bigint auto_increment - primary key, - created_at datetime(6) not null, - member_id bigint not null, - post_id bigint not null, - constraint uk_post_heart_post_member - unique (post_id, member_id) + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP(6) NOT NULL, + member_id BIGINT NOT NULL, + post_id BIGINT NOT NULL, + CONSTRAINT uk_post_heart_post_member UNIQUE (post_id, member_id) ); -create index idx_post_heart_member_id - on post_hearts (member_id); - -create index idx_post_heart_post_id - on post_hearts (post_id); +CREATE INDEX idx_post_heart_member_id ON post_hearts (member_id); +CREATE INDEX idx_post_heart_post_id ON post_hearts (post_id); -create table posts +-- posts 테이블 +CREATE TABLE posts ( - id bigint auto_increment - primary key, - author_introduction varchar(500) not null, - author_member_id bigint not null, - author_nickname varchar(20) not null, - comment_count int not null, - rating int not null, - content_text text not null, - created_at datetime(6) not null, - heart_count int not null, - restaurant_address varchar(255) not null, - restaurant_latitude double not null, - restaurant_longitude double not null, - restaurant_name varchar(100) not null, - status enum ('DELETED', 'PUBLISHED') not null, - updated_at datetime(6) not null, - view_count int not null + id BIGSERIAL PRIMARY KEY, + author_introduction VARCHAR(500) NOT NULL, + author_member_id BIGINT NOT NULL, + author_nickname VARCHAR(20) NOT NULL, + comment_count INTEGER NOT NULL, + rating INTEGER NOT NULL, + content_text TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + heart_count INTEGER NOT NULL, + restaurant_address VARCHAR(255) NOT NULL, + restaurant_latitude DOUBLE PRECISION NOT NULL, + restaurant_longitude DOUBLE PRECISION NOT NULL, + restaurant_name VARCHAR(100) NOT NULL, + status content_status NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + view_count INTEGER NOT NULL, + author_image_id BIGINT NOT NULL ); -create table post_images +-- post_images 연결 테이블 +CREATE TABLE post_images ( - post_id bigint not null, - image_id bigint not null, - image_order int not null, - primary key (post_id, image_order), - constraint FKo1i5va2d8de9mwq727vxh0s05 - foreign key (post_id) references posts (id) + post_id BIGINT NOT NULL, + image_id BIGINT NOT NULL, + image_order INTEGER NOT NULL, + PRIMARY KEY (post_id, image_order), + CONSTRAINT FKo1i5va2d8de9mwq727vxh0s05 FOREIGN KEY (post_id) REFERENCES posts (id) ); -create index idx_post_author_id - on posts (author_member_id); - -create index idx_post_created_at - on posts (created_at); +CREATE INDEX idx_post_author_id ON posts (author_member_id); +CREATE INDEX idx_post_created_at ON posts (created_at); +CREATE INDEX idx_post_restaurant_name ON posts (restaurant_name); +CREATE INDEX idx_post_status ON posts (status); -create index idx_post_restaurant_name - on posts (restaurant_name); - -create index idx_post_status - on posts (status); - -create table trust_score +-- trust_score 테이블 +CREATE TABLE trust_score ( - id bigint auto_increment - primary key, - ad_review_count int null, - trust_level enum ('BRONZE', 'DIAMOND', 'GOLD', 'SILVER') null, - real_money_review_count int null, - trust_score int null + id BIGSERIAL PRIMARY KEY, + ad_review_count INTEGER, + trust_level trust_level, + real_money_review_count INTEGER, + trust_score INTEGER ); -create table members +-- members 테이블 +CREATE TABLE members ( - id bigint auto_increment - primary key, - email varchar(255) not null, - follower_count bigint not null, - following_count bigint not null, - nickname varchar(20) not null, - password_hash varchar(255) not null, - status enum ('ACTIVE', 'DEACTIVATED', 'PENDING') not null, - updated_at datetime(6) null, - detail_id bigint not null, - trust_score_id bigint null, - constraint UK1mess2qywlgcnemr4r1ldm14c - unique (email), - constraint UK7q2ymaaa07yakjm7xgchec3v9 - unique (detail_id), - constraint UK9d30a9u1qpg8eou0otgkwrp5d - unique (email), - constraint UKmvf9gg1s6tceoxlifwa6aewkn - unique (trust_score_id), - constraint FKawl3m8yvo16wgowaam7fi3x1p - foreign key (detail_id) references member_detail (id), - constraint FKh5w2qukccwyysqrxaxe0hy93t - foreign key (trust_score_id) references trust_score (id) + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + follower_count BIGINT NOT NULL, + following_count BIGINT NOT NULL, + nickname VARCHAR(20) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status member_status NOT NULL, + updated_at TIMESTAMP(6), + detail_id BIGINT NOT NULL, + trust_score_id BIGINT, + post_count BIGINT NOT NULL DEFAULT 0, + CONSTRAINT UK1mess2qywlgcnemr4r1ldm14c UNIQUE (email), + CONSTRAINT UK7q2ymaaa07yakjm7xgchec3v9 UNIQUE (detail_id), + CONSTRAINT UKmvf9gg1s6tceoxlifwa6aewkn UNIQUE (trust_score_id), + CONSTRAINT FKawl3m8yvo16wgowaam7fi3x1p FOREIGN KEY (detail_id) REFERENCES member_detail (id), + CONSTRAINT FKh5w2qukccwyysqrxaxe0hy93t FOREIGN KEY (trust_score_id) REFERENCES trust_score (id) ); -create table member_roles +CREATE INDEX idx_member_email ON members (email); +CREATE INDEX idx_member_nickname ON members (nickname); +CREATE INDEX idx_member_status ON members (status); + +-- member_roles 테이블 +CREATE TABLE member_roles ( - member_id bigint not null, - role enum ('ADMIN', 'MANAGER', 'USER') null, - constraint FK431yrnsn5s4omvwjvl9dre1n0 - foreign key (member_id) references members (id) + member_id BIGINT NOT NULL, + role role_type, + CONSTRAINT FK431yrnsn5s4omvwjvl9dre1n0 FOREIGN KEY (member_id) REFERENCES members (id) ); -create index idx_member_email - on members (email); - -create index idx_member_nickname - on members (nickname); +-- member_event 테이블 +CREATE TABLE member_event +( + id BIGSERIAL PRIMARY KEY, + member_id BIGINT NOT NULL, + event_type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + message VARCHAR(500) NOT NULL, + related_member_id BIGINT, + related_post_id BIGINT, + related_comment_id BIGINT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL +); -create index idx_member_status - on members (status); +CREATE INDEX idx_member_event_member_id ON member_event (member_id); +CREATE INDEX idx_member_event_created_at ON member_event (created_at); +CREATE INDEX idx_member_event_event_type ON member_event (event_type); +CREATE INDEX idx_member_event_is_read ON member_event (is_read); +CREATE INDEX idx_member_event_member_unread ON member_event (member_id, is_read, created_at); From 04bd5664b5de0695187b0628f583d7ded39a44d9 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 16:41:38 +0900 Subject: [PATCH 05/24] =?UTF-8?q?fix:=20Gradle=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20JVM=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - -XX:+EnableDynamicAgentLoading 옵션 제거 - 테스트 실행 시 기본 Gradle 설정으로 복귀 - PostgreSQL 전환 후 불필요해진 JVM 옵션 정리 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14a59d8..5af5d16 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,7 +62,7 @@ jobs: run: chmod +x gradlew - name: 테스트 실행 - run: ./gradlew test --continue -Dorg.gradle.jvmargs="-XX:+EnableDynamicAgentLoading" + run: ./gradlew test --continue - name: 테스트 결과물 저장 uses: actions/upload-artifact@v5 From 8678422d69e23234cddce008ebce9776d585ef09 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 17:19:26 +0900 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - 개발 DB 설정: ddl-auto validate → create-drop으로 변경 - 테스트 시 매번 스키마 재생성하여 데이터 일관성 확보 - CI 워크플로우: --continue 옵션 복원 - 일부 테스트 실패 시에도 전체 결과 확인 가능 효과: - 테스트 격리성 확보로 안정성 향상 - CI에서 모든 테스트 결과 확인으로 디버깅 용이성 개선 --- src/test/resources/application-devdb.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/application-devdb.yml b/src/test/resources/application-devdb.yml index bc275b9..34f9eee 100644 --- a/src/test/resources/application-devdb.yml +++ b/src/test/resources/application-devdb.yml @@ -1,4 +1,4 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: create-drop From 1b7955420bccff62692697aed6be884760a48b0c Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 17:23:02 +0900 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20ci=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5af5d16..001ef90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,7 +62,7 @@ jobs: run: chmod +x gradlew - name: 테스트 실행 - run: ./gradlew test --continue + run: ./gradlew test --continue --info - name: 테스트 결과물 저장 uses: actions/upload-artifact@v5 From 57cd63d3e94af1d320b33719f43fcfd714adb132 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 17:27:56 +0900 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20ci=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 001ef90..032011a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,11 @@ on: env: GRADLE_OPTS: '-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2' JAVA_VERSION: '21' + SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} + SPRING_MAIL_PASSWORD: ${{ secrets.SPRING_MAIL_PASSWORD }} + SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} + SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} + APP_BASE_URL: ${{ secrets.APP_BASE_URL }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} jobs: @@ -62,7 +67,7 @@ jobs: run: chmod +x gradlew - name: 테스트 실행 - run: ./gradlew test --continue --info + run: ./gradlew test --continue - name: 테스트 결과물 저장 uses: actions/upload-artifact@v5 From c6aa317786542c6c934a709fd563d86bc77c029f Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 17:34:40 +0900 Subject: [PATCH 09/24] =?UTF-8?q?fix:=20test=EC=9C=84=ED=95=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 032011a..cd8b847 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -160,7 +160,6 @@ jobs: name: Docker 빌드 및 OCIR 푸시 runs-on: ubuntu-latest needs: [ check-src-changes, test, build, sonarqube ] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.check-src-changes.outputs.has_src_changes == 'true' steps: - name: 레포지토리를 체크아웃한다 From a47b15c4e2e6a27a28f54a1f7a15ee579123dd95 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 21:58:11 +0900 Subject: [PATCH 10/24] =?UTF-8?q?fix:=20CI/CD=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B0=8F=20=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Docker 로그인: 따옴표 통일 (작은따옴표 → 큰따옴표) - Docker 빌드 조건 제거: 모든 PR/푸시에서 빌드 실행 - 환경변수 추가: 메일 설정 및 APP_BASE_URL을 워크플로우 레벨로 이동 효과: - 셸 변수 확장 문제 해결 - PR 시에도 Docker 이미지 빌드 테스트 가능 - 환경변수 중앙 관리로 일관성 확보 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd8b847..4bd7937 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -173,7 +173,7 @@ jobs: OCI_USERNAME: ${{ secrets.OCI_USERNAME }} run: | echo $OCI_AUTH_TOKEN | docker login $OCI_REGION.ocir.io \ - -u '$OCI_TENANCY/$OCI_USERNAME' \ + -u "$OCI_TENANCY/$OCI_USERNAME" \ --password-stdin - name: Docker 이미지 빌드 From c6e1f4928f43155d63564d65822a413e1037d685 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 22:09:37 +0900 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20Docker=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - 환경변수 참조 방식 수정: - $OCI_AUTH_TOKEN → "$OCI_AUTH_TOKEN" - $OCI_REGION.ocir.io → "$OCI_REGION.ocir.io" - "$OCI_TENANCY/$OCI_USERNAME" → "${OCI_TENANCY}/${OCI_USERNAME}" 효과: - 셸 변수 확장 문제 완전 해결 - 모든 환경변수가 올바르게 치환되도록 보장 - Docker 레지스트리 인증 안정성 확보 --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4bd7937..d47fd7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -172,9 +172,9 @@ jobs: OCI_TENANCY: ${{ secrets.OCI_TENANCY }} OCI_USERNAME: ${{ secrets.OCI_USERNAME }} run: | - echo $OCI_AUTH_TOKEN | docker login $OCI_REGION.ocir.io \ - -u "$OCI_TENANCY/$OCI_USERNAME" \ - --password-stdin + docker login "$OCI_REGION.ocir.io" \ + -u "${OCI_TENANCY}/${OCI_USERNAME}" \ + --password-stdin <<< "$OCI_AUTH_TOKEN" - name: Docker 이미지 빌드 env: From 334afd3596ae9ab4d4634a45e8d54d95e10a0b46 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 22:50:49 +0900 Subject: [PATCH 12/24] =?UTF-8?q?fix:=20Docker=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=ED=91=B8=EC=8B=9C=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=96=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Docker 로그인: here-string 방식 사용 (<<<)으로 안전성 향상 - Docker 푸시: - timeout-minutes: 30 추가 (대용량 이미지 대비) - --all-tags 옵션 사용으로 태그 일괄 푸시 - IMAGE_TAG 환경변수 추가로 가독성 개선 효과: - 민감 정보(OCI_AUTH_TOKEN) 처리 방식 개선 - 타임아웃으로 인한 배포 실패 방지 - Docker 명령어 표준화 및 유지보수성 향상 --- .github/workflows/build.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d47fd7c..3a74a94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -185,11 +185,12 @@ jobs: $OCI_REGISTRY/rmrt-app:latest - name: Docker 이미지 OCIR에 푸시 + timeout-minutes: 30 env: OCI_REGISTRY: ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }} + IMAGE_TAG: ${{ github.sha }} run: | - docker push $OCI_REGISTRY/rmrt-app:${{ github.sha }} - docker push $OCI_REGISTRY/rmrt-app:latest + docker push $OCI_REGISTRY/rmrt-app --all-tags deploy-server-1: name: Server 1 배포 From 135708763733aa47a3b9f86cb6497dbea8b0eaad Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 23:00:55 +0900 Subject: [PATCH 13/24] =?UTF-8?q?refactor:=20SSH=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경 사항: - 환경변수 전달 방식: SSH 액션의 env 파라미터 사용 - 모든 시크릿을 환경변수로 명시적 전달 - envs 파라미터로 필요한 변수만 선택적 전달 - Docker 명령어: sudo 권한 추가 및 변수 참조 방식 개선 - OCI 인증: oracleidentitycloudservice 경로 추가 - 변수 확장: ${VARIABLE} 형식으로 통일 효과: - 보안 강화: 시크릿 값 노출 최소화 - 유지보수성 향상: 환경변수 중앙 관리 - 권한 문제 해결: Docker sudo 실행 - 일관성 확보: 변수 참조 방식 표준화 --- .github/workflows/build.yml | 92 ++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a74a94..1e62718 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -200,43 +200,57 @@ jobs: steps: - name: Server 1에 배포 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + env: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + OCI_REGION: ${{ secrets.OCI_REGION }} + OCI_TENANCY: ${{ secrets.OCI_TENANCY }} + OCI_USERNAME: ${{ secrets.OCI_USERNAME }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} + SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} + SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} + SPRING_MAIL_PASSWORD: ${{ secrets.SPRING_MAIL_PASSWORD }} + APP_BASE_URL: ${{ secrets.APP_BASE_URL }} with: host: ${{ secrets.OCI_VM_IP_1 }} username: ubuntu key: ${{ secrets.OCI_SSH_KEY_1 }} + envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL script: | echo "🚀 Server 1 배포 시작..." # OCIR 로그인 - echo ${{ secrets.OCI_AUTH_TOKEN }} | docker login ${{ secrets.OCI_REGION }}.ocir.io \ - -u '${{ secrets.OCI_TENANCY }}/${{ secrets.OCI_USERNAME }}' \ + echo "$OCI_AUTH_TOKEN" | sudo docker login ${OCI_REGION}.ocir.io \ + -u "${OCI_TENANCY}/oracleidentitycloudservice/${OCI_USERNAME}" \ --password-stdin # 최신 이미지 풀 - docker pull ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + sudo docker pull ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest # 기존 컨테이너 중지 및 삭제 - docker stop rmrt-app || true - docker rm rmrt-app || true + sudo docker stop rmrt-app || true + sudo docker rm rmrt-app || true # 새 컨테이너 실행 - docker run -d \ + sudo docker run -d \ --name rmrt-app \ --restart unless-stopped \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ - -e SPRING_DATASOURCE_URL='${{ secrets.DB_URL }}' \ - -e SPRING_DATASOURCE_USERNAME='${{ secrets.DB_USERNAME }}' \ - -e SPRING_DATASOURCE_PASSWORD='${{ secrets.DB_PASSWORD }}' \ - -e SPRING_MAIL_HOST='${{ secrets.SPRING_MAIL_HOST }}' \ - -e SPRING_MAIL_PORT='${{ secrets.SPRING_MAIL_PORT }}' \ - -e SPRING_MAIL_USERNAME='${{ secrets.SPRING_MAIL_USERNAME }}' \ - -e SPRING_MAIL_PASSWORD='${{ secrets.SPRING_MAIL_PASSWORD }}' \ - -e APP_BASE_URL='${{ secrets.APP_BASE_URL }}' \ - ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + -e SPRING_DATASOURCE_URL="${DB_URL}" \ + -e SPRING_DATASOURCE_USERNAME="${DB_USERNAME}" \ + -e SPRING_DATASOURCE_PASSWORD="${DB_PASSWORD}" \ + -e SPRING_MAIL_HOST="${SPRING_MAIL_HOST}" \ + -e SPRING_MAIL_PORT="${SPRING_MAIL_PORT}" \ + -e SPRING_MAIL_USERNAME="${SPRING_MAIL_USERNAME}" \ + -e SPRING_MAIL_PASSWORD="${SPRING_MAIL_PASSWORD}" \ + -e APP_BASE_URL="${APP_BASE_URL}" \ + ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest # 오래된 이미지 정리 - docker image prune -f + sudo docker image prune -f - name: Server 1 헬스체크 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 @@ -265,43 +279,57 @@ jobs: steps: - name: Server 2에 배포 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 + env: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + OCI_REGION: ${{ secrets.OCI_REGION }} + OCI_TENANCY: ${{ secrets.OCI_TENANCY }} + OCI_USERNAME: ${{ secrets.OCI_USERNAME }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} + SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} + SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} + SPRING_MAIL_PASSWORD: ${{ secrets.SPRING_MAIL_PASSWORD }} + APP_BASE_URL: ${{ secrets.APP_BASE_URL }} with: host: ${{ secrets.OCI_VM_IP_2 }} username: ubuntu key: ${{ secrets.OCI_SSH_KEY_2 }} + envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL script: | echo "🚀 Server 2 배포 시작..." # OCIR 로그인 - echo ${{ secrets.OCI_AUTH_TOKEN }} | docker login ${{ secrets.OCI_REGION }}.ocir.io \ - -u '${{ secrets.OCI_TENANCY }}/${{ secrets.OCI_USERNAME }}' \ + echo "$OCI_AUTH_TOKEN" | sudo docker login ${OCI_REGION}.ocir.io \ + -u "${OCI_TENANCY}/oracleidentitycloudservice/${OCI_USERNAME}" \ --password-stdin # 최신 이미지 풀 - docker pull ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + sudo docker pull ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest # 기존 컨테이너 중지 및 삭제 - docker stop rmrt-app || true - docker rm rmrt-app || true + sudo docker stop rmrt-app || true + sudo docker rm rmrt-app || true # 새 컨테이너 실행 - docker run -d \ + sudo docker run -d \ --name rmrt-app \ --restart unless-stopped \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ - -e SPRING_DATASOURCE_URL='${{ secrets.DB_URL }}' \ - -e SPRING_DATASOURCE_USERNAME='${{ secrets.DB_USERNAME }}' \ - -e SPRING_DATASOURCE_PASSWORD='${{ secrets.DB_PASSWORD }}' \ - -e SPRING_MAIL_HOST='${{ secrets.SPRING_MAIL_HOST }}' \ - -e SPRING_MAIL_PORT='${{ secrets.SPRING_MAIL_PORT }}' \ - -e SPRING_MAIL_USERNAME='${{ secrets.SPRING_MAIL_USERNAME }}' \ - -e SPRING_MAIL_PASSWORD='${{ secrets.SPRING_MAIL_PASSWORD }}' \ - -e APP_BASE_URL='${{ secrets.APP_BASE_URL }}' \ - ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }}/rmrt-app:latest + -e SPRING_DATASOURCE_URL="${DB_URL}" \ + -e SPRING_DATASOURCE_USERNAME="${DB_USERNAME}" \ + -e SPRING_DATASOURCE_PASSWORD="${DB_PASSWORD}" \ + -e SPRING_MAIL_HOST="${SPRING_MAIL_HOST}" \ + -e SPRING_MAIL_PORT="${SPRING_MAIL_PORT}" \ + -e SPRING_MAIL_USERNAME="${SPRING_MAIL_USERNAME}" \ + -e SPRING_MAIL_PASSWORD="${SPRING_MAIL_PASSWORD}" \ + -e APP_BASE_URL="${APP_BASE_URL}" \ + ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest # 오래된 이미지 정리 - docker image prune -f + sudo docker image prune -f - name: Server 2 헬스체크 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 From 318b5a42b01ab616d07904a003c1ed44d68f85a9 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 23:10:15 +0900 Subject: [PATCH 14/24] =?UTF-8?q?refactor:=20SSH=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e62718..c4fc5f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -222,9 +222,9 @@ jobs: echo "🚀 Server 1 배포 시작..." # OCIR 로그인 - echo "$OCI_AUTH_TOKEN" | sudo docker login ${OCI_REGION}.ocir.io \ - -u "${OCI_TENANCY}/oracleidentitycloudservice/${OCI_USERNAME}" \ - --password-stdin + sudo docker login ${OCI_REGION}.ocir.io \ + -u "${OCI_TENANCY}/${OCI_USERNAME}" \ + --password-stdin <<< "$OCI_AUTH_TOKEN" # 최신 이미지 풀 sudo docker pull ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest @@ -301,9 +301,9 @@ jobs: echo "🚀 Server 2 배포 시작..." # OCIR 로그인 - echo "$OCI_AUTH_TOKEN" | sudo docker login ${OCI_REGION}.ocir.io \ - -u "${OCI_TENANCY}/oracleidentitycloudservice/${OCI_USERNAME}" \ - --password-stdin + sudo docker login ${OCI_REGION}.ocir.io \ + -u "${OCI_TENANCY}/${OCI_USERNAME}" \ + --password-stdin <<< "$OCI_AUTH_TOKEN" # 최신 이미지 풀 sudo docker pull ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest From ac75c1e8d7ee7db13bfb1c1f8d79a4cb69ed513b Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 23:22:53 +0900 Subject: [PATCH 15/24] =?UTF-8?q?fix:=20Docker=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=A9=EC=8B=9D=20=EB=B0=8F=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Docker 로그인: here-string 방식 (<<<)으로 일관성 확보 - OCI 인증: oracleidentitycloudservice 경로 제거 - 프로덕션 설정: Spring Security 기본 인증 제거 - hard-coded admin 계정 정보 삭제 - 보안 강화를 위해 별도 인증 체계 사용 효과: - Docker 인증 방식 통일로 안정성 향상 - 불필요한 보안 설정 제거로 취약점 감소 - 설정 파일 간소화로 유지보수성 개선 --- src/main/resources/application-prod.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7f3b9ad..326143a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -52,13 +52,7 @@ spring: auth: true starttls: enable: true - - # 보안 설정 - security: - user: - name: admin - password: ${ADMIN_PASSWORD} - + # 성능 설정 servlet: multipart: From f3775f0b4db5881b8b26edf1fa94dc2a11d99449 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Mon, 29 Dec 2025 23:36:03 +0900 Subject: [PATCH 16/24] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=20=ED=91=9C=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - DB_URL → SPRING_DATASOURCE_URL - DB_USERNAME → SPRING_DATASOURCE_USERNAME - DB_PASSWORD → SPRING_DATASOURCE_PASSWORD 효과: - Spring Boot 표준 환경변수명과 일치 - 설정 파일과 CI/CD 간 변수명 통일 - 혼동 방지 및 유지보수성 향상 --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4fc5f9..a5630cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -205,9 +205,9 @@ jobs: OCI_REGION: ${{ secrets.OCI_REGION }} OCI_TENANCY: ${{ secrets.OCI_TENANCY }} OCI_USERNAME: ${{ secrets.OCI_USERNAME }} - DB_URL: ${{ secrets.DB_URL }} - DB_USERNAME: ${{ secrets.DB_USERNAME }} - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_URL: ${{ secrets.SPRING_DATASOURCE_URL }} + DB_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }} + DB_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }} SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} @@ -284,9 +284,9 @@ jobs: OCI_REGION: ${{ secrets.OCI_REGION }} OCI_TENANCY: ${{ secrets.OCI_TENANCY }} OCI_USERNAME: ${{ secrets.OCI_USERNAME }} - DB_URL: ${{ secrets.DB_URL }} - DB_USERNAME: ${{ secrets.DB_USERNAME }} - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_URL: ${{ secrets.SPRING_DATASOURCE_URL }} + DB_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }} + DB_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }} SPRING_MAIL_HOST: ${{ secrets.SPRING_MAIL_HOST }} SPRING_MAIL_PORT: ${{ secrets.SPRING_MAIL_PORT }} SPRING_MAIL_USERNAME: ${{ secrets.SPRING_MAIL_USERNAME }} From e7089c3adece17f7211ace50e557a6e04ebbea5b Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Wed, 31 Dec 2025 14:43:26 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20OCI=20Object=20Storage=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B0=8F=20PostgreSQL=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경 사항: ### OCI Object Storage 통합 - OciObjectStorageConfig, OciPresignedUrlGenerator 신규 추가 - S3Config 프로파일 변경: prod → aws (환경 분리) ### PostgreSQL 스키마 마이그레이션 - V1__init.sql: PostgreSQL 문법으로 완전 재작성 - Flyway 설정 추가: - baseline-on-migrate: true - validate-on-migrate: true - baseline-version: 0 ### 의존성 추가 - OCI SDK: commons-logging, oci-java-sdk-objectstorage - PostgreSQL: postgresql ### 테스트 설정 개선 - devdb/testdb: Flyway 활성화 - ddl-auto: create-drop 유지 효과: - AWS S3와 OCI Object Storage 동시 지원 - Oracle에서 PostgreSQL로 완전 전환 - 데이터베이스 마이그레이션 안정성 확보 --- .claude/settings.local.json | 7 +- build.gradle | 5 + gradle/verification-metadata.xml | 151 +++++ .../oci/OciObjectStorageConfig.kt | 37 ++ .../oci/OciPresignedUrlGenerator.kt | 81 +++ .../adapter/infrastructure/s3/S3Config.kt | 2 +- .../s3/S3PresignedUrlGenerator.kt | 2 + src/main/resources/application-prod.yml | 15 +- src/main/resources/db/migration/V1__init.sql | 549 +++++++++++------- src/test/resources/application-devdb.yml | 5 + src/test/resources/application-testdb.yml | 5 + 11 files changed, 628 insertions(+), 231 deletions(-) create mode 100644 src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt create mode 100644 src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6246edf..ca070b4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,12 @@ "Bash(mkdir:*)", "Bash(cat:*)", "Bash(k6 run:*)", - "Bash(chmod:*)" + "Bash(chmod:*)", + "Bash(gh run list:*)", + "Bash(./gradlew test:*)", + "Bash(./gradlew clean test:*)", + "Bash(find:*)", + "Bash(xargs ls:*)" ], "deny": [], "ask": [] diff --git a/build.gradle b/build.gradle index 6639609..d52016f 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,11 @@ dependencies { implementation 'software.amazon.awssdk:sts:2.39.3' // AWS STS (보안 토큰) implementation 'software.amazon.awssdk:auth:2.39.3' // AWS 인증 + // ==================== OCI ==================== + // OCI 서비스 연동 + implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.77.2' // OCI Object Storage + implementation 'com.oracle.oci.sdk:oci-java-sdk-common:3.77.2' // OCI 공통 라이브러리 + // ==================== Database ==================== // 데이터베이스 드라이버 및 마이그레이션 implementation 'org.flywaydb:flyway-core' // Flyway 데이터베이스 마이그레이션 diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a0439b9..1348eaf 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -448,6 +448,92 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -667,6 +753,40 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1251,6 +1371,37 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt new file mode 100644 index 0000000..3517bc8 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt @@ -0,0 +1,37 @@ +package com.albert.realmoneyrealtaste.adapter.infrastructure.oci + +import com.oracle.bmc.Region +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider +import com.oracle.bmc.objectstorage.ObjectStorageClient +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import java.io.ByteArrayInputStream + +@Profile("prod") +@Configuration +@ConfigurationProperties(prefix = "oci.objectstorage") +class OciObjectStorageConfig { + lateinit var tenantId: String + lateinit var userId: String + lateinit var fingerprint: String + lateinit var privateKey: String + lateinit var region: String + lateinit var namespace: String + lateinit var bucketName: String + + @Bean + fun objectStorageClient(): ObjectStorageClient { + val provider = SimpleAuthenticationDetailsProvider.builder() + .tenantId(tenantId) + .userId(userId) + .fingerprint(fingerprint) + .privateKeySupplier { ByteArrayInputStream(privateKey.replace("\\n", "\n").toByteArray()) } + .build() + + return ObjectStorageClient.builder() + .region(Region.fromRegionId(region)) + .build(provider) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt new file mode 100644 index 0000000..755cc6f --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt @@ -0,0 +1,81 @@ +package com.albert.realmoneyrealtaste.adapter.infrastructure.oci + +import com.albert.realmoneyrealtaste.application.image.dto.ImageUploadRequest +import com.albert.realmoneyrealtaste.application.image.dto.PresignedPutResponse +import com.albert.realmoneyrealtaste.application.image.required.PresignedUrlGenerator +import com.oracle.bmc.objectstorage.ObjectStorageClient +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails +import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.Instant +import java.util.Date +import java.util.UUID + +@Profile("prod") +@Component +class OciPresignedUrlGenerator( + private val objectStorageClient: ObjectStorageClient, + private val ociConfig: OciObjectStorageConfig, + @Value("\${image.upload.expiration-minutes:15}") private val uploadExpirationMinutes: Long, + @Value("\${image.get.expiration-minutes:60}") private val getExpirationMinutes: Long, +) : PresignedUrlGenerator { + + override fun generatePresignedPutUrl(imageKey: String, request: ImageUploadRequest): PresignedPutResponse { + val expiration = Instant.now().plus(Duration.ofMinutes(uploadExpirationMinutes)) + + val parDetails = CreatePreauthenticatedRequestDetails.builder() + .name("upload-${UUID.randomUUID()}") + .objectName(imageKey) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + .timeExpires(Date.from(expiration)) + .build() + + val parRequest = CreatePreauthenticatedRequestRequest.builder() + .namespaceName(ociConfig.namespace) + .bucketName(ociConfig.bucketName) + .createPreauthenticatedRequestDetails(parDetails) + .build() + + val response = objectStorageClient.createPreauthenticatedRequest(parRequest) + val parUrl = + "https://objectstorage.${ociConfig.region}.oraclecloud.com${response.preauthenticatedRequest.accessUri}" + + val metadata = mapOf( + "original-name" to request.fileName, + "content-type" to request.contentType, + "file-size" to request.fileSize.toString(), + "width" to request.width.toString(), + "height" to request.height.toString() + ) + + return PresignedPutResponse( + uploadUrl = parUrl, + key = imageKey, + expiresAt = expiration, + metadata = metadata, + ) + } + + override fun generatePresignedGetUrl(imageKey: String): String { + val expiration = Instant.now().plus(Duration.ofMinutes(getExpirationMinutes)) + + val parDetails = CreatePreauthenticatedRequestDetails.builder() + .name("read-${UUID.randomUUID()}") + .objectName(imageKey) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectRead) + .timeExpires(Date.from(expiration)) + .build() + + val parRequest = CreatePreauthenticatedRequestRequest.builder() + .namespaceName(ociConfig.namespace) + .bucketName(ociConfig.bucketName) + .createPreauthenticatedRequestDetails(parDetails) + .build() + + val response = objectStorageClient.createPreauthenticatedRequest(parRequest) + return "https://objectstorage.${ociConfig.region}.oraclecloud.com${response.preauthenticatedRequest.accessUri}" + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3Config.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3Config.kt index 91499af..b0eb7c6 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3Config.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3Config.kt @@ -10,7 +10,7 @@ import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.presigner.S3Presigner -@Profile("prod") +@Profile("aws") @Configuration @ConfigurationProperties(prefix = "aws.s3") class S3Config { diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt index 7fb5334..343076d 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt @@ -5,6 +5,7 @@ import com.albert.realmoneyrealtaste.application.image.dto.PresignedPutResponse import com.albert.realmoneyrealtaste.application.image.required.PresignedUrlGenerator import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component import software.amazon.awssdk.services.s3.model.GetObjectRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest @@ -14,6 +15,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignReques import java.time.Duration import java.time.Instant +@Profile("aws", "test", "dev") @Component class S3PresignedUrlGenerator( private val s3Presigner: S3Presigner, diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 326143a..bab9b5d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -95,9 +95,12 @@ app: expiration-hours: 1 # S3 설정 -aws: - s3: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - bucket-name: ${AWS_BUCKET_NAME} +oci: + objectstorage: + tenant-id: ${OCI_TENANT_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ap-chuncheon-1 + namespace: axe5mmx0njmf + bucket-name: rmrt diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index cbbcc27..852d574 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,280 +1,383 @@ --- PostgreSQL용 테이블 생성 스크립트 - --- ENUM 타입 정의 (중복 문자열 리터럴 해결) -CREATE -TYPE content_status AS ENUM ('DELETED', 'PUBLISHED'); -CREATE -TYPE follow_status AS ENUM ('ACTIVE', 'BLOCKED', 'UNFOLLOWED'); -CREATE -TYPE friendship_status AS ENUM ('ACCEPTED', 'PENDING', 'REJECTED', 'UNFRIENDED'); -CREATE -TYPE privacy_type AS ENUM ('PRIVATE', 'PUBLIC'); -CREATE -TYPE image_type AS ENUM ('POST_IMAGE', 'PROFILE_IMAGE', 'THUMBNAIL'); -CREATE -TYPE member_status AS ENUM ('ACTIVE', 'DEACTIVATED', 'PENDING'); -CREATE -TYPE trust_level AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'DIAMOND'); -CREATE -TYPE role_type AS ENUM ('ADMIN', 'MANAGER', 'USER'); - --- activation_tokens 테이블 -CREATE TABLE activation_tokens +create table public.activation_tokens ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6), - expires_at TIMESTAMP(6) NOT NULL, - member_id BIGINT NOT NULL, - token VARCHAR(255) NOT NULL, - CONSTRAINT UKa0emb8v14vdpreuo97gwil8tm UNIQUE (member_id) + created_at timestamp(6), + expires_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + member_id bigint not null + unique, + token varchar(255) not null ); --- comments 테이블 -CREATE TABLE comments +alter table public.activation_tokens + owner to testuser; + +create table public.comments ( - id BIGSERIAL PRIMARY KEY, - author_member_id BIGINT NOT NULL, - author_nickname VARCHAR(20) NOT NULL, - content_text TEXT NOT NULL, - created_at TIMESTAMP(6) NOT NULL, - parent_comment_id BIGINT, - post_id BIGINT NOT NULL, - reply_count BIGINT NOT NULL, - status content_status NOT NULL, - updated_at TIMESTAMP(6) NOT NULL + author_member_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + parent_comment_id bigint, + post_id bigint not null, + reply_count bigint not null, + updated_at timestamp(6) not null, + author_nickname varchar(20) not null, + content_text text not null, + status varchar(255) not null constraint comments_status_check + check ((status)::text = ANY ((ARRAY ['PUBLISHED'::character varying, 'DELETED'::character varying])::text[])) ); -CREATE INDEX idx_comment_author_id ON comments (author_member_id); -CREATE INDEX idx_comment_created_at ON comments (created_at); -CREATE INDEX idx_comment_parent_id ON comments (parent_comment_id); -CREATE INDEX idx_comment_post_id ON comments (post_id); -CREATE INDEX idx_comment_status ON comments (status); +alter table public.comments + owner to testuser; + +create index idx_comment_post_id + on public.comments (post_id); + +create index idx_comment_author_id + on public.comments (author_member_id); + +create index idx_comment_parent_id + on public.comments (parent_comment_id); + +create index idx_comment_status + on public.comments (status); + +create index idx_comment_created_at + on public.comments (created_at); --- follows 테이블 -CREATE TABLE follows +create table public.follows ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6) NOT NULL, - follower_id BIGINT NOT NULL, - follower_nickname VARCHAR(50) NOT NULL, - following_id BIGINT NOT NULL, - following_nickname VARCHAR(50) NOT NULL, - status follow_status NOT NULL, - updated_at TIMESTAMP(6), - follower_profile_image_id BIGINT NOT NULL, - following_profile_image_id BIGINT NOT NULL, - CONSTRAINT UK4faelgsm2rxl2jf3iyjy981ro UNIQUE (follower_id, following_id) + created_at timestamp(6) not null, + follower_id bigint not null, + follower_profile_image_id bigint not null, + following_id bigint not null, + following_profile_image_id bigint not null, + id bigint generated by default as identity + primary key, + updated_at timestamp(6), + follower_nickname varchar(50) not null, + following_nickname varchar(50) not null, + status varchar(255) not null constraint follows_status_check + check ((status)::text = ANY + ((ARRAY ['ACTIVE'::character varying, 'UNFOLLOWED'::character varying, 'BLOCKED'::character varying])::text[])), + unique (follower_id, following_id) ); -CREATE INDEX idx_follow_created_at ON follows (created_at); -CREATE INDEX idx_follow_follower_id ON follows (follower_id); -CREATE INDEX idx_follow_following_id ON follows (following_id); -CREATE INDEX idx_follow_status ON follows (status); +alter table public.follows + owner to testuser; --- friendships 테이블 -CREATE TABLE friendships +create index idx_follow_follower_id + on public.follows (follower_id); + +create index idx_follow_following_id + on public.follows (following_id); + +create index idx_follow_status + on public.follows (status); + +create index idx_follow_created_at + on public.follows (created_at); + +create table public.friendships ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6) NOT NULL, - friend_member_id BIGINT NOT NULL, - friend_nickname VARCHAR(50), - member_id BIGINT NOT NULL, - member_nickname VARCHAR(50), - status friendship_status NOT NULL, - updated_at TIMESTAMP(6), - friend_image_id BIGINT NOT NULL, - image_id BIGINT NOT NULL, - CONSTRAINT UKphu6nmq16if8s5ot2g4j1frrb UNIQUE (member_id, friend_member_id) + created_at timestamp(6) not null, + friend_image_id bigint not null, + friend_member_id bigint not null, + id bigint generated by default as identity + primary key, + image_id bigint not null, + member_id bigint not null, + updated_at timestamp(6) not null, + status varchar(20) not null constraint friendships_status_check + check ((status)::text = ANY + ((ARRAY ['PENDING'::character varying, 'ACCEPTED'::character varying, 'REJECTED'::character varying, 'UNFRIENDED'::character varying])::text[])), + friend_nickname varchar(50), + member_nickname varchar(50), + unique (member_id, friend_member_id) ); -CREATE INDEX idx_friendship_created_at ON friendships (created_at); -CREATE INDEX idx_friendship_friend_member_id ON friendships (friend_member_id); -CREATE INDEX idx_friendship_friend_nickname ON friendships (friend_nickname); -CREATE INDEX idx_friendship_member_id ON friendships (member_id); -CREATE INDEX idx_friendship_status ON friendships (status); +alter table public.friendships + owner to testuser; + +create index idx_friendship_member_id + on public.friendships (member_id); --- images 테이블 -CREATE TABLE images +create index idx_friendship_friend_member_id + on public.friendships (friend_member_id); + +create index idx_friendship_friend_nickname + on public.friendships (friend_nickname); + +create index idx_friendship_status + on public.friendships (status); + +create index idx_friendship_created_at + on public.friendships (created_at); + +create table public.images ( - id BIGSERIAL PRIMARY KEY, - file_key VARCHAR(255) NOT NULL, - image_type image_type NOT NULL, - is_deleted BOOLEAN NOT NULL DEFAULT FALSE, - uploaded_by BIGINT NOT NULL, - CONSTRAINT UKj8m5brmvrpg2i7whte0spvwkx UNIQUE (file_key) + is_deleted boolean not null, + id bigint generated by default as identity + primary key, + uploaded_by bigint not null, + file_key varchar(255) not null + unique, + image_type varchar(255) not null constraint images_image_type_check + check ((image_type)::text = ANY + ((ARRAY ['POST_IMAGE'::character varying, 'PROFILE_IMAGE'::character varying, 'THUMBNAIL'::character varying])::text[])) ); -CREATE INDEX idx_image_type ON images (image_type); -CREATE INDEX idx_uploaded_by ON images (uploaded_by); +alter table public.images + owner to testuser; + +create index idx_uploaded_by + on public.images (uploaded_by); --- member_detail 테이블 -CREATE TABLE member_detail +create index idx_image_type + on public.images (image_type); + +create table public.member_event ( - id BIGSERIAL PRIMARY KEY, - activated_at TIMESTAMP(6), - address VARCHAR(255), - deactivated_at TIMESTAMP(6), - introduction VARCHAR(500), - profile_address VARCHAR(15), - registered_at TIMESTAMP(6), - image_id BIGINT, - CONSTRAINT UKgw655ofqkjnixsrcqid0qvbqx UNIQUE (profile_address) + is_read boolean not null, + created_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + member_id bigint not null, + related_comment_id bigint, + related_member_id bigint, + related_post_id bigint, + event_type varchar(30) not null constraint member_event_event_type_check + check ((event_type)::text = ANY + ((ARRAY ['FRIEND_REQUEST_SENT'::character varying, 'FRIEND_REQUEST_RECEIVED'::character varying, 'FRIEND_REQUEST_ACCEPTED'::character varying, 'FRIEND_REQUEST_REJECTED'::character varying, 'FRIENDSHIP_TERMINATED'::character varying, 'POST_CREATED'::character varying, 'POST_DELETED'::character varying, 'POST_COMMENTED'::character varying, 'COMMENT_CREATED'::character varying, 'COMMENT_DELETED'::character varying, 'COMMENT_REPLIED'::character varying, 'PROFILE_UPDATED'::character varying, 'ACCOUNT_ACTIVATED'::character varying, 'ACCOUNT_DEACTIVATED'::character varying])::text[])), + title varchar(100) not null, + message varchar(500) not null ); --- password_reset_tokens 테이블 -CREATE TABLE password_reset_tokens +alter table public.member_event + owner to testuser; + +create index idx_member_event_member_id + on public.member_event (member_id); + +create index idx_member_event_event_type + on public.member_event (event_type); + +create index idx_member_event_is_read + on public.member_event (is_read); + +create index idx_member_event_created_at + on public.member_event (created_at); + +create table public.member_detail ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6) NOT NULL, - expires_at TIMESTAMP(6) NOT NULL, - member_id BIGINT NOT NULL, - token VARCHAR(255) NOT NULL, - CONSTRAINT UK71lqwbwtklmljk3qlsugr1mig UNIQUE (token) + activated_at timestamp(6), + deactivated_at timestamp(6), + id bigint generated by default as identity + primary key, + image_id bigint, + registered_at timestamp(6), + profile_address varchar(15) + unique, + introduction varchar(500), + address varchar(255) ); -CREATE INDEX idx_password_reset_member_id ON password_reset_tokens (member_id); -CREATE INDEX idx_password_reset_token ON password_reset_tokens (token); +alter table public.member_detail + owner to testuser; --- post_collections 테이블 -CREATE TABLE post_collections +create table public.password_reset_tokens ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6) NOT NULL, - cover_image_url VARCHAR(500), - description TEXT, - name VARCHAR(100) NOT NULL, - owner_member_id BIGINT NOT NULL, - owner_nickname VARCHAR(255) NOT NULL, - privacy privacy_type NOT NULL, - status content_status NOT NULL, - updated_at TIMESTAMP(6) NOT NULL + created_at timestamp(6) not null, + expires_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + member_id bigint not null, + token varchar(255) not null + unique ); --- collection_posts 연결 테이블 -CREATE TABLE collection_posts +alter table public.password_reset_tokens + owner to testuser; + +create index idx_password_reset_token + on public.password_reset_tokens (token); + +create index idx_password_reset_member_id + on public.password_reset_tokens (member_id); + +create table public.post_collections ( - collection_id BIGINT NOT NULL, - post_id BIGINT NOT NULL, - display_order INTEGER NOT NULL, - PRIMARY KEY (collection_id, display_order), - CONSTRAINT FKr71d636l9ctei4h0nnkkkag3e FOREIGN KEY (collection_id) REFERENCES post_collections (id) + created_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + owner_member_id bigint not null, + updated_at timestamp(6) not null, + name varchar(100) not null, + cover_image_url varchar(500), + description text, + owner_nickname varchar(255) not null, + privacy varchar(255) not null constraint post_collections_privacy_check + check ((privacy)::text = ANY ((ARRAY ['PUBLIC'::character varying, 'PRIVATE'::character varying])::text[])), + status varchar(255) not null constraint post_collections_status_check + check ((status)::text = ANY ((ARRAY ['ACTIVE'::character varying, 'DELETED'::character varying])::text[])) ); -CREATE INDEX idx_collection_created_at ON post_collections (created_at); -CREATE INDEX idx_collection_owner_member_id ON post_collections (owner_member_id); -CREATE INDEX idx_collection_privacy_status ON post_collections (privacy, status); -CREATE INDEX idx_collection_status ON post_collections (status); +alter table public.post_collections + owner to testuser; --- post_hearts 테이블 -CREATE TABLE post_hearts +create table public.collection_posts ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMP(6) NOT NULL, - member_id BIGINT NOT NULL, - post_id BIGINT NOT NULL, - CONSTRAINT uk_post_heart_post_member UNIQUE (post_id, member_id) + display_order integer not null, + collection_id bigint not null constraint fkr71d636l9ctei4h0nnkkkag3e + references public.post_collections, + post_id bigint not null, + primary key (display_order, collection_id) ); -CREATE INDEX idx_post_heart_member_id ON post_hearts (member_id); -CREATE INDEX idx_post_heart_post_id ON post_hearts (post_id); +alter table public.collection_posts + owner to testuser; + +create index idx_collection_owner_member_id + on public.post_collections (owner_member_id); --- posts 테이블 -CREATE TABLE posts +create index idx_collection_privacy_status + on public.post_collections (privacy, status); + +create index idx_collection_created_at + on public.post_collections (created_at); + +create index idx_collection_status + on public.post_collections (status); + +create table public.post_hearts ( - id BIGSERIAL PRIMARY KEY, - author_introduction VARCHAR(500) NOT NULL, - author_member_id BIGINT NOT NULL, - author_nickname VARCHAR(20) NOT NULL, - comment_count INTEGER NOT NULL, - rating INTEGER NOT NULL, - content_text TEXT NOT NULL, - created_at TIMESTAMP(6) NOT NULL, - heart_count INTEGER NOT NULL, - restaurant_address VARCHAR(255) NOT NULL, - restaurant_latitude DOUBLE PRECISION NOT NULL, - restaurant_longitude DOUBLE PRECISION NOT NULL, - restaurant_name VARCHAR(100) NOT NULL, - status content_status NOT NULL, - updated_at TIMESTAMP(6) NOT NULL, - view_count INTEGER NOT NULL, - author_image_id BIGINT NOT NULL + created_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + member_id bigint not null, + post_id bigint not null, + constraint uk_post_heart_post_member + unique (post_id, member_id) ); --- post_images 연결 테이블 -CREATE TABLE post_images +alter table public.post_hearts + owner to testuser; + +create index idx_post_heart_post_id + on public.post_hearts (post_id); + +create index idx_post_heart_member_id + on public.post_hearts (member_id); + +create table public.posts ( - post_id BIGINT NOT NULL, - image_id BIGINT NOT NULL, - image_order INTEGER NOT NULL, - PRIMARY KEY (post_id, image_order), - CONSTRAINT FKo1i5va2d8de9mwq727vxh0s05 FOREIGN KEY (post_id) REFERENCES posts (id) + comment_count integer not null, + heart_count integer not null, + rating integer not null, + restaurant_latitude double precision not null, + restaurant_longitude double precision not null, + view_count integer not null, + author_image_id bigint not null, + author_member_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity + primary key, + updated_at timestamp(6) not null, + author_nickname varchar(20) not null, + restaurant_name varchar(100) not null, + author_introduction varchar(500) not null, + content_text text not null, + restaurant_address varchar(255) not null, + status varchar(255) not null constraint posts_status_check + check ((status)::text = ANY ((ARRAY ['PUBLISHED'::character varying, 'DELETED'::character varying])::text[])) ); -CREATE INDEX idx_post_author_id ON posts (author_member_id); -CREATE INDEX idx_post_created_at ON posts (created_at); -CREATE INDEX idx_post_restaurant_name ON posts (restaurant_name); -CREATE INDEX idx_post_status ON posts (status); +alter table public.posts + owner to testuser; --- trust_score 테이블 -CREATE TABLE trust_score +create table public.post_images ( - id BIGSERIAL PRIMARY KEY, - ad_review_count INTEGER, - trust_level trust_level, - real_money_review_count INTEGER, - trust_score INTEGER + image_order integer not null, + image_id bigint not null, + post_id bigint not null constraint fko1i5va2d8de9mwq727vxh0s05 + references public.posts, + primary key (image_order, post_id) ); --- members 테이블 -CREATE TABLE members +alter table public.post_images + owner to testuser; + +create index idx_post_author_id + on public.posts (author_member_id); + +create index idx_post_status + on public.posts (status); + +create index idx_post_created_at + on public.posts (created_at); + +create index idx_post_restaurant_name + on public.posts (restaurant_name); + +create table public.trust_score ( - id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL, - follower_count BIGINT NOT NULL, - following_count BIGINT NOT NULL, - nickname VARCHAR(20) NOT NULL, - password_hash VARCHAR(255) NOT NULL, - status member_status NOT NULL, - updated_at TIMESTAMP(6), - detail_id BIGINT NOT NULL, - trust_score_id BIGINT, - post_count BIGINT NOT NULL DEFAULT 0, - CONSTRAINT UK1mess2qywlgcnemr4r1ldm14c UNIQUE (email), - CONSTRAINT UK7q2ymaaa07yakjm7xgchec3v9 UNIQUE (detail_id), - CONSTRAINT UKmvf9gg1s6tceoxlifwa6aewkn UNIQUE (trust_score_id), - CONSTRAINT FKawl3m8yvo16wgowaam7fi3x1p FOREIGN KEY (detail_id) REFERENCES member_detail (id), - CONSTRAINT FKh5w2qukccwyysqrxaxe0hy93t FOREIGN KEY (trust_score_id) REFERENCES trust_score (id) + ad_review_count integer, + real_money_review_count integer, + trust_score integer, + id bigint generated by default as identity + primary key, + trust_level varchar(255) constraint trust_score_trust_level_check + check ((trust_level)::text = ANY + ((ARRAY ['BRONZE'::character varying, 'SILVER'::character varying, 'GOLD'::character varying, 'DIAMOND'::character varying])::text[])) ); -CREATE INDEX idx_member_email ON members (email); -CREATE INDEX idx_member_nickname ON members (nickname); -CREATE INDEX idx_member_status ON members (status); +alter table public.trust_score + owner to testuser; --- member_roles 테이블 -CREATE TABLE member_roles +create table public.members ( - member_id BIGINT NOT NULL, - role role_type, - CONSTRAINT FK431yrnsn5s4omvwjvl9dre1n0 FOREIGN KEY (member_id) REFERENCES members (id) + detail_id bigint not null + unique constraint fkawl3m8yvo16wgowaam7fi3x1p + references public.member_detail, + follower_count bigint not null, + following_count bigint not null, + id bigint generated by default as identity + primary key, + post_count bigint not null, + trust_score_id bigint + unique constraint fkh5w2qukccwyysqrxaxe0hy93t + references public.trust_score, + updated_at timestamp(6), + nickname varchar(20) not null, + email varchar(255) not null + unique, + password_hash varchar(255) not null, + status varchar(255) not null constraint members_status_check + check ((status)::text = ANY + ((ARRAY ['PENDING'::character varying, 'ACTIVE'::character varying, 'DEACTIVATED'::character varying])::text[])) ); --- member_event 테이블 -CREATE TABLE member_event +alter table public.members + owner to testuser; + +create table public.member_roles ( - id BIGSERIAL PRIMARY KEY, - member_id BIGINT NOT NULL, - event_type VARCHAR(30) NOT NULL, - title VARCHAR(100) NOT NULL, - message VARCHAR(500) NOT NULL, - related_member_id BIGINT, - related_post_id BIGINT, - related_comment_id BIGINT, - is_read BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP NOT NULL + member_id bigint not null constraint fk431yrnsn5s4omvwjvl9dre1n0 + references public.members, + role varchar(255) constraint member_roles_role_check + check ((role)::text = ANY + ((ARRAY ['USER'::character varying, 'ADMIN'::character varying, 'MANAGER'::character varying])::text[])) ); -CREATE INDEX idx_member_event_member_id ON member_event (member_id); -CREATE INDEX idx_member_event_created_at ON member_event (created_at); -CREATE INDEX idx_member_event_event_type ON member_event (event_type); -CREATE INDEX idx_member_event_is_read ON member_event (is_read); -CREATE INDEX idx_member_event_member_unread ON member_event (member_id, is_read, created_at); +alter table public.member_roles + owner to testuser; + +create index idx_member_email + on public.members (email); + +create index idx_member_nickname + on public.members (nickname); + +create index idx_member_status + on public.members (status); diff --git a/src/test/resources/application-devdb.yml b/src/test/resources/application-devdb.yml index 34f9eee..6a5a3f7 100644 --- a/src/test/resources/application-devdb.yml +++ b/src/test/resources/application-devdb.yml @@ -2,3 +2,8 @@ spring: jpa: hibernate: ddl-auto: create-drop + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: true diff --git a/src/test/resources/application-testdb.yml b/src/test/resources/application-testdb.yml index 34f9eee..6a5a3f7 100644 --- a/src/test/resources/application-testdb.yml +++ b/src/test/resources/application-testdb.yml @@ -2,3 +2,8 @@ spring: jpa: hibernate: ddl-auto: create-drop + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: true From 624e4583eb90620b88896bbc8a0320ae7c113e8f Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Wed, 31 Dec 2025 15:57:56 +0900 Subject: [PATCH 18/24] =?UTF-8?q?refactor:=20PostgreSQL=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - 모든 alter table owner to testuser 구문 제거 - 제약 조건 정의를 한 줄로 통합 및 가독성 향상 - 불필요한 줄바꿈 제거로 스크립트 간소화 효과: - 마이그레이션 스크립트 크기 감소 - 소유자 설정은 Flyway/애플리케이션 레벨에서 처리 - 코드 가독성 및 유지보수성 개선 --- src/main/resources/db/migration/V1__init.sql | 48 -------------------- 1 file changed, 48 deletions(-) diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 852d574..908b418 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -9,9 +9,6 @@ create table public.activation_tokens token varchar(255) not null ); -alter table public.activation_tokens - owner to testuser; - create table public.comments ( author_member_id bigint not null, @@ -28,9 +25,6 @@ create table public.comments check ((status)::text = ANY ((ARRAY ['PUBLISHED'::character varying, 'DELETED'::character varying])::text[])) ); -alter table public.comments - owner to testuser; - create index idx_comment_post_id on public.comments (post_id); @@ -64,9 +58,6 @@ create table public.follows unique (follower_id, following_id) ); -alter table public.follows - owner to testuser; - create index idx_follow_follower_id on public.follows (follower_id); @@ -97,9 +88,6 @@ create table public.friendships unique (member_id, friend_member_id) ); -alter table public.friendships - owner to testuser; - create index idx_friendship_member_id on public.friendships (member_id); @@ -128,9 +116,6 @@ create table public.images ((ARRAY ['POST_IMAGE'::character varying, 'PROFILE_IMAGE'::character varying, 'THUMBNAIL'::character varying])::text[])) ); -alter table public.images - owner to testuser; - create index idx_uploaded_by on public.images (uploaded_by); @@ -154,9 +139,6 @@ create table public.member_event message varchar(500) not null ); -alter table public.member_event - owner to testuser; - create index idx_member_event_member_id on public.member_event (member_id); @@ -183,9 +165,6 @@ create table public.member_detail address varchar(255) ); -alter table public.member_detail - owner to testuser; - create table public.password_reset_tokens ( created_at timestamp(6) not null, @@ -197,9 +176,6 @@ create table public.password_reset_tokens unique ); -alter table public.password_reset_tokens - owner to testuser; - create index idx_password_reset_token on public.password_reset_tokens (token); @@ -223,9 +199,6 @@ create table public.post_collections check ((status)::text = ANY ((ARRAY ['ACTIVE'::character varying, 'DELETED'::character varying])::text[])) ); -alter table public.post_collections - owner to testuser; - create table public.collection_posts ( display_order integer not null, @@ -235,9 +208,6 @@ create table public.collection_posts primary key (display_order, collection_id) ); -alter table public.collection_posts - owner to testuser; - create index idx_collection_owner_member_id on public.post_collections (owner_member_id); @@ -261,9 +231,6 @@ create table public.post_hearts unique (post_id, member_id) ); -alter table public.post_hearts - owner to testuser; - create index idx_post_heart_post_id on public.post_hearts (post_id); @@ -293,9 +260,6 @@ create table public.posts check ((status)::text = ANY ((ARRAY ['PUBLISHED'::character varying, 'DELETED'::character varying])::text[])) ); -alter table public.posts - owner to testuser; - create table public.post_images ( image_order integer not null, @@ -305,9 +269,6 @@ create table public.post_images primary key (image_order, post_id) ); -alter table public.post_images - owner to testuser; - create index idx_post_author_id on public.posts (author_member_id); @@ -332,9 +293,6 @@ create table public.trust_score ((ARRAY ['BRONZE'::character varying, 'SILVER'::character varying, 'GOLD'::character varying, 'DIAMOND'::character varying])::text[])) ); -alter table public.trust_score - owner to testuser; - create table public.members ( detail_id bigint not null @@ -358,9 +316,6 @@ create table public.members ((ARRAY ['PENDING'::character varying, 'ACTIVE'::character varying, 'DEACTIVATED'::character varying])::text[])) ); -alter table public.members - owner to testuser; - create table public.member_roles ( member_id bigint not null constraint fk431yrnsn5s4omvwjvl9dre1n0 @@ -370,9 +325,6 @@ create table public.member_roles ((ARRAY ['USER'::character varying, 'ADMIN'::character varying, 'MANAGER'::character varying])::text[])) ); -alter table public.member_roles - owner to testuser; - create index idx_member_email on public.members (email); From cfa661fde94cca2603db79fa49fb1c0b6a6c70a6 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Thu, 1 Jan 2026 17:13:14 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20OCI=20Object=20Storage=20Presigne?= =?UTF-8?q?d=20URL=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경 사항: ### OCI 인프라 구현 - OciObjectStorageConfig: OCI 설정 및 클라이언트 빈 등록 - OciPresignedUrlGenerator: Presigned URL 생성 로직 구현 - 의존성 추가: oci-java-sdk-objectstorage, commons-logging ### S3 프로파일 분리 - S3Config: @Profile("aws")로 변경하여 환경 분리 - 프로덕션 환경에서 OCI 사용 준비 ### 도메인 모델 개선 - Member: 이미지 관련 로직 개선 - 이미지 업로드 템플릿: OCI 지원 준비 ### 빌드 설정 - Gradle: OCI SDK 의존성 추가 - verification-metadata: 서명 검증 업데이트 효과: - AWS S3와 OCI Object Storage 동시 지원 - 클라우드 환경별 유연한 배포 가능 - 이미지 업로드 성능 및 보안 강화 --- .github/workflows/build.yml | 8 + build.gradle | 5 - gradle/verification-metadata.xml | 601 ++++++++++++++++++ .../oci/OciObjectStorageConfig.kt | 62 +- .../oci/OciPresignedUrlGenerator.kt | 80 ++- .../domain/member/Member.kt | 13 +- src/main/resources/application-prod.yml | 6 +- .../image/fragments/image-upload.html | 2 +- 8 files changed, 703 insertions(+), 74 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5630cd..41329e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -205,6 +205,8 @@ jobs: OCI_REGION: ${{ secrets.OCI_REGION }} OCI_TENANCY: ${{ secrets.OCI_TENANCY }} OCI_USERNAME: ${{ secrets.OCI_USERNAME }} + OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }} + OCI_USER_ID: ${{ secrets.OCI_USER_ID }} DB_URL: ${{ secrets.SPRING_DATASOURCE_URL }} DB_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }} DB_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }} @@ -246,6 +248,8 @@ jobs: -e SPRING_MAIL_PORT="${SPRING_MAIL_PORT}" \ -e SPRING_MAIL_USERNAME="${SPRING_MAIL_USERNAME}" \ -e SPRING_MAIL_PASSWORD="${SPRING_MAIL_PASSWORD}" \ + -e OCI_PRIVATE_KEY="${OCI_PRIVATE_KEY}" \ + -e OCI_USER_ID="${OCI_USER_ID}" \ -e APP_BASE_URL="${APP_BASE_URL}" \ ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest @@ -284,6 +288,8 @@ jobs: OCI_REGION: ${{ secrets.OCI_REGION }} OCI_TENANCY: ${{ secrets.OCI_TENANCY }} OCI_USERNAME: ${{ secrets.OCI_USERNAME }} + OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }} + OCI_USER_ID: ${{ secrets.OCI_USER_ID }} DB_URL: ${{ secrets.SPRING_DATASOURCE_URL }} DB_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }} DB_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }} @@ -325,6 +331,8 @@ jobs: -e SPRING_MAIL_PORT="${SPRING_MAIL_PORT}" \ -e SPRING_MAIL_USERNAME="${SPRING_MAIL_USERNAME}" \ -e SPRING_MAIL_PASSWORD="${SPRING_MAIL_PASSWORD}" \ + -e OCI_PRIVATE_KEY="${OCI_PRIVATE_KEY}" \ + -e OCI_USER_ID="${OCI_USER_ID}" \ -e APP_BASE_URL="${APP_BASE_URL}" \ ${OCI_REGION}.ocir.io/${OCI_TENANCY}/rmrt-app:latest diff --git a/build.gradle b/build.gradle index d52016f..6639609 100644 --- a/build.gradle +++ b/build.gradle @@ -60,11 +60,6 @@ dependencies { implementation 'software.amazon.awssdk:sts:2.39.3' // AWS STS (보안 토큰) implementation 'software.amazon.awssdk:auth:2.39.3' // AWS 인증 - // ==================== OCI ==================== - // OCI 서비스 연동 - implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.77.2' // OCI Object Storage - implementation 'com.oracle.oci.sdk:oci-java-sdk-common:3.77.2' // OCI 공통 라이브러리 - // ==================== Database ==================== // 데이터베이스 드라이버 및 마이그레이션 implementation 'org.flywaydb:flyway-core' // Flyway 데이터베이스 마이그레이션 diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1348eaf..0b5f6fe 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -264,6 +264,24 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + @@ -298,6 +316,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -491,6 +516,25 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + @@ -715,6 +759,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -1516,6 +1572,25 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + @@ -1535,6 +1610,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -1578,6 +1665,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -1880,6 +1974,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -1928,6 +2029,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -1935,6 +2048,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2294,6 +2414,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2308,6 +2435,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2419,6 +2553,216 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2476,6 +2820,227 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2646,6 +3211,30 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + @@ -3566,6 +4155,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt index 3517bc8..ca2ad03 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt @@ -1,37 +1,65 @@ package com.albert.realmoneyrealtaste.adapter.infrastructure.oci -import com.oracle.bmc.Region -import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider -import com.oracle.bmc.objectstorage.ObjectStorageClient import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile -import java.io.ByteArrayInputStream +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.S3Configuration +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import java.net.URI @Profile("prod") @Configuration @ConfigurationProperties(prefix = "oci.objectstorage") class OciObjectStorageConfig { - lateinit var tenantId: String - lateinit var userId: String - lateinit var fingerprint: String - lateinit var privateKey: String + lateinit var accessKeyId: String + lateinit var secretAccessKey: String lateinit var region: String lateinit var namespace: String lateinit var bucketName: String + private val endpoint: String + get() = "https://$namespace.compat.objectstorage.$region.oraclecloud.com" + @Bean - fun objectStorageClient(): ObjectStorageClient { - val provider = SimpleAuthenticationDetailsProvider.builder() - .tenantId(tenantId) - .userId(userId) - .fingerprint(fingerprint) - .privateKeySupplier { ByteArrayInputStream(privateKey.replace("\\n", "\n").toByteArray()) } + fun ociS3Client(): S3Client { + val credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey) + + return S3Client.builder() + .region(Region.of(region)) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build() + ) + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .build() + ) .build() + } - return ObjectStorageClient.builder() - .region(Region.fromRegionId(region)) - .build(provider) + @Bean + fun ociS3Presigner(): S3Presigner { + val credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey) + + return S3Presigner.builder() + .region(Region.of(region)) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build() + ) + .build() } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt index 755cc6f..a496615 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt @@ -3,21 +3,21 @@ package com.albert.realmoneyrealtaste.adapter.infrastructure.oci import com.albert.realmoneyrealtaste.application.image.dto.ImageUploadRequest import com.albert.realmoneyrealtaste.application.image.dto.PresignedPutResponse import com.albert.realmoneyrealtaste.application.image.required.PresignedUrlGenerator -import com.oracle.bmc.objectstorage.ObjectStorageClient -import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails -import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import java.time.Duration import java.time.Instant -import java.util.Date -import java.util.UUID @Profile("prod") @Component class OciPresignedUrlGenerator( - private val objectStorageClient: ObjectStorageClient, + private val s3Presigner: S3Presigner, private val ociConfig: OciObjectStorageConfig, @Value("\${image.upload.expiration-minutes:15}") private val uploadExpirationMinutes: Long, @Value("\${image.get.expiration-minutes:60}") private val getExpirationMinutes: Long, @@ -26,56 +26,54 @@ class OciPresignedUrlGenerator( override fun generatePresignedPutUrl(imageKey: String, request: ImageUploadRequest): PresignedPutResponse { val expiration = Instant.now().plus(Duration.ofMinutes(uploadExpirationMinutes)) - val parDetails = CreatePreauthenticatedRequestDetails.builder() - .name("upload-${UUID.randomUUID()}") - .objectName(imageKey) - .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) - .timeExpires(Date.from(expiration)) + val putObjectRequest = PutObjectRequest.builder() + .bucket(ociConfig.bucketName) + .key(imageKey) + .contentType(request.contentType) + .metadata( + mapOf( + "original-name" to request.fileName, + "file-size" to request.fileSize.toString(), + "width" to request.width.toString(), + "height" to request.height.toString() + ) + ) .build() - val parRequest = CreatePreauthenticatedRequestRequest.builder() - .namespaceName(ociConfig.namespace) - .bucketName(ociConfig.bucketName) - .createPreauthenticatedRequestDetails(parDetails) + val presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(uploadExpirationMinutes)) + .putObjectRequest(putObjectRequest) .build() - val response = objectStorageClient.createPreauthenticatedRequest(parRequest) - val parUrl = - "https://objectstorage.${ociConfig.region}.oraclecloud.com${response.preauthenticatedRequest.accessUri}" - - val metadata = mapOf( - "original-name" to request.fileName, - "content-type" to request.contentType, - "file-size" to request.fileSize.toString(), - "width" to request.width.toString(), - "height" to request.height.toString() - ) + val presignedRequest = s3Presigner.presignPutObject(presignRequest) return PresignedPutResponse( - uploadUrl = parUrl, + uploadUrl = presignedRequest.url().toString(), key = imageKey, expiresAt = expiration, - metadata = metadata, + metadata = mapOf( + "original-name" to request.fileName, + "content-type" to request.contentType, + "file-size" to request.fileSize.toString(), + "width" to request.width.toString(), + "height" to request.height.toString() + ), ) } override fun generatePresignedGetUrl(imageKey: String): String { - val expiration = Instant.now().plus(Duration.ofMinutes(getExpirationMinutes)) - - val parDetails = CreatePreauthenticatedRequestDetails.builder() - .name("read-${UUID.randomUUID()}") - .objectName(imageKey) - .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectRead) - .timeExpires(Date.from(expiration)) + val getObjectRequest = GetObjectRequest.builder() + .bucket(ociConfig.bucketName) + .key(imageKey) .build() - val parRequest = CreatePreauthenticatedRequestRequest.builder() - .namespaceName(ociConfig.namespace) - .bucketName(ociConfig.bucketName) - .createPreauthenticatedRequestDetails(parDetails) + val presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(getExpirationMinutes)) + .getObjectRequest(getObjectRequest) .build() - val response = objectStorageClient.createPreauthenticatedRequest(parRequest) - return "https://objectstorage.${ociConfig.region}.oraclecloud.com${response.preauthenticatedRequest.accessUri}" + val presignedRequest = s3Presigner.presignGetObject(presignRequest) + + return presignedRequest.url().toString() } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt index cf16b68..e1089cc 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt @@ -64,7 +64,7 @@ class Member protected constructor( ) : BaseEntity(), AggregateRoot { @Transient - private var domainEvents: MutableList = mutableListOf() + private var domainEvents: MutableList? = null @Embedded var email: Email = email @@ -279,17 +279,18 @@ class Member protected constructor( * 도메인 이벤트 추가 */ private fun addDomainEvent(event: MemberDomainEvent) { - domainEvents.add(event) + if (domainEvents == null) { + domainEvents = mutableListOf() + } + domainEvents!!.add(event) } /** * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. */ override fun drainDomainEvents(): List { - val events = domainEvents.toList() - domainEvents.clear() - - // 이벤트의 memberId를 실제 ID로 설정 + val events = domainEvents?.toList() ?: emptyList() + domainEvents?.clear() return events.map { it.withMemberId(requireId()) } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bab9b5d..1d9bf4d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -97,10 +97,8 @@ app: # S3 설정 oci: objectstorage: - tenant-id: ${OCI_TENANT_ID} - user-id: ${OCI_USER_ID} - fingerprint: ${OCI_FINGERPRINT} - private-key: ${OCI_PRIVATE_KEY} + access-key-id: ${OCI_USER_ID} + secret-access-key: ${OCI_PRIVATE_KEY} region: ap-chuncheon-1 namespace: axe5mmx0njmf bucket-name: rmrt diff --git a/src/main/resources/templates/image/fragments/image-upload.html b/src/main/resources/templates/image/fragments/image-upload.html index 25092d5..ae0bd95 100644 --- a/src/main/resources/templates/image/fragments/image-upload.html +++ b/src/main/resources/templates/image/fragments/image-upload.html @@ -264,7 +264,7 @@
if (!response.ok) { const errorText = await response.text(); console.error('S3 업로드 실패:', response.status, errorText); - throw new Error(`S3 업로드 실패: ${response.status}`); + throw new Error(`S3 업로드 실패: ${response.body}`); } } From 8a1c4b48a7d9f710bf6c035fa139ac0ef40ea747 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Thu, 1 Jan 2026 17:48:18 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix:=20CI/CD=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - 배포 스크립트에 OCI 관련 환경변수 추가: - OCI_REGION, OCI_TENANCY, OCI_USERNAME - SPRING_DATASOURCE_* 관련 변수 - SPRING_MAIL_* 관련 변수 - APP_BASE_URL 효과: - SSH 배포 시 필요한 모든 환경변수 전달 보장 - 컨테이너 실행 시 올바른 설정 값 주입 - 배포 안정성 및 일관성 확보 --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41329e0..e117995 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -219,7 +219,7 @@ jobs: host: ${{ secrets.OCI_VM_IP_1 }} username: ubuntu key: ${{ secrets.OCI_SSH_KEY_1 }} - envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL + envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,OCI_PRIVATE_KEY,OCI_USER_ID,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL script: | echo "🚀 Server 1 배포 시작..." @@ -302,7 +302,7 @@ jobs: host: ${{ secrets.OCI_VM_IP_2 }} username: ubuntu key: ${{ secrets.OCI_SSH_KEY_2 }} - envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL + envs: OCI_AUTH_TOKEN,OCI_REGION,OCI_TENANCY,OCI_USERNAME,OCI_PRIVATE_KEY,OCI_USER_ID,DB_URL,DB_USERNAME,DB_PASSWORD,SPRING_MAIL_HOST,SPRING_MAIL_PORT,SPRING_MAIL_USERNAME,SPRING_MAIL_PASSWORD,APP_BASE_URL script: | echo "🚀 Server 2 배포 시작..." From 7d8826e3091f9cd307489a4b59811d68fb839bed Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Thu, 1 Jan 2026 18:03:07 +0900 Subject: [PATCH 21/24] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=EC=B2=B4=ED=81=AC=20=EB=8C=80=EA=B8=B0=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A6=9D=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Server 1/2 헬스체크: 24회 → 100회로 증가 - 최대 대기 시간: 2분 → 8분 20초로 확장 효과: - OCI VM 초기 시작 시간 대비 충분한 헬스체크 시간 확보 - 컨테이너 시작 지연으로 인한 배포 실패 방지 - 안정적인 롤링 업데이트 지원 --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e117995..732a60c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -264,12 +264,12 @@ jobs: key: ${{ secrets.OCI_SSH_KEY_1 }} script: | echo "⏳ Server 1 헬스체크 대기..." - for i in {1..24}; do + for i in {1..100}; do if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then echo "✅ Server 1 헬스체크 통과!" exit 0 fi - echo "대기 중... ($i/24)" + echo "대기 중... ($i/100)" sleep 5 done echo "❌ Server 1 헬스체크 실패" @@ -347,12 +347,12 @@ jobs: key: ${{ secrets.OCI_SSH_KEY_2 }} script: | echo "⏳ Server 2 헬스체크 대기..." - for i in {1..24}; do + for i in {1..100}; do if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then echo "✅ Server 2 헬스체크 통과!" exit 0 fi - echo "대기 중... ($i/24)" + echo "대기 중... ($i/100)" sleep 5 done echo "❌ Server 2 헬스체크 실패" From c950a31be3f65599ccea9ab893baeda957593e58 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Thu, 1 Jan 2026 19:08:38 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20Actuator=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - Health 엔드포인트: - show-details: when-authorized 제거 (기본값 사용) - probes.enabled: false (Kubernetes 프로브 비활성화) 효과: - 불필요한 프로브 체크 제거로 성능 최적화 - 헬스체크 응답 시간 단축 - OCI VM 환경에 최적화된 설정 --- src/main/resources/application-prod.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1d9bf4d..82bc11a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -73,9 +73,8 @@ management: include: health,info,metrics endpoint: health: - show-details: when-authorized probes: - enabled: true + enabled: false # 로깅 설정 (프로덕션) logging: From 48396775ca47627dce114e9411f43596b3566280 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Thu, 1 Jan 2026 19:09:41 +0900 Subject: [PATCH 23/24] =?UTF-8?q?fix:=20Docker=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: - docker-build-and-push 잡에 조건 추가: - main 브랜치 푸시만 빌드 - src 폴더 변경 시에만 실행 효과: - 불필요한 Docker 빌드 방지로 CI/CD 속도 향상 - PR 시 빌드 자원 절약 - main 브랜치 배포만 보장하여 안정성 확보 --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 732a60c..f781c5c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -160,6 +160,7 @@ jobs: name: Docker 빌드 및 OCIR 푸시 runs-on: ubuntu-latest needs: [ check-src-changes, test, build, sonarqube ] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.check-src-changes.outputs.has_src_changes == 'true' steps: - name: 레포지토리를 체크아웃한다 From 4bb850e9041caff44489319f687868cf5efa03b1 Mon Sep 17 00:00:00 2001 From: ALbertIM0427 Date: Fri, 2 Jan 2026 14:55:04 +0900 Subject: [PATCH 24/24] =?UTF-8?q?refactor(domain):=20Member=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domainEvents 필드를 nullable에서 non-null로 변경하여 초기화 로직 단순화 - drainDomainEvents() 메서드에서 null 체크 제거로 코드 개선 - MemberTest에 도메인 이벤트 초기화 테스트 케이스 추가 - S3ConfigTest에서 불필요한 주석 및 어노테이션 제거 --- .../domain/member/Member.kt | 11 +- .../oci/OciObjectStorageConfigTest.kt | 202 +++++++++++++ .../oci/OciPresignedUrlGeneratorTest.kt | 280 ++++++++++++++++++ .../adapter/infrastructure/s3/S3ConfigTest.kt | 5 - .../domain/member/MemberTest.kt | 10 + 5 files changed, 496 insertions(+), 12 deletions(-) create mode 100644 src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt create mode 100644 src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGeneratorTest.kt diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt index e1089cc..c7018a4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt @@ -64,7 +64,7 @@ class Member protected constructor( ) : BaseEntity(), AggregateRoot { @Transient - private var domainEvents: MutableList? = null + private var domainEvents: MutableList = mutableListOf() @Embedded var email: Email = email @@ -279,18 +279,15 @@ class Member protected constructor( * 도메인 이벤트 추가 */ private fun addDomainEvent(event: MemberDomainEvent) { - if (domainEvents == null) { - domainEvents = mutableListOf() - } - domainEvents!!.add(event) + domainEvents.add(event) } /** * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. */ override fun drainDomainEvents(): List { - val events = domainEvents?.toList() ?: emptyList() - domainEvents?.clear() + val events = domainEvents.toList() + domainEvents.clear() return events.map { it.withMemberId(requireId()) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt new file mode 100644 index 0000000..ead292a --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt @@ -0,0 +1,202 @@ +package com.albert.realmoneyrealtaste.adapter.infrastructure.oci + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * OciObjectStorageConfig 단위 테스트 + * OCI Object Storage 클라이언트 설정이 올바르게 생성되는지 검증합니다. + */ +@DisplayName("OciObjectStorageConfig 단위 테스트") +class OciObjectStorageConfigTest { + + private lateinit var ociConfig: OciObjectStorageConfig + + @BeforeEach + fun setUp() { + ociConfig = OciObjectStorageConfig() + ociConfig.accessKeyId = "test-access-key" + ociConfig.secretAccessKey = "test-secret-key" + ociConfig.region = "us-ashburn-1" + ociConfig.namespace = "test-namespace" + ociConfig.bucketName = "test-bucket" + } + + @Nested + @DisplayName("S3Client 생성 테스트") + inner class S3ClientCreationTest { + + @Test + @DisplayName("S3Client - 성공 - 기본 설정으로 클라이언트 생성") + fun `ociS3Client - success - creates client with basic configuration`() { + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + assertNotNull(client.serviceClientConfiguration()) + } + + @Test + @DisplayName("S3Client - 성공 - 다른 리전으로 클라이언트 생성") + fun `ociS3Client - success - creates client with different region`() { + // Given + ociConfig.region = "ap-seoul-1" + + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + assertNotNull(client.serviceClientConfiguration()) + } + + @Test + @DisplayName("S3Client - 성공 - 자격 증명이 올바르게 설정됨") + fun `ociS3Client - success - credentials are properly configured`() { + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + assertNotNull(client.serviceClientConfiguration()) + } + + @Test + @DisplayName("S3Client - 성공 - endpoint가 올바르게 설정됨") + fun `ociS3Client - success - endpoint is correctly configured`() { + // Given + ociConfig.namespace = "my-namespace" + ociConfig.region = "eu-frankfurt-1" + + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + // endpoint는 private이라 직접 확인은 어렵지만 클라이언트 생성 성공으로 간접 확인 + assertNotNull(client.serviceClientConfiguration()) + } + } + + @Nested + @DisplayName("S3Presigner 생성 테스트") + inner class S3PresignerCreationTest { + + @Test + @DisplayName("S3Presigner - 성공 - 기본 설정으로 프리사이너 생성") + fun `ociS3Presigner - success - creates presigner with basic configuration`() { + // When + val presigner = ociConfig.ociS3Presigner() + + // Then + assertNotNull(presigner) + } + + @Test + @DisplayName("S3Presigner - 성공 - 다른 리전으로 프리사이너 생성") + fun `ociS3Presigner - success - creates presigner with different region`() { + // Given + ociConfig.region = "ca-toronto-1" + + // When + val presigner = ociConfig.ociS3Presigner() + + // Then + assertNotNull(presigner) + } + } + + @Nested + @DisplayName("설정 속성 테스트") + inner class ConfigurationPropertiesTest { + + @Test + @DisplayName("설정 속성 - 성공 - 모든 속성이 올바르게 설정됨") + fun `configuration properties - success - all properties are set correctly`() { + // Given & When + val config = ociConfig + + // Then + assertEquals("test-access-key", config.accessKeyId) + assertEquals("test-secret-key", config.secretAccessKey) + assertEquals("us-ashburn-1", config.region) + assertEquals("test-namespace", config.namespace) + assertEquals("test-bucket", config.bucketName) + } + } + + @Nested + @DisplayName("Endpoint 생성 테스트") + inner class EndpointTest { + + @Test + @DisplayName("Endpoint - 성공 - us-ashburn-1 리전의 endpoint가 올바르게 생성됨") + fun `endpoint - success - generates correct endpoint for us-ashburn-1`() { + // Given + ociConfig.namespace = "my-namespace" + ociConfig.region = "us-ashburn-1" + + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + // 실제 endpoint: https://my-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com + assertNotNull(client.serviceClientConfiguration()) + } + + @Test + @DisplayName("Endpoint - 성공 - ap-seoul-1 리전의 endpoint가 올바르게 생성됨") + fun `endpoint - success - generates correct endpoint for ap-seoul-1`() { + // Given + ociConfig.namespace = "seoul-namespace" + ociConfig.region = "ap-seoul-1" + + // When + val client = ociConfig.ociS3Client() + + // Then + assertNotNull(client) + // 실제 endpoint: https://seoul-namespace.compat.objectstorage.ap-seoul-1.oraclecloud.com + assertNotNull(client.serviceClientConfiguration()) + } + } + + @Nested + @DisplayName("다양한 리전 테스트") + inner class RegionTest { + + @Test + @DisplayName("리전 - 성공 - 모든 주요 OCI 리전에서 클라이언트 생성") + fun `region - success - creates clients in all major OCI regions`() { + val regions = listOf( + "us-ashburn-1", + "us-phoenix-1", + "ap-seoul-1", + "ap-tokyo-1", + "eu-frankfurt-1", + "ca-toronto-1" + ) + + regions.forEach { region -> + // Given + ociConfig.region = region + ociConfig.namespace = "test-namespace" + + // When + val client = ociConfig.ociS3Client() + val presigner = ociConfig.ociS3Presigner() + + // Then + assertNotNull(client) + assertNotNull(presigner) + assertNotNull(client.serviceClientConfiguration()) + } + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGeneratorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGeneratorTest.kt new file mode 100644 index 0000000..e9a9782 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGeneratorTest.kt @@ -0,0 +1,280 @@ +package com.albert.realmoneyrealtaste.adapter.infrastructure.oci + +import com.albert.realmoneyrealtaste.application.image.dto.ImageUploadRequest +import com.albert.realmoneyrealtaste.domain.image.ImageType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URI +import java.time.Instant +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OciPresignedUrlGeneratorTest { + + private lateinit var s3Presigner: S3Presigner + private lateinit var ociConfig: OciObjectStorageConfig + private lateinit var urlGenerator: OciPresignedUrlGenerator + + @BeforeEach + fun setUp() { + s3Presigner = mockk() + ociConfig = OciObjectStorageConfig() + ociConfig.bucketName = "test-bucket" + + urlGenerator = OciPresignedUrlGenerator( + s3Presigner = s3Presigner, + ociConfig = ociConfig, + uploadExpirationMinutes = 15, + getExpirationMinutes = 60 + ) + } + + @Nested + @DisplayName("Presigned PUT URL 생성 테스트") + inner class PresignedPutUrlTest { + + @Test + @DisplayName("generatePresignedPutUrl - 성공 - 기본 이미지 업로드 URL 생성") + fun `generatePresignedPutUrl - success - generates basic image upload URL`() { + // Given + val imageKey = "images/test-image.jpg" + val request = ImageUploadRequest( + contentType = "image/jpeg", + fileName = "test.jpg", + fileSize = 1024L, + width = 800, + height = 600, + imageType = ImageType.PROFILE_IMAGE, + ) + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg") + .toURL() + + every { + s3Presigner.presignPutObject(any()) + } returns mockPresignedRequest + + // When + val result = urlGenerator.generatePresignedPutUrl(imageKey, request) + + // Then + assertNotNull(result) + assertEquals(imageKey, result.key) + assertEquals( + "https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg", + result.uploadUrl + ) + assertEquals("test.jpg", result.metadata["original-name"]) + assertEquals("image/jpeg", result.metadata["content-type"]) + assertEquals("1024", result.metadata["file-size"]) + assertEquals("800", result.metadata["width"]) + assertEquals("600", result.metadata["height"]) + + // S3Presigner가 올바른 파라미터로 호출되었는지 검증 + verify { + s3Presigner.presignPutObject( + match { + it.putObjectRequest().bucket() == "test-bucket" && + it.putObjectRequest().key() == imageKey && + it.putObjectRequest().contentType() == "image/jpeg" && + it.putObjectRequest().metadata()["original-name"] == "test.jpg" && + it.putObjectRequest().metadata()["file-size"] == "1024" && + it.putObjectRequest().metadata()["width"] == "800" && + it.putObjectRequest().metadata()["height"] == "600" + } + ) + } + } + + @Test + @DisplayName("generatePresignedPutUrl - 성공 - PNG 이미지 업로드 URL 생성") + fun `generatePresignedPutUrl - success - generates PNG image upload URL`() { + // Given + val imageKey = "images/test-image.png" + val request = ImageUploadRequest( + contentType = "image/png", + fileName = "test.png", + fileSize = 2048L, + width = 1024, + height = 768, + imageType = ImageType.PROFILE_IMAGE, + ) + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.png") + .toURL() + + every { + s3Presigner.presignPutObject(any()) + } returns mockPresignedRequest + + // When + val result = urlGenerator.generatePresignedPutUrl(imageKey, request) + + // Then + assertNotNull(result) + assertEquals(imageKey, result.key) + assertEquals("image/png", result.metadata["content-type"]) + assertEquals("2048", result.metadata["file-size"]) + assertEquals("1024", result.metadata["width"]) + assertEquals("768", result.metadata["height"]) + } + + @Test + @DisplayName("generatePresignedPutUrl - 성공 - 만료 시간이 올바르게 설정됨") + fun `generatePresignedPutUrl - success - expiration time is correctly set`() { + // Given + val imageKey = "images/test-image.jpg" + val request = ImageUploadRequest( + contentType = "image/jpeg", + fileName = "test.jpg", + fileSize = 1024L, + width = 800, + height = 600, + imageType = ImageType.PROFILE_IMAGE, + ) + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg") + .toURL() + + every { + s3Presigner.presignPutObject(any()) + } returns mockPresignedRequest + + val beforeGeneration = Instant.now() + + // When + val result = urlGenerator.generatePresignedPutUrl(imageKey, request) + + val afterGeneration = Instant.now() + + // Then + assertTrue(result.expiresAt.isAfter(beforeGeneration.minusSeconds(1))) + assertTrue(result.expiresAt.isBefore(afterGeneration.plusSeconds(900))) // 15분 = 900초 + } + } + + @Nested + @DisplayName("Presigned GET URL 생성 테스트") + inner class PresignedGetUrlTest { + + @Test + @DisplayName("generatePresignedGetUrl - 성공 - 기본 이미지 다운로드 URL 생성") + fun `generatePresignedGetUrl - success - generates basic image download URL`() { + // Given + val imageKey = "images/test-image.jpg" + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg") + .toURL() + + every { + s3Presigner.presignGetObject(any()) + } returns mockPresignedRequest + + // When + val result = urlGenerator.generatePresignedGetUrl(imageKey) + + // Then + assertNotNull(result) + assertEquals( + "https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg", + result + ) + + // S3Presigner가 올바른 파라미터로 호출되었는지 검증 + verify { + s3Presigner.presignGetObject( + match { + it.getObjectRequest().bucket() == "test-bucket" && + it.getObjectRequest().key() == imageKey + } + ) + } + } + + @Test + @DisplayName("generatePresignedGetUrl - 성공 - 다른 키로 URL 생성") + fun `generatePresignedGetUrl - success - generates URL with different key`() { + // Given + val imageKey = "profile/user-avatar.png" + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/profile/user-avatar.png") + .toURL() + + every { + s3Presigner.presignGetObject(any()) + } returns mockPresignedRequest + + // When + val result = urlGenerator.generatePresignedGetUrl(imageKey) + + // Then + assertNotNull(result) + assertEquals( + "https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/profile/user-avatar.png", + result + ) + } + } + + @Nested + @DisplayName("만료 시간 설정 테스트") + inner class ExpirationTimeTest { + + @Test + @DisplayName("만료 시간 - 성공 - 커스텀 만료 시간으로 URL 생성") + fun `expiration time - success - generates URL with custom expiration time`() { + // Given + val customGenerator = OciPresignedUrlGenerator( + s3Presigner = s3Presigner, + ociConfig = ociConfig, + uploadExpirationMinutes = 30, + getExpirationMinutes = 120 + ) + + val imageKey = "images/test-image.jpg" + val request = ImageUploadRequest( + contentType = "image/jpeg", + fileName = "test.jpg", + fileSize = 1024L, + width = 800, + height = 600, + imageType = ImageType.PROFILE_IMAGE, + ) + + val mockPresignedRequest = mockk() + every { mockPresignedRequest.url() } returns URI.create("https://test-bucket.compat.objectstorage.us-ashburn-1.oraclecloud.com/images/test-image.jpg") + .toURL() + + every { + s3Presigner.presignPutObject(any()) + } returns mockPresignedRequest + + val beforeGeneration = Instant.now() + + // When + val result = customGenerator.generatePresignedPutUrl(imageKey, request) + + val afterGeneration = Instant.now() + + // Then + assertTrue(result.expiresAt.isAfter(beforeGeneration.minusSeconds(1))) + assertTrue(result.expiresAt.isBefore(afterGeneration.plusSeconds(1800))) // 30분 = 1800초 + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3ConfigTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3ConfigTest.kt index d9b1e96..9a17638 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3ConfigTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3ConfigTest.kt @@ -7,11 +7,6 @@ import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -/** - * S3Config 단위 테스트 - * S3 클라이언트 설정이 올바르게 생성되는지 검증합니다. - */ -@DisplayName("S3Config 단위 테스트") class S3ConfigTest { private lateinit var s3Config: S3Config diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt index 2925830..3f268dc 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt @@ -974,4 +974,14 @@ class MemberTest { assertTrue(originalEvents[1] is MemberActivatedDomainEvent) assertTrue(originalEvents[2] is MemberProfileUpdatedDomainEvent) } + + @Test + fun `drainDomainEvents - success - when domainEvents is null`() { + val member = createMember() + member.drainDomainEvents() + + val events = member.drainDomainEvents() + + assertTrue(events.isEmpty()) + } }