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