diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6246edf0..ca070b4b 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/.github/workflows/build.yml b/.github/workflows/build.yml index 6681f208..f781c5cc 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,10 +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 }} @@ -46,7 +42,7 @@ jobs: test: name: 단위 및 통합 테스트 runs-on: ubuntu-latest - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 @@ -81,11 +77,12 @@ jobs: path: | build/reports/ build/test-results/ + build: name: 애플리케이션 빌드 runs-on: ubuntu-latest needs: test - + steps: - name: 레포지토리를 체크아웃한다 uses: actions/checkout@v6 @@ -117,7 +114,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 +156,232 @@ 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: | + docker login "$OCI_REGION.ocir.io" \ + -u "${OCI_TENANCY}/${OCI_USERNAME}" \ + --password-stdin <<< "$OCI_AUTH_TOKEN" - 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에 푸시 + timeout-minutes: 30 env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ env.IMAGE_TAG }} + OCI_REGISTRY: ${{ secrets.OCI_REGION }}.ocir.io/${{ secrets.OCI_TENANCY }} + IMAGE_TAG: ${{ github.sha }} run: | - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + docker push $OCI_REGISTRY/rmrt-app --all-tags - - 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 + env: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + 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 }} + 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,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 배포 시작..." + + # OCIR 로그인 + 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 + + # 기존 컨테이너 중지 및 삭제 + sudo docker stop rmrt-app || true + sudo docker rm rmrt-app || true + + # 새 컨테이너 실행 + sudo docker run -d \ + --name rmrt-app \ + --restart unless-stopped \ + -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -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 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 + + # 오래된 이미지 정리 + sudo 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..100}; do + if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then + echo "✅ Server 1 헬스체크 통과!" + exit 0 + fi + echo "대기 중... ($i/100)" + 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 + env: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + 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 }} + 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,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 배포 시작..." + + # OCIR 로그인 + 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 + + # 기존 컨테이너 중지 및 삭제 + sudo docker stop rmrt-app || true + sudo docker rm rmrt-app || true + + # 새 컨테이너 실행 + sudo docker run -d \ + --name rmrt-app \ + --restart unless-stopped \ + -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -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 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 + + # 오래된 이미지 정리 + sudo 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..100}; do + if curl -s http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then + echo "✅ Server 2 헬스체크 통과!" + exit 0 + fi + echo "대기 중... ($i/100)" + 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/README.md b/README.md index e15f5749..d215f23b 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) ## 🚀 빠른 시작 diff --git a/build.gradle b/build.gradle index a17271e4..6639609c 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 2078fc5f..9946f91c 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/docs/TODO.md b/docs/TODO.md index 68a1a300..346fa6a6 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 시나리오 완성 | --- diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8a5697df..0b5f6fed 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"/> + + + + + + @@ -448,6 +473,111 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -629,6 +759,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -667,6 +809,40 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1251,6 +1427,37 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1365,6 +1572,25 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + @@ -1384,6 +1610,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -1427,6 +1665,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -1729,6 +1974,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -1777,6 +2029,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -1784,6 +2048,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2088,6 +2359,23 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + @@ -2126,6 +2414,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2140,6 +2435,13 @@ origin="Generated by Gradle"/> + + + + + + @@ -2206,6 +2508,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -2239,6 +2553,216 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2296,6 +2820,227 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2466,6 +3211,30 @@ origin="Generated by Gradle"/> + + + + + + + + + + + + + + + + + + + + @@ -3386,6 +4155,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -3667,6 +4448,18 @@ origin="Generated by Gradle"/> + + + + + + + + + + @@ -4928,6 +5721,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 new file mode 100644 index 00000000..ca2ad030 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfig.kt @@ -0,0 +1,65 @@ +package com.albert.realmoneyrealtaste.adapter.infrastructure.oci + +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 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 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 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() + } + + @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 new file mode 100644 index 00000000..a4966152 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciPresignedUrlGenerator.kt @@ -0,0 +1,79 @@ +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 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 + +@Profile("prod") +@Component +class OciPresignedUrlGenerator( + 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, +) : PresignedUrlGenerator { + + override fun generatePresignedPutUrl(imageKey: String, request: ImageUploadRequest): PresignedPutResponse { + val expiration = Instant.now().plus(Duration.ofMinutes(uploadExpirationMinutes)) + + 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 presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(uploadExpirationMinutes)) + .putObjectRequest(putObjectRequest) + .build() + + val presignedRequest = s3Presigner.presignPutObject(presignRequest) + + return PresignedPutResponse( + uploadUrl = presignedRequest.url().toString(), + key = imageKey, + expiresAt = expiration, + 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 getObjectRequest = GetObjectRequest.builder() + .bucket(ociConfig.bucketName) + .key(imageKey) + .build() + + val presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(getExpirationMinutes)) + .getObjectRequest(getObjectRequest) + .build() + + val presignedRequest = s3Presigner.presignGetObject(presignRequest) + + return presignedRequest.url().toString() + } +} 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 91499af2..b0eb7c69 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 7fb53347..343076da 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/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt index 54ad8d92..09c539a6 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 3592f003..6c73c95c 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 148a9498..32c0e699 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/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt index cf16b68f..c7018a46 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt @@ -288,8 +288,6 @@ class Member protected constructor( override fun drainDomainEvents(): List { val events = domainEvents.toList() domainEvents.clear() - - // 이벤트의 memberId를 실제 ID로 설정 return events.map { it.withMemberId(requireId()) } } diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 9b0b7404..4460bb47 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 400c42ca..82bc11aa 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: @@ -46,13 +52,7 @@ spring: auth: true starttls: enable: true - - # 보안 설정 - security: - user: - name: admin - password: ${ADMIN_PASSWORD} - + # 성능 설정 servlet: multipart: @@ -73,9 +73,8 @@ management: include: health,info,metrics endpoint: health: - show-details: when-authorized probes: - enabled: true + enabled: false # 로깅 설정 (프로덕션) logging: @@ -95,9 +94,10 @@ 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: + 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/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11c67d43..908b418e 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,298 +1,335 @@ -create table activation_tokens +create table public.activation_tokens ( - id bigint auto_increment + created_at timestamp(6), + expires_at timestamp(6) not null, + id bigint generated by default as identity 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) + member_id bigint not null + unique, + token varchar(255) not null ); -create table comments +create table public.comments ( - id bigint auto_increment + author_member_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity 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 + 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_post_id + on public.comments (post_id); -create index idx_comment_created_at - on comments (created_at); +create index idx_comment_author_id + on public.comments (author_member_id); create index idx_comment_parent_id - on comments (parent_comment_id); - -create index idx_comment_post_id - on comments (post_id); + on public.comments (parent_comment_id); create index idx_comment_status - on comments (status); + on public.comments (status); + +create index idx_comment_created_at + on public.comments (created_at); -create table follows +create table public.follows ( - id bigint auto_increment + 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, - 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) + 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); + on public.follows (follower_id); create index idx_follow_following_id - on follows (following_id); + on public.follows (following_id); create index idx_follow_status - on follows (status); + on public.follows (status); -create table friendships +create index idx_follow_created_at + on public.follows (created_at); + +create table public.friendships ( - id bigint auto_increment + 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, - 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) + 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_member_id + on public.friendships (member_id); create index idx_friendship_friend_member_id - on friendships (friend_member_id); + on public.friendships (friend_member_id); create index idx_friendship_friend_nickname - on friendships (friend_nickname); - -create index idx_friendship_member_id - on friendships (member_id); + on public.friendships (friend_nickname); create index idx_friendship_status - on friendships (status); + on public.friendships (status); -create table images +create index idx_friendship_created_at + on public.friendships (created_at); + +create table public.images ( - id bigint auto_increment + is_deleted boolean not null, + id bigint generated by default as identity 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) + 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_uploaded_by + on public.images (uploaded_by); + create index idx_image_type - on images (image_type); + on public.images (image_type); -create index idx_uploaded_by - on images (uploaded_by); +create table public.member_event +( + 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 +); + +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 member_detail +create table public.member_detail ( - id bigint auto_increment + activated_at timestamp(6), + deactivated_at timestamp(6), + id bigint generated by default as identity 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) + image_id bigint, + registered_at timestamp(6), + profile_address varchar(15) + unique, + introduction varchar(500), + address varchar(255) ); -create table password_reset_tokens +create table public.password_reset_tokens ( - id bigint auto_increment + created_at timestamp(6) not null, + expires_at timestamp(6) not null, + id bigint generated by default as identity 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) + token varchar(255) not null + unique ); -create index idx_password_reset_member_id - on password_reset_tokens (member_id); - create index idx_password_reset_token - on password_reset_tokens (token); + on public.password_reset_tokens (token); + +create index idx_password_reset_member_id + on public.password_reset_tokens (member_id); -create table post_collections +create table public.post_collections ( - id bigint auto_increment + created_at timestamp(6) not null, + id bigint generated by default as identity 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 + 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 table collection_posts +create table public.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) + 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_collection_created_at - on post_collections (created_at); - create index idx_collection_owner_member_id - on post_collections (owner_member_id); + on public.post_collections (owner_member_id); create index idx_collection_privacy_status - on post_collections (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 post_collections (status); + on public.post_collections (status); -create table post_hearts +create table public.post_hearts ( - id bigint auto_increment + created_at timestamp(6) not null, + id bigint generated by default as identity primary key, - created_at datetime(6) not null, - member_id bigint not null, - post_id bigint 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); + on public.post_hearts (post_id); -create table posts +create index idx_post_heart_member_id + on public.post_hearts (member_id); + +create table public.posts ( - id bigint auto_increment + 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, - 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 + 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 table post_images +create table public.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) + 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) ); create index idx_post_author_id - on posts (author_member_id); + on public.posts (author_member_id); + +create index idx_post_status + on public.posts (status); create index idx_post_created_at - on posts (created_at); + on public.posts (created_at); create index idx_post_restaurant_name - on posts (restaurant_name); - -create index idx_post_status - on posts (status); + on public.posts (restaurant_name); -create table trust_score +create table public.trust_score ( - id bigint auto_increment + ad_review_count integer, + real_money_review_count integer, + trust_score integer, + id bigint generated by default as identity 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 + 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 table members +create table public.members ( - id bigint auto_increment + 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, - 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) + 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[])) ); -create table member_roles +create table public.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 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_email - on members (email); + on public.members (email); create index idx_member_nickname - on members (nickname); + on public.members (nickname); create index idx_member_status - on members (status); + on public.members (status); 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 0ca7b8da..00000000 --- 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 ebf6c276..00000000 --- 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/main/resources/templates/image/fragments/image-upload.html b/src/main/resources/templates/image/fragments/image-upload.html index 25092d59..ae0bd953 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}`); } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt index c13ac6e8..b831b665 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/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/oci/OciObjectStorageConfigTest.kt new file mode 100644 index 00000000..ead292a6 --- /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 00000000..e9a9782d --- /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 d9b1e96b..9a176389 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/application/follow/provided/FollowReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt index eb2f3456..e64eaf08 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 f4dd47d0..d9ca59cf 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/domain/member/MemberTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt index 29258303..3f268dc7 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()) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/IntegrationConcurrencyTestBase.kt index 6d02b80c..c70955d2 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") } } } diff --git a/src/test/resources/application-devdb.yml b/src/test/resources/application-devdb.yml index bc275b92..6a5a3f70 100644 --- a/src/test/resources/application-devdb.yml +++ b/src/test/resources/application-devdb.yml @@ -1,4 +1,9 @@ spring: jpa: hibernate: - ddl-auto: validate + 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 34f9eeeb..6a5a3f70 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