diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..70971de --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# JWT — 256비트 이상 랜덤 문자열 권장 +JWT_SECRET=CHANGE_ME_BASE64_32_BYTES_MINIMUM_STRING +JWT_ACCESS_EXPIRATION=1800000 +JWT_REFRESH_EXPIRATION=604800000 + +# server port, 유레카 클라이언트 호스트명 +SERVER_PORT=도메인_서비스별_포트번호 +HOSTNAME=localhost + +# 아래의 환경변수들은 .env 파일에만 포함하여 빌드 시에만 사용되며, .env.runtime에서는 생략됨 +# 배포 환경에서도 공통 모듈을 적용하기 위해 Dockerfile에 추가해야 할 환경변수 +GPR_USER=GitHub_ID +GPR_TOKEN=GitHub_Personal_Access_Token(PAT) + +EUREKA_SERVER_URL=http://localhost:8761/eureka/ +ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans \ No newline at end of file diff --git a/.github/actions/deploy-vm/action.yaml b/.github/actions/deploy-vm/action.yaml new file mode 100644 index 0000000..a4ce2f3 --- /dev/null +++ b/.github/actions/deploy-vm/action.yaml @@ -0,0 +1,185 @@ +name: Deploy VM +description: GCP VM에 Docker Compose 기반으로 배포하고, 실패 시 stable 태그로 롤백합니다 + +inputs: + vm: + description: "VM 이름" + required: true + zone: + description: "VM 존" + required: true + gcp_project: + description: "GCP 프로젝트 ID" + required: true + ar_image_path: + description: "Artifact Registry 이미지 경로" + required: true + image_tag: + description: "배포할 이미지 태그" + required: true + ar_token: + description: "Artifact Registry 액세스 토큰" + required: true + work_dir: + description: "VM 내 작업 디렉토리" + required: true + health_retries: + description: "헬스체크 최대 재시도 횟수" + required: true + health_interval: + description: "헬스체크 재시도 간격(초)" + required: true + +runs: + using: composite + steps: + # TERMINATED 상태면 이후 step 전체 스킵 + - name: Check VM status + id: vm-status + shell: bash + run: | + STATUS=$(gcloud compute instances describe ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + if [ "$STATUS" = "NOT_FOUND" ]; then + echo "::error::${{ inputs.vm }} (${{ inputs.zone }}) 을(를) 찾을 수 없습니다." + exit 1 + fi + echo "status=$STATUS" >> "$GITHUB_OUTPUT" + echo "${{ inputs.vm }} → $STATUS" + + - name: Resolve rollback target + id: rollback + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + STABLE=$(gcloud artifacts docker tags list "${{ inputs.ar_image_path }}" \ + --project="${{ inputs.gcp_project }}" \ + --filter="tag=stable" \ + --format="value(tag)" 2>/dev/null | head -n1) + + if [ -n "$STABLE" ]; then + echo "tag=stable" >> "$GITHUB_OUTPUT" + else + echo "tag=" >> "$GITHUB_OUTPUT" + echo "No stable tag yet — rollback will be skipped if deploy fails." + fi + + - name: Sync compose file + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + gcloud compute scp deploy/docker-compose.prod.yaml \ + ${{ inputs.vm }}:/tmp/docker-compose.prod.yaml \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap + + gcloud compute scp deploy/promtail-config.yml \ + ${{ inputs.vm }}:/tmp/promtail-config.yml \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap + + - name: Deploy and verify + id: deploy_run + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1) + + gcloud compute ssh ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + + sudo mkdir -p ${{ inputs.work_dir }} + sudo mv /tmp/docker-compose.prod.yaml ${{ inputs.work_dir }}/docker-compose.prod.yaml + sudo mv /tmp/promtail-config.yml ${{ inputs.work_dir }}/promtail-config.yml + cd ${{ inputs.work_dir }} + + if ! sudo docker network inspect pgsg-network > /dev/null 2>&1; then + echo 'pgsg-network not found. Creating...' + sudo docker network create pgsg-network + fi + + sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml up -d + + echo 'Checking actuator health (max ${{ inputs.health_retries }} x ${{ inputs.health_interval }}s)...' + HEALTHY=0 + for i in \$(seq 1 ${{ inputs.health_retries }}); do + if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then + echo \"Actuator UP (attempt \$i)\" + HEALTHY=1 + break + fi + echo \"Waiting for service... (\$i/${{ inputs.health_retries }})\" + sleep ${{ inputs.health_interval }} + done + + if [ \"\$HEALTHY\" != '1' ]; then + echo 'Actuator health check failed.' + exit 1 + fi + " + + - name: Rollback + if: failure() && steps.deploy_run.conclusion == 'failure' && steps.rollback.outputs.tag != '' + shell: bash + run: | + set +e + REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1) + + attempt_rollback() { + gcloud compute ssh ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + cd ${{ inputs.work_dir }} + + sudo docker compose -f docker-compose.prod.yaml down --remove-orphans -t 5 || true + + sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml up -d + + for i in \$(seq 1 ${{ inputs.health_retries }}); do + if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then + echo \"Rollback container UP (attempt \$i)\" + exit 0 + fi + sleep ${{ inputs.health_interval }} + done + + echo 'Rollback container did not become healthy.' + exit 1 + " + } + + for attempt in 1 2 3; do + echo "::group::Rollback attempt $attempt/3 on ${{ inputs.vm }}" + attempt_rollback + RC=$? + echo "::endgroup::" + + [ $RC -eq 0 ] && echo "Rollback succeeded on attempt $attempt." && exit 0 + + echo "Rollback attempt $attempt failed (exit=$RC)." + [ $attempt -lt 3 ] && echo "Retrying in 15s..." && sleep 15 + done + + echo "::error::All rollback attempts failed on ${{ inputs.vm }}. Manual intervention required." + exit 1 \ No newline at end of file diff --git a/.github/actions/scale-vm/action.yaml b/.github/actions/scale-vm/action.yaml new file mode 100644 index 0000000..4930045 --- /dev/null +++ b/.github/actions/scale-vm/action.yaml @@ -0,0 +1,88 @@ +name: Scale VM +description: GCP VM 인스턴스를 기동하거나 중지합니다 + +inputs: + direction: + description: "'out' = 기동 | 'in' = 중지" + required: true + targets: + description: "공백 구분 'vm이름:zone' 목록 (예: server-2:asia-northeast3-b server-3:asia-northeast3-c)" + required: true + gcp_project: + description: "GCP 프로젝트 ID" + required: true + +runs: + using: composite + steps: + - name: Validate direction + shell: bash + run: | + if [[ "${{ inputs.direction }}" != "out" && "${{ inputs.direction }}" != "in" ]]; then + echo "::error::direction은 'out' 또는 'in' 이어야 합니다" + exit 1 + fi + + - name: Start / Stop VMs + shell: bash + run: | + DIRECTION="${{ inputs.direction }}" + ACTION=$([ "$DIRECTION" = "out" ] && echo "start" || echo "stop") + WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED") + + for ENTRY in ${{ inputs.targets }}; do + VM="${ENTRY%%:*}" + ZONE="${ENTRY##*:}" + + CURRENT=$(gcloud compute instances describe "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + + if [ "$CURRENT" = "NOT_FOUND" ]; then + echo "::error::$VM ($ZONE) 을 찾을 수 없습니다 — VM을 먼저 생성해 주세요" + exit 1 + fi + + if [ "$CURRENT" = "$WAIT_STATUS" ]; then + echo "✅ $VM is already $CURRENT — skipping" + continue + fi + + echo "▶ ${ACTION}ing $VM ($ZONE)..." + gcloud compute instances "$ACTION" "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" + done + + - name: Wait for target status + shell: bash + run: | + DIRECTION="${{ inputs.direction }}" + WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED") + + for ENTRY in ${{ inputs.targets }}; do + VM="${ENTRY%%:*}" + ZONE="${ENTRY##*:}" + + echo "⏳ Waiting for $VM to be $WAIT_STATUS..." + for attempt in $(seq 1 20); do + CURRENT=$(gcloud compute instances describe "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)") + + if [ "$CURRENT" = "$WAIT_STATUS" ]; then + echo "✅ $VM is $WAIT_STATUS" + break + fi + + if [ "$attempt" = "20" ]; then + echo "::error::$VM did not reach $WAIT_STATUS in time" + exit 1 + fi + + echo " $VM is $CURRENT... ($attempt/20)" + sleep 15 + done + done \ No newline at end of file diff --git a/.github/workflows/_build.yaml b/.github/workflows/_build.yaml new file mode 100644 index 0000000..7aa49c4 --- /dev/null +++ b/.github/workflows/_build.yaml @@ -0,0 +1,92 @@ +name: _build + +on: + workflow_call: + inputs: + force_apply_retention_policy: + type: boolean + default: false + outputs: + image_tag: + value: ${{ jobs.build-and-push.outputs.image_tag }} + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Resolve image metadata + id: meta + run: | + echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) + echo "registry_host=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" + + - name: Apply AR Image Retention Policy + if: | + contains(toJSON(github.event.commits.*.modified), 'deploy/ar-image-retention-policy.json') || + contains(toJSON(github.event.commits.*.added), 'deploy/ar-image-retention-policy.json') || + inputs.force_apply_retention_policy + run: | + REPO_NAME=$(echo "$AR_IMAGE_PATH" | cut -d/ -f3) + POLICY_FILE="deploy/ar-image-retention-policy.json" + + if [ ! -f "$POLICY_FILE" ]; then + echo "Error: $POLICY_FILE not found" + exit 1 + fi + + REGION=$(echo "$AR_IMAGE_PATH" | sed 's/-docker\.pkg\.dev.*//') + gcloud artifacts repositories set-cleanup-policies "$REPO_NAME" \ + --project="$GCP_PROJECT" \ + --location="${REGION}" \ + --policy="$POLICY_FILE" \ + --quiet + echo "Successfully synced retention policy from $POLICY_FILE" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Artifact Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.registry_host }} + username: 'oauth2accesstoken' + password: ${{ steps.auth.outputs.access_token }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }} + ${{ env.AR_IMAGE_PATH }}:latest + secrets: | + GPR_USER=${{ secrets.GPR_USER }} + GPR_TOKEN=${{ secrets.GPR_TOKEN }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/_deploy.yaml b/.github/workflows/_deploy.yaml new file mode 100644 index 0000000..c286366 --- /dev/null +++ b/.github/workflows/_deploy.yaml @@ -0,0 +1,62 @@ +name: _deploy + +on: + workflow_call: + inputs: + image_tag: + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + WORK_DIR: /opt/gateway + HEALTH_RETRIES: 30 + HEALTH_INTERVAL: 5 + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production + permissions: + contents: read + id-token: write + strategy: + fail-fast: true + max-parallel: 1 + matrix: + include: + - vm: gateway-server-1 + zone: asia-northeast3-a + - vm: gateway-server-2 + zone: asia-northeast3-b + - vm: gateway-server-3 + zone: asia-northeast3-c + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Deploy to ${{ matrix.vm }} + uses: ./.github/actions/deploy-vm + with: + vm: ${{ matrix.vm }} + zone: ${{ matrix.zone }} + gcp_project: ${{ env.GCP_PROJECT }} + ar_image_path: ${{ env.AR_IMAGE_PATH }} + image_tag: ${{ inputs.image_tag }} + ar_token: ${{ steps.auth.outputs.access_token }} + work_dir: ${{ env.WORK_DIR }} + health_retries: ${{ env.HEALTH_RETRIES }} + health_interval: ${{ env.HEALTH_INTERVAL }} \ No newline at end of file diff --git a/.github/workflows/_promote.yaml b/.github/workflows/_promote.yaml new file mode 100644 index 0000000..ce771ff --- /dev/null +++ b/.github/workflows/_promote.yaml @@ -0,0 +1,40 @@ +name: _promote + +on: + workflow_call: + inputs: + image_tag: + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + +jobs: + promote-stable: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: production + permissions: + contents: read + id-token: write + steps: + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Promote to :stable tag + run: | + echo "Promoting ${AR_IMAGE_PATH}:${{ inputs.image_tag }} -> ${AR_IMAGE_PATH}:stable" + gcloud artifacts docker tags add \ + "${AR_IMAGE_PATH}:${{ inputs.image_tag }}" \ + "${AR_IMAGE_PATH}:stable" \ + --quiet + echo "Stable tag updated." \ No newline at end of file diff --git a/.github/workflows/_scale.yaml b/.github/workflows/_scale.yaml new file mode 100644 index 0000000..90252ec --- /dev/null +++ b/.github/workflows/_scale.yaml @@ -0,0 +1,42 @@ +name: _scale + +on: + workflow_call: + inputs: + direction: + description: "'out' = 2, 3번 서버 기동 | 'in' = 2, 3번 서버 중지" + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + # VM 이름:존 목록 — 서버 추가 시 여기만 수정 + SCALE_TARGETS: "gateway-server-2:asia-northeast3-b gateway-server-3:asia-northeast3-c" + +jobs: + scale: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + # scale-vm action을 불러오기 위해 추가 + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Scale ${{ inputs.direction }} + uses: ./.github/actions/scale-vm + with: + direction: ${{ inputs.direction }} + targets: ${{ env.SCALE_TARGETS }} + gcp_project: ${{ env.GCP_PROJECT }} \ No newline at end of file diff --git a/.github/workflows/deploy-nginx.yaml b/.github/workflows/deploy-nginx.yaml new file mode 100644 index 0000000..980ba93 --- /dev/null +++ b/.github/workflows/deploy-nginx.yaml @@ -0,0 +1,115 @@ +name: Deploy Nginx + +on: + push: + branches: [ main, dev ] + paths: + - 'deploy/nginx/nginx.conf' # nginx.conf 변경 시에만 실행 + workflow_dispatch: # 수동 실행 (긴급 시) + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + NGINX_VM: nginx-server + NGINX_ZONE: asia-northeast3-a + +jobs: + deploy-nginx: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Check Nginx VM status + id: vm-status + run: | + STATUS=$(gcloud compute instances describe "$NGINX_VM" \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + echo "status=$STATUS" >> "$GITHUB_OUTPUT" + echo "$NGINX_VM → $STATUS" + + if [ "$STATUS" = "NOT_FOUND" ]; then + echo "::error::$NGINX_VM not found — VM을 먼저 생성해 주세요" + exit 1 + fi + + if [ "$STATUS" != "RUNNING" ]; then + echo "::error::$NGINX_VM is $STATUS — RUNNING 상태여야 합니다" + exit 1 + fi + + - name: Sync nginx.conf to VM + run: | + gcloud compute scp deploy/nginx/nginx.conf \ + $NGINX_VM:/tmp/nginx.conf \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap + + # 기존 conf를 bak으로 rename → sites-enabled에서 검증 → conf.d에 반영 → reload 실패 시 복구 → 성공 시 bak 제거 + - name: Apply nginx.conf and reload + run: | + gcloud compute ssh $NGINX_VM \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + set -e + + if [ -f /etc/nginx/conf.d/gateway.conf ]; then + sudo mv /etc/nginx/conf.d/gateway.conf /etc/nginx/conf.d/gateway.conf.bak + fi + + sudo cp /tmp/nginx.conf /etc/nginx/sites-enabled/gateway.tmp.conf + + if ! sudo nginx -t; then + echo '❌ nginx.conf validation failed — gateway.conf is unchanged' + sudo rm /etc/nginx/sites-enabled/gateway.tmp.conf + [ -f /etc/nginx/conf.d/gateway.conf.bak ] && sudo mv /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf + exit 1 + fi + + sudo rm /etc/nginx/sites-enabled/gateway.tmp.conf + sudo mv /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf 2>/dev/null || true + sudo cp /tmp/nginx.conf /etc/nginx/conf.d/gateway.conf + + if ! sudo nginx -s reload; then + [ -f /etc/nginx/conf.d/gateway.conf.bak ] && sudo cp /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf + sudo nginx -s reload || true + echo '❌ Nginx reload failed — rolled back to previous gateway.conf' + exit 1 + fi + + sudo rm -f /etc/nginx/conf.d/gateway.conf.bak + echo '✅ Nginx reloaded successfully' + " + + - name: Verify Nginx is running + run: | + gcloud compute ssh $NGINX_VM \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + STATUS=\$(sudo systemctl is-active nginx) + if [ \"\$STATUS\" = 'active' ]; then + echo '✅ Nginx is active' + else + echo '::error::Nginx is not active — status: '\$STATUS + exit 1 + fi + " \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..01499bd --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,97 @@ +name: Deploy Gateway Server + +on: + push: + branches: [ main, dev ] + workflow_dispatch: + inputs: + action: + description: "실행할 작업" + type: choice + default: deploy-only + options: + - scale-out-and-deploy + - deploy-only + - scale-in + force_apply_retention_policy: + description: "AR retention policy 적용" + type: boolean + default: false + +jobs: + build-and-push: + if: github.event.inputs.action != 'scale-in' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_build.yaml + with: + force_apply_retention_policy: ${{ inputs.force_apply_retention_policy || false }} + secrets: inherit + + scale-out: + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.action == 'scale-out-and-deploy' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: out + secrets: inherit + + deploy: + needs: [ build-and-push, scale-out ] + if: | + always() && + needs.build-and-push.result == 'success' && + (needs.scale-out.result == 'success' || needs.scale-out.result == 'skipped') && + github.event.inputs.action != 'scale-in' && + (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/_deploy.yaml + permissions: + contents: read + id-token: write + with: + image_tag: ${{ needs.build-and-push.outputs.image_tag }} + secrets: inherit + + scale-in: + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.action == 'scale-in' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: in + secrets: inherit + + scale-in-on-failure: + needs: [ scale-out, deploy ] + if: | + always() && + needs.scale-out.result == 'success' && + needs.deploy.result != 'success' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: in + secrets: inherit + + promote-stable: + needs: [ build-and-push, deploy ] + if: | + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + needs.deploy.result == 'success' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_promote.yaml + with: + image_tag: ${{ needs.build-and-push.outputs.image_tag }} + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2a7071..3a96a62 100644 --- a/.gitignore +++ b/.gitignore @@ -35,14 +35,14 @@ # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr + .idea/artifacts + .idea/compiler.xml + .idea/jarRepositories.xml + .idea/modules.xml + .idea/*.iml + .idea/modules + *.iml + *.ipr # CMake cmake-build-*/ @@ -220,4 +220,11 @@ gradle-app.setting # Java heap dump *.hprof +# environment variable +.env +.env.runtime + +# csv file data +**/csv/*.csv + # End of https://www.toptal.com/developers/gitignore/api/macos,windows,java,gradle,intellij+all,visualstudiocode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4232058..8ab1745 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,17 @@ FROM gradle:8.7-jdk21 AS build WORKDIR /app + +# Gradle 캐시를 활용하기 위해 의존성 파일만 먼저 복사 +COPY build.gradle settings.gradle ./ +RUN gradle build -x test --no-daemon > /dev/null 2>&1 || true + COPY . . -RUN gradle bootJar --no-daemon + +RUN --mount=type=secret,id=GPR_USER \ + --mount=type=secret,id=GPR_TOKEN \ + export GPR_USER=$(cat /run/secrets/GPR_USER) && \ + export GPR_TOKEN=$(cat /run/secrets/GPR_TOKEN) && \ + gradle bootJar --no-daemon FROM eclipse-temurin:21-jre WORKDIR /app diff --git a/README.md b/README.md new file mode 100644 index 0000000..250fcb3 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# PGSG Gateway Server + +PGSG 마이크로서비스 아키텍처의 강력한 보안 입구이자 통합 관측성(Observability) 허브 역할을 수행하는 게이트웨이 서버입니다. + +## 🌟 핵심 기능 (Core Capabilities) + +### 1. 보안 입구 정책 (Entry Gate Security) +- **Reactive WebFlux 기반 설계**: `GlobalFilter` 기반의 비동기 논블로킹 아키텍처를 채택하여 고성능 요청 처리를 보장하며, 모든 라우팅 경로에 대해 보안 검사를 강제합니다. +- **헤더 스푸핑(Spoofing) 원천 차단**: 요청 진입 시점에 외부 유입 헤더(`x-user-*`)를 즉시 제거(Sanitize)하고, 검증된 인증 데이터만 다시 주입하는 선제적 보안 시스템을 갖추고 있습니다. +- **실시간 이중 검증 하이브리드 인증**: 게이트웨이의 **로컬 JWT 서명 검증**과 유저 서비스의 **원격 블랙리스트 확인**을 결합하여 보안성을 극대화했습니다. +- **인증 성능 최적화 (Dual Caching)**: Caffeine Cache를 활용하여 토큰 검증 결과와 파싱된 Claims 정보를 각각 로컬 캐싱(TTL 30s)함으로써 원격 서비스 호출 부하를 획기적으로 낮췄습니다. + +### 2. 정밀한 분산 추적 (Distributed Tracing) +- **Trace ID 동기화 아키텍처**: Micrometer Tracing(Brave)이 생성한 표준 ID를 로그 및 요청 헤더(`X-Trace-Id`)와 100% 동기화합니다. +- **전 구간 가시성**: 게이트웨이부터 하위 마이크로서비스까지 하나의 고유 ID(Single Source of Truth)로 모든 실행 로그를 연결하여 복잡한 분산 환경에서의 장애 추적 시간을 단축했습니다. + +### 3. 표준화된 장애 및 에러 대응 +- **통합 에러 핸들링**: `JwtGatewayFilter` 내에서 발생하는 모든 인증 실패 및 예외 상황에 대해 공통 모듈의 `CommonResponse` 규격에 맞는 정교한 JSON 응답을 반환합니다. +- **비동기 장애 내성 (Resilience)**: `WebClient`에 커넥션 풀 및 Read/Write 타임아웃 설정을 적용하고, 원격 서비스 장애 시 패닉 없이 안전한 에러 응답을 반환(Fail-Safe)하도록 설계되었습니다. + +### 4. 시스템 최적화 (System Optimization) +- **의존성 격리 및 경량화**: DB를 사용하지 않는 게이트웨이 특성에 맞춰 JPA/DB 관련 자동 설정을 제외하고, 비동기 기반의 가벼운 실행 컨텍스트를 유지합니다. + +### 5. 인프라 자동화 및 관측성 (Infrastructure & Observability) +- **CI/CD 파이프라인**: GitHub Actions를 통해 Docker 이미지 빌드부터 GCP Artifact Registry 푸시, VM 배포까지 일련의 과정이 구축되어 있습니다. +- **안전한 배포 및 롤백**: 배포 후 Actuator 헬스체크 실패 시, 즉시 이전의 안정적인(`stable`) 태그 이미지로 자동 롤백하는 Fail-Safe 로직을 갖추고 있습니다. +- **유연한 스케일링 (Manual Trigger)**: GitHub Actions 워크플로우(`_scale.yaml`)를 통해 필요시 수동으로 게이트웨이 서버(2, 3번 노드)를 Scale-In / Scale-Out 할 수 있는 자동화 스크립트를 제공합니다. +- **통합 로그 수집 및 로드밸런싱**: Docker Compose를 통해 Promtail을 함께 배포하여 Loki로 로그를 중앙 집중화하며, Nginx(`least_conn`)를 활용해 다중 게이트웨이 인스턴스로 트래픽을 효율적으로 분산합니다. + +## 🛠 기술 스택 +- **Runtime**: Java 21 / Spring Boot 3.5.13 +- **Gateway**: Spring Cloud Gateway (WebFlux) +- **Security**: Spring Security 6.x (Reactive) +- **Tracing**: Micrometer Tracing (Brave) +- **Client**: Spring WebFlux WebClient +- **Cache**: Caffeine Cache + +## 📂 주요 문서 +- [상세 개선 보고서](./docs/gateway-server-improvement-summary.md): 기술적 해결 방안 및 리팩토링 상세 내역 +- [설정 및 작업 이력](./docs/gateway-server-setup-summary.md): Phase별 구축 과정 및 최종 검증 결과 + diff --git a/build.gradle b/build.gradle index c5cfa6a..f1c83d3 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,13 @@ java { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/89-49/common") + credentials { + username = findProperty('gpr.user') ?: System.getenv('GPR_USER') + password = findProperty('gpr.token') ?: System.getenv('GPR_TOKEN') + } + } } ext { @@ -22,16 +29,34 @@ ext { } dependencies { + + implementation('org.pgsg:common:0.3.2-SNAPSHOT') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' + exclude group: 'com.querydsl', module: 'querydsl-jpa' + exclude group: 'org.springdoc', module: 'springdoc-openapi-starter-webmvc-ui' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-web' + } + implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' - implementation 'org.springframework.cloud:spring-cloud-starter-config' - implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc' + implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' - implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'com.github.ben-manes.caffeine:caffeine' + + implementation 'io.micrometer:micrometer-tracing-bridge-brave' + + // jwt 관련 라이브러리 + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/deploy/.env.template b/deploy/.env.template new file mode 100644 index 0000000..54e2f43 --- /dev/null +++ b/deploy/.env.template @@ -0,0 +1,15 @@ +# deploy/.env.template + +# JWT 설정 +JWT_SECRET= +JWT_ACCESS_EXPIRATION= +JWT_REFRESH_EXPIRATION= + +# 유레카 클라이언트 호스트명 +HOSTNAME= + +# Eureka 서버 주소 +EUREKA_SERVER_URL= + +LOKI_URL= +ZIPKIN_ENDPOINT= \ No newline at end of file diff --git a/deploy/ar-image-retention-policy.json b/deploy/ar-image-retention-policy.json new file mode 100644 index 0000000..2daf3c0 --- /dev/null +++ b/deploy/ar-image-retention-policy.json @@ -0,0 +1,11 @@ +[ + { + "name": "keep-latest-3-images", + "action": { + "type": "Keep" + }, + "mostRecentVersions": { + "keepCount": 3 + } + } +] \ No newline at end of file diff --git a/deploy/docker-compose.prod.yaml b/deploy/docker-compose.prod.yaml new file mode 100644 index 0000000..1b7ba93 --- /dev/null +++ b/deploy/docker-compose.prod.yaml @@ -0,0 +1,31 @@ +services: + gateway-server: + container_name: gateway-server + image: ${AR_IMAGE_PATH}:${IMAGE_TAG} + restart: always + ports: + - "8090:8090" + env_file: + - .env + volumes: + - /opt/gateway/logs:/logs # 게이트웨이에서 생성한 로그 파일을 저장할 경로 + networks: + - pgsg-network + + promtail: + image: grafana/promtail:2.9.1 + container_name: promtail + restart: always + volumes: + - /opt/gateway/promtail-config.yml:/etc/promtail/config.yml + - /opt/gateway/logs:/logs # 로그 파일을 저장한 경로와 동일하게 지정 + # promtail 2.9.1 버전은 명시적으로 환경변수 플래그를 지정해야 promtail-config.yml에 설정한 환경변수값 적용 가능 + command: -config.file=/etc/promtail/config.yml -config.expand-env=true + env_file: + - .env + networks: + - pgsg-network + +networks: + pgsg-network: + external: true \ No newline at end of file diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..7307f6c --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,20 @@ +upstream gateway { + least_conn; + server 10.0.0.10:8090 max_fails=3 fail_timeout=10s; # gateway-server-1 + server 10.0.0.20:8090 max_fails=3 fail_timeout=10s; # gateway-server-2 + server 10.0.0.30:8090 max_fails=3 fail_timeout=10s; # gateway-server-3 +} + +server { + listen 80; + + # 게이트웨이 관련 리버스 프록시 설정 추가 + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_pass http://gateway; + } +} \ No newline at end of file diff --git a/deploy/promtail-config.yml b/deploy/promtail-config.yml new file mode 100644 index 0000000..05284bc --- /dev/null +++ b/deploy/promtail-config.yml @@ -0,0 +1,14 @@ +server: + http_listen_port: 9080 + +clients: + - url: ${LOKI_URL:-http://loki:3100/loki/api/v1/push} + +scrape_configs: + - job_name: gateway-server + static_configs: + - targets: + - localhost + labels: + job: gateway-server + __path__: /logs/*.log \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 23d6d02..e6b6afb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,13 +4,22 @@ services: build: context: . dockerfile: Dockerfile + secrets: + - GPR_USER + - GPR_TOKEN ports: - "8090:8090" - environment: - - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ + env_file: + - .env.runtime networks: - pgsg-network networks: pgsg-network: - external: true \ No newline at end of file + external: true + +secrets: + GPR_USER: + environment: GPR_USER + GPR_TOKEN: + environment: GPR_TOKEN diff --git a/docs/gateway-server-improvement-summary.md b/docs/gateway-server-improvement-summary.md new file mode 100644 index 0000000..47539b4 --- /dev/null +++ b/docs/gateway-server-improvement-summary.md @@ -0,0 +1,63 @@ +# Gateway Server 개선 및 보안 강화 상세 보고서 + +본 문서는 Gateway Server의 보안성, 안정성, 및 관측성 향상을 위해 진행된 주요 개선 사항 및 기술적 해결 방안을 상세히 기록합니다. + +--- + +## 1. 필터 아키텍처 개편: Servlet-based Filter 도입 + +### 배경 및 문제점 +Spring Cloud Gateway MVC 환경에서 표준 `HandlerFilterFunction`을 사용할 경우, 필터의 적용 여부가 라우팅 설정(YAML)에 의존하게 됩니다. 원격 Config 서버를 사용하거나 복잡한 라우팅 환경에서는 설정 실수로 인해 특정 경로에서 인증 필터가 누락될 수 있는 보안 허점이 존재했습니다. + +### 개선 사항 +- **`OncePerRequestFilter` 채택**: 서블릿 컨테이너(Tomcat) 레벨에서 동작하는 필터 방식을 도입하여, 게이트웨이 엔진의 라우팅 설정과 무관하게 모든 HTTP 요청에 대해 필터 실행을 강제했습니다. +- **최상위 우선순위 (`Ordered.HIGHEST_PRECEDENCE + 1`)**: 로깅 필터(`MdcLoggingFilter`) 직후에 실행되도록 보장하여, 보안 검사 이전에 추적 컨텍스트를 완벽히 준비했습니다. +- **Fail-Fast 보안 정책**: 유효하지 않은 모든 토큰(위조, 만료, 블랙리스트 등)에 대해 즉시 401 응답을 반환하고 요청을 종료하여 하위 서비스 자원을 보호합니다. + +--- + +## 2. 최적화된 이중 검증 시스템 (Performance Optimized) + +리소스 소모를 최소화하기 위해 검증 순서를 비용 효율적으로 재설계했습니다. + +1. **로컬 검증 (Local Validation - 1차)**: `TokenProvider`를 통해 JWT의 서명 위조 및 만료 여부를 게이트웨이 메모리 내에서 즉시 확인합니다. (가장 비용이 낮음) +2. **원격 검증 (Remote Verification - 2차)**: 로컬 검증을 통과한 유효한 토큰에 한해서만 유저 서비스 API 또는 로컬 캐시를 통해 블랙리스트 여부를 확인합니다. +3. **효율적 캐싱**: `AuthProviderImpl` 내부에 짧은 TTL(10~30s)의 캐시를 적용하여 실시간성과 성능 사이의 균형을 맞췄습니다. + +--- + +## 3. 분산 추적 및 관측성 (Distributed Tracing) + +### Trace ID 동기화 전략 (Zipkin Readiness) +분산 환경에서 로그의 정합성을 100% 보장하기 위해 **"단일 소스 원칙(Single Source of Truth)"**을 적용했습니다. +1. **Tracer 우선순위**: Zipkin(`Tracer`)이 생성한 실제 Trace ID를 최우선으로 가져옵니다. +2. **MDC 동기화**: 결정된 진짜 Trace ID를 `MDC.put("traceId", traceId)`를 통해 로그 시스템에 강제 동기화합니다. 이는 `MdcLoggingFilter`가 생성한 임시 ID를 진짜 ID로 교체하는 역할을 합니다. +3. **전구간 전파**: 동기화된 ID를 하위 서비스로 전달되는 `X-Trace-Id` 헤더에 주입하여 전체 트랜잭션을 하나의 ID로 연결합니다. + +--- + +## 4. 에러 핸들링 표준화 + +### 공통 에러 응답 (`ErrorResponse`) 적용 +- **에러 처리 일원화**: 필터 내부의 개별 응답 로직을 제거하고 `CustomAuthenticationEntryPoint`로 에러 처리를 위임하여 모든 인증 실패 응답 형식을 통일했습니다. +- **Trace ID 포함**: 모든 에러 응답 본문에 Trace ID를 포함시켜 장애 발생 시 로그 추적의 편의성을 극대화했습니다. + +--- + +## 5. 의존성 격리 및 최적화 (JPA Dependency Isolation) + +### 기술적 해결 방안 +- **명시적 자동 설정 제외**: `GatewayApplication`에서 `@ImportAutoConfiguration(exclude = AppCtx.class)`를 사용하여 불필요한 JPA 관련 설정을 완벽히 차단했습니다. +- **맞춤형 컨텍스트 구성 (`GatewayAppCtx`)**: 게이트웨이에 꼭 필요한 공통 기능(Feign, JSON, Error Properties 등)만 선택적으로 로드하여 컨텍스트를 경량화했습니다. + +--- + +## 6. 시스템 내결함성 및 통합 테스트 + +### Feign Client Fallback +- `AuthClientFallbackFactory`를 구현하여 인증 서비스 장애 시에도 시스템 전체가 마비되지 않도록 Fail-Safe 로직을 강화했습니다. + +### 통합 테스트 성공 +- **로그인/로그아웃/재발급**: 모든 핵심 인증 시나리오에 대해 Trace ID 추적과 함께 정상 동작 및 즉시 차단(Fail-Fast) 기능을 완벽히 검증했습니다. + +--- \ No newline at end of file diff --git a/docs/gateway-server-integration-test-report.md b/docs/gateway-server-integration-test-report.md new file mode 100644 index 0000000..fe49ecb --- /dev/null +++ b/docs/gateway-server-integration-test-report.md @@ -0,0 +1,50 @@ +# 게이트웨이 서버 통합 테스트 구현 보고서 + +이 문서는 게이트웨이 서버(`gateway-server`)의 핵심 로직인 인증 필터(`JwtGatewayFilter`) 및 보안 정책에 대한 통합 테스트 구현 내역을 정리합니다. + +## 1. 개요 +조만간 예정된 **WebFlux 기반 게이트웨이로의 리팩토링**을 대비하여, 현재 서블릿 기반 환경에서의 비즈니스 정합성(인증, 헤더 주입, 보안 등)을 보장하기 위한 통합 테스트를 작성했습니다. + +## 2. 테스트 환경 및 전략 +- **도구**: `JUnit 5`, `MockMvc`, `Mockito` +- **전략**: + - 외부 서비스(`user-service`) 호출은 `FeignClient`를 `@MockBean`으로 처리하여 격리된 테스트 수행. + - 게이트웨이가 하위 서비스로 전달하는 헤더를 검증하기 위해 테스트 내부 전용 `TestDownstreamController`를 정의. + - 나중에 WebFlux로 전환 시 테스트 코드 수정을 최소화할 수 있도록 비즈니스 로직(결과 헤더 검증) 위주로 구성. + +## 3. 테스트 시나리오 +작성된 `JwtGatewayIntegrationTest`는 다음 4가지 핵심 시나리오를 검증합니다. + +| 시나리오 | 검증 내용 | 결과 | +| :--- | :--- | :---: | +| **인증 성공 및 헤더 주입** | 유효한 토큰 요청 시 `x-user-id`, `x-user-roles` 헤더가 정상 주입되는지 확인 | **PASS** | +| **블랙리스트 차단** | 로그아웃된 토큰 요청 시 인증 서비스(`user-service`) 연동을 통해 401 응답 확인 | **PASS** | +| **화이트리스트 통과** | 로그인, 회원가입 등 인증 제외 경로가 토큰 없이 정상 동작하는지 확인 | **PASS** | +| **헤더 스푸핑 방지** | 클라이언트가 보낸 임의의 `x-user-` 헤더가 무시되고 인증 정보로 덮어써지는지 확인 | **PASS** | + +## 4. 기술적 이슈 및 해결 내역 + +### 4.1 TokenType 매칭 오류 (401 Unauthorized) +- **문제**: 테스트 코드에서 `tokenType`을 `"ACCESS"`로 주입했으나 필터에서 검증 실패. +- **원인**: 공통 모듈의 `TokenType` enum이 내부 필드 `value`를 기준으로 `"access"` (소문자)와 매칭하도록 구현되어 있었음. +- **해결**: Claims 생성 시 `TokenType.ACCESS.getValue()` 값인 `"access"`를 사용하도록 수정. + +### 4.2 응답 구조 불일치 (PathNotFoundException) +- **문제**: `$.userId` 경로로 JSON 결과를 찾지 못해 테스트 실패. +- **원인**: 프로젝트의 `CommonResponseAdvice`가 적용되어 모든 응답이 `{"success":..., "data":{...}}` 구조로 감싸짐. +- **해결**: JSON Path를 `$.data.userId`, `$.data.roles`로 수정하여 실제 데이터 영역을 검증하도록 변경. + +### 4.3 WebTestClient 의존성 이슈 +- **문제**: WebFlux 전환을 고려해 `WebTestClient`를 쓰려 했으나, MVC 환경에서 이를 사용하려면 `spring-webflux` 라이브러리가 테스트 클래스패스에 추가되어야 함. +- **결정**: 현재 프로젝트의 순수성을 유지하기 위해 추가 의존성 없이 `MockMvc`를 사용하되, 테스트 로직을 단순화하여 나중에 교체가 쉽도록 구현함. + +## 5. 향후 WebFlux 리팩토링 시 가이드 +현재 작성된 테스트 코드는 비즈니스 로직 검증에 집중되어 있으므로, WebFlux 마이그레이션 시 다음 부분만 수정하면 됩니다. + +1. **테스트 클라이언트 변경**: `MockMvc` 대신 `WebTestClient` 사용 (이때 `spring-boot-starter-webflux` 의존성 필요). +2. **바인딩 방식 수정**: `MockMvcWebTestClient` 대신 WebFlux용 `WebTestClient.bindToApplicationContext()` 사용. +3. **결과 검증**: 현재 `andExpect(jsonPath(...))` 문법은 `WebTestClient`에서도 거의 동일하게 지원하므로 로직 재사용 가능. + +--- +**작성일**: 2026-05-08 +**작성자**: Gemini CLI diff --git a/docs/gateway-server-setup-summary.md b/docs/gateway-server-setup-summary.md new file mode 100644 index 0000000..c9fc736 --- /dev/null +++ b/docs/gateway-server-setup-summary.md @@ -0,0 +1,48 @@ +# Gateway Server 구축 및 보안 아키텍처 작업 이력 (최종본) + +## 1. 프로젝트 개요 +본 프로젝트는 MSA 환경에서 요청의 진입점 역할을 수행하는 **Servlet 기반(WebMVC)의 Spring Cloud Gateway**입니다. 전역적인 보안 입구 컷, 실시간 블랙리스트 검증, 그리고 분산 추적(Tracing)을 핵심 아키텍처로 채택하고 있습니다. + +## 2. 작업 이력 및 기술적 진화 + +### 2.1 [Phase 1] 초기 인프라 설정 및 Config Server 통합 +- **DataSource 자동 설정 제외**: DB 미사용에 따른 기동 오류를 `DataSourceAutoConfiguration` 제외 설정을 통해 1차 해결. +- **Docker 컨테이너화**: Multi-stage 빌드 및 `.env`를 통한 환경 변수 주입 환경 구축. +- **Config Server 통합**: 원격 설정 서버로부터 라우팅 및 보안 설정을 동적으로 로드하도록 구성. + +### 2.2 [Phase 2] 의존성 격리 및 컨테이너 최적화 (JPA Isolation) +- **문제**: 공통 모듈(`common`) 로드 시 JPA 및 QueryDSL 관련 Bean이 강제 주입되어 기동 실패 현상 발생. +- **해결**: `GatewayApplication`에서 `@ImportAutoConfiguration(exclude = AppCtx.class)`를 적용하여 공통 메인 설정을 제외하고, `GatewayAppCtx`를 통해 게이트웨이에 필요한 Bean만 선택적으로 수용하도록 최적화. + +### 2.3 [Phase 3] 필터 아키텍처 확정 (Servlet-based Gatekeeping) +- **JwtGatewayFilter (OncePerRequestFilter)**: 라우팅 설정 의존성을 제거하고 보안 강제성을 확보하기 위해 서블릿 필터 방식을 최종 채택. +- **순서 조정**: `Ordered.HIGHEST_PRECEDENCE + 1`을 부여하여 로깅 준비(`MDC`) 후 즉시 보안 검사가 이루어지도록 순서 확정. +- **가독성 개선**: Guard Clauses 패턴을 적용하여 중첩 `if`문을 제거하고 로직을 평탄화. + +### 2.4 [Phase 4] 실시간 블랙리스트 검증 및 내결함성 (Resilience) +- **이중 검증**: 게이트웨이 로컬 검증(JWT 서명)과 `AuthProvider`를 통한 원격 실시간 검증(블랙리스트 여부)을 연동. +- **캐싱 전략**: `AuthProviderImpl`에 10~30초 단위의 짧은 로컬 캐시를 적용하여 인증 서비스 부하 감소와 실시간성 사이의 균형 확보. +- **Fallback 구현**: `AuthClientFallbackFactory`를 통해 인증 서버 장애 시에도 시스템 전체가 마비되지 않도록 Fail-Safe 로직 구축. + +### 2.5 [Phase 5] 관측성(Tracing) 및 에러 표준화 +- **Trace ID 동기화**: `Tracer`(Zipkin)가 생성한 진짜 ID를 `MDC` 및 헤더에 강제 동기화하여 분산 환경에서의 로그 일관성 100% 확보. +- **통합 에러 핸들링**: `CustomAuthenticationEntryPoint`를 필터 내부에서 직접 호출하도록 연동하여, 모든 인증 실패 시 공통 모듈의 `ErrorResponse` 규격에 맞는 JSON 응답을 보장. + +## 3. 핵심 클래스 현황 +- `GatewayApplication.java`: 애플리케이션 엔트리 포인트 및 자동 설정 제외 관리. +- `JwtGatewayFilter.java`: 입구 보안 및 헤더 주입을 담당하는 핵심 필터. +- `GatewayAppCtx.java`: 게이트웨이 전용 최적화 컨텍스트 구성. +- `AuthProviderImpl.java`: 캐싱 기반 실시간 토큰 검증기. +- `AuthClient.java`: 유저 서비스 규격(DTO)에 맞춘 Feign 통신 인터페이스. +- `CustomAuthenticationEntryPoint.java`: 통합 에러 응답 처리기. + +## 4. 최종 검증 결과 +1. **로그인 성공**: 유효한 Access Token 발급 및 Trace ID 생성 확인. +2. **권한 통과**: 발급된 토큰을 통한 게이트웨이 → 하위 서비스 호출 및 데이터 수신 성공. +3. **로그아웃 및 차단**: 로그아웃된 토큰 사용 시 게이트웨이 필터 및 서비스 최종 방어에 의해 **401 Unauthorized** 차단 성공. +4. **일관성 확인**: 게이트웨이 로그와 서비스 응답 내의 Trace ID가 완벽하게 일치함을 검증. + +## 5. 설계 원칙 (Design Principles) +- **보안**: 설정 실수로 인한 보안 구멍이 발생하지 않도록 서블릿 컨테이너 레벨에서 선제 방어. +- **관측성**: "Single Source of Truth(ID)" 원칙에 기반한 전 구간 추적 시스템 구축. +- **유연성**: 공통 모듈의 에러 규격 및 DTO를 완벽히 준수하여 프론트엔드 연동성을 극대화. diff --git a/script/result/result-test1-baseline-1.json b/script/result/result-test1-baseline-1.json new file mode 100644 index 0000000..2a7365a --- /dev/null +++ b/script/result/result-test1-baseline-1.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 29556, + "fails": 0, + "name": "status 200" + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 29524, + "fails": 32 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdErrTTY": true, + "testRunDurationMs": 126106.9503, + "isStdOutTTY": true + }, + "metrics": { + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 28871223, + "rate": 228942.36147426683 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.09858671623794212, + "min": 0, + "med": 0, + "max": 224.2047 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.007481795953912109, + "min": 0, + "med": 0, + "max": 8.9269, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 760.6497966505872, + "min": 10.5916, + "med": 417.75365, + "max": 3254.0217, + "p(90)": 2003.5394000000001, + "p(95)": 2261.3128 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9994586547570713, + "passes": 59080, + "fails": 32 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "min": 0, + "max": 300, + "value": 3 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "med": 419.56275, + "max": 3254.0217, + "p(90)": 2004.4157500000001, + "p(95)": 2262.459375, + "avg": 767.1553657971283, + "min": 10.5916 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "med": 417.75365, + "max": 3254.0217, + "p(90)": 2003.5394000000001, + "p(95)": 2261.3128, + "avg": 760.6497966505872, + "min": 10.5916 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.10065674571275464, + "min": 0, + "med": 0, + "max": 224.2047, + "p(90)": 0, + "p(95)": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 300, + "min": 300, + "max": 300 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 760.4196670752933, + "min": 9.6234, + "med": 417.56685000000004, + "max": 3254.0217, + "p(90)": 2003.1511, + "p(95)": 2261.1126999999997 + } + }, + "http_req_failed": { + "values": { + "rate": 0, + "passes": 0, + "fails": 29856 + }, + "type": "rate", + "contains": "default" + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "rate": 234.37249041141868, + "count": 29556 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.2226477793408362, + "min": 0, + "med": 0.033549999999999996, + "max": 1.6596, + "p(90)": 0.74945, + "p(95)": 0.8939 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 29856, + "rate": 236.7514235256231 + } + }, + "error_rate": { + "contains": "default", + "values": { + "rate": 0.0010826904858573555, + "passes": 32, + "fails": 29524 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 2262.5646, + "avg": 767.3446495906079, + "min": 10.5916, + "med": 419.5927, + "max": 3254.5665, + "p(90)": 2004.5055 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 13370747, + "rate": 106027.04266649767 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline-2.json b/script/result/result-test1-baseline-2.json new file mode 100644 index 0000000..4ff4a04 --- /dev/null +++ b/script/result/result-test1-baseline-2.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "fails": 0, + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 31575 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 30725, + "fails": 850 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "testRunDurationMs": 126778.6542, + "isStdOutTTY": true, + "isStdErrTTY": true + }, + "metrics": { + "iteration_duration": { + "contains": "time", + "values": { + "med": 381.1319, + "max": 5764.4988, + "p(90)": 1982.0961000000002, + "p(95)": 2488.29166, + "avg": 719.2527691053028, + "min": 10.7777 + }, + "type": "trend" + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "med": 375.4834, + "max": 5761.9723, + "p(90)": 1967.20308, + "p(95)": 2474.47919, + "avg": 705.3671656470617, + "min": 9.8929 + } + }, + "http_req_duration{expected_response:true}": { + "contains": "time", + "values": { + "p(95)": 2486.3049799999994, + "avg": 713.4340477584294, + "min": 10.7777, + "med": 378.1792, + "max": 5764.4988, + "p(90)": 1979.0498000000005 + }, + "type": "trend" + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + } + }, + "vus_max": { + "contains": "default", + "values": { + "max": 300, + "value": 300, + "min": 300 + }, + "type": "gauge" + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 8.05668734431375, + "min": 0, + "med": 0.3807, + "max": 2220.995, + "p(90)": 6.072900000000002, + "p(95)": 16.92079999999997 + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "passes": 62300, + "fails": 850, + "rate": 0.9865399841646872 + } + }, + "http_req_duration": { + "thresholds": { + "p(95)<3000": { + "ok": true + } + }, + "type": "trend", + "contains": "time", + "values": { + "med": 378.1792, + "max": 5764.4988, + "p(90)": 1979.0498000000005, + "p(95)": 2486.3049799999994, + "avg": 713.4340477584294, + "min": 10.7777 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 27, + "min": 0, + "max": 300 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0, + "avg": 0.061471730196078454, + "min": 0, + "med": 0, + "max": 30.9291, + "p(90)": 0 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "rate": 251192.8068723733, + "count": 31845886 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 31575, + "rate": 249.05612225689646 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 31875 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 31875, + "rate": 251.42245120945603 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.010194767058823526, + "min": 0, + "med": 0, + "max": 1.4257, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "max": 30.9291, + "p(90)": 0, + "p(95)": 0, + "avg": 0.05839844078431371, + "min": 0, + "med": 0 + } + }, + "data_sent": { + "contains": "data", + "values": { + "count": 14281064, + "rate": 112645.65072185472 + }, + "type": "counter" + }, + "error_rate": { + "contains": "default", + "values": { + "rate": 0.026920031670625493, + "passes": 850, + "fails": 30725 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "latency_ms": { + "values": { + "p(95)": 2487.9050799999995, + "avg": 719.0559004845587, + "min": 10.7777, + "med": 380.732, + "max": 5764.4988, + "p(90)": 1982.0908600000002 + }, + "type": "trend", + "contains": "time" + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline-3.json b/script/result/result-test1-baseline-3.json new file mode 100644 index 0000000..fab2127 --- /dev/null +++ b/script/result/result-test1-baseline-3.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 29945, + "fails": 0 + }, + { + "fails": 791, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 29154 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 128669.8229 + }, + "metrics": { + "checks": { + "type": "rate", + "contains": "default", + "values": { + "fails": 791, + "rate": 0.9867924528301887, + "passes": 59099 + } + }, + "http_req_blocked": { + "values": { + "p(95)": 0, + "avg": 0.062362638452636836, + "min": 0, + "med": 0, + "max": 48.7299, + "p(90)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 2510.6531999999997, + "avg": 751.6595685567881, + "min": 9.9928, + "med": 383.8901, + "max": 5738.9482, + "p(90)": 2001.29564 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.058840059513969235, + "min": 0, + "med": 0, + "max": 48.7299 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01201500413291453, + "min": 0, + "med": 0, + "max": 8.4046 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 30245, + "rate": 235.059000769014 + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 16.762799999999984, + "avg": 6.94494492643411, + "min": 0, + "med": 0.4212, + "max": 2038.285, + "p(90)": 6.410920000000003 + } + }, + "error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "rate": 0.026415094339622643, + "passes": 791, + "fails": 29154 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 30245 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 13545911, + "rate": 105276.51857054043 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 757.9021660043437, + "min": 9.9928, + "med": 386.528, + "max": 5738.9482, + "p(90)": 2003.29032, + "p(95)": 2512.7481599999996 + } + }, + "vus_max": { + "values": { + "min": 300, + "max": 300, + "value": 300 + }, + "type": "gauge", + "contains": "default" + }, + "vus": { + "contains": "default", + "values": { + "min": 0, + "max": 300, + "value": 24 + }, + "type": "gauge" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 758.1173075304725, + "min": 10.0073, + "med": 386.7922, + "max": 5739.4715, + "p(90)": 2003.3237600000002, + "p(95)": 2512.96022 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "med": 383.8901, + "max": 5738.9482, + "p(90)": 2001.29564, + "p(95)": 2510.6531999999997, + "avg": 751.6595685567881, + "min": 9.9928 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "rate": 232.72745174501983, + "count": 29945 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 30218723, + "rate": 234854.78039000236 + } + }, + "http_req_waiting": { + "contains": "time", + "values": { + "med": 380.3399, + "max": 5738.9069, + "p(90)": 1995.7995800000003, + "p(95)": 2504.5667599999997, + "avg": 744.7026086262207, + "min": 9.0535 + }, + "type": "trend" + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline-4.json b/script/result/result-test1-baseline-4.json new file mode 100644 index 0000000..bba3d95 --- /dev/null +++ b/script/result/result-test1-baseline-4.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 17441, + "fails": 0 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 17441, + "fails": 0 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 61693.9629 + }, + "metrics": { + "http_req_connecting": { + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.005711938447663604, + "min": 0, + "med": 0, + "max": 6.5243 + }, + "type": "trend" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 1, + "passes": 34882, + "fails": 0 + } + }, + "http_req_duration": { + "contains": "time", + "values": { + "avg": 18.54878851812194, + "min": 6.6769, + "med": 14.5145, + "max": 509.708, + "p(90)": 26.6481, + "p(95)": 44.6758 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + }, + "type": "trend" + }, + "http_req_tls_handshaking": { + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 17441, + "rate": 282.7018914033807 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 509.2297, + "p(90)": 25.8254, + "p(95)": 43.234, + "avg": 17.94140202919777, + "min": 6.5901, + "med": 13.9288 + } + }, + "error_rate": { + "contains": "default", + "values": { + "passes": 0, + "fails": 17441, + "rate": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 17741 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 10, + "min": 0, + "max": 10 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 7896249, + "rate": 127990.62710234812 + } + }, + "data_received": { + "contains": "data", + "values": { + "count": 18090704, + "rate": 293232.97045001463 + }, + "type": "counter" + }, + "latency_ms": { + "contains": "time", + "values": { + "med": 14.4121, + "max": 209.1893, + "p(90)": 24.9339, + "p(95)": 34.4135, + "avg": 17.055405057049523, + "min": 6.6769 + }, + "type": "trend" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "max": 209.1893, + "p(90)": 25.1525, + "p(95)": 34.6196, + "avg": 17.19121986698013, + "min": 6.6769, + "med": 14.5293 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.5992108731187618, + "min": 0, + "med": 0.3371, + "max": 98.4534, + "p(90)": 1.2546, + "p(95)": 1.7388 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "min": 10, + "max": 10, + "value": 10 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.007877509723239952, + "min": 0, + "med": 0, + "max": 6.5243 + } + }, + "http_req_sending": { + "values": { + "med": 0, + "max": 1.038, + "p(90)": 0, + "p(95)": 0, + "avg": 0.008175615805197009, + "min": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.54878851812194, + "min": 6.6769, + "med": 14.5145, + "max": 509.708, + "p(90)": 26.6481, + "p(95)": 44.6758 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 17741, + "rate": 287.5646038293319 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline.json b/script/result/result-test1-baseline.json new file mode 100644 index 0000000..6823a71 --- /dev/null +++ b/script/result/result-test1-baseline.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 179957, + "fails": 0 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 178679, + "fails": 1278 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 362737.3467 + }, + "metrics": { + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01076151883144622, + "min": 0, + "med": 0, + "max": 46.6748 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "rate": 496463.5255738888, + "count": 180085862 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 180257, + "rate": 496.93532149332447 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 525.052223189116, + "min": 8.3346, + "med": 145.608, + "max": 6965.0928, + "p(90)": 1512.8542599999998, + "p(95)": 1963.1354599999995 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.3346, + "med": 146.5089, + "max": 6965.0928, + "p(90)": 1513.0836, + "p(95)": 1963.53668, + "avg": 525.7460906655506 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "med": 0.0202, + "max": 4487.403, + "p(90)": 2.485979999999996, + "p(95)": 7.3518, + "avg": 7.675597563478827, + "min": 0 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 27, + "min": 0, + "max": 300 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01132287900053807, + "min": 0, + "med": 0, + "max": 12.0435 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 180257, + "rate": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 300, + "min": 300, + "max": 300 + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.996449151741805, + "passes": 358636, + "fails": 1278 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 1513.33932, + "p(95)": 1963.746219999999, + "avg": 525.9128594647588, + "min": 8.4281, + "med": 146.6424, + "max": 6965.0928 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 179957, + "rate": 496.1082767935458 + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.3346, + "med": 145.608, + "max": 6965.0928, + "p(90)": 1512.8542599999998, + "p(95)": 1963.1354599999995, + "avg": 525.052223189116 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 6964.7998, + "p(90)": 1507.14392, + "p(95)": 1944.0801, + "avg": 517.3653027466223, + "min": 8.3346, + "med": 139.5968 + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0071016965163900264, + "passes": 1278, + "fails": 178679 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 81152146, + "rate": 223721.50741653977 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.013610653123040918, + "min": 0, + "med": 0, + "max": 46.6748, + "p(90)": 0, + "p(95)": 0 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users-1.json b/script/result/result-test2-max-users-1.json new file mode 100644 index 0000000..9559f4b --- /dev/null +++ b/script/result/result-test2-max-users-1.json @@ -0,0 +1,250 @@ +{ + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 500838.9432 + }, + "metrics": { + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 116813, + "rate": 0 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 4459.4034, + "p(90)": 2587.244400000001, + "p(95)": 2983.4811599999994, + "avg": 1035.0400295583534, + "min": 9.2913, + "med": 722.7379 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 116696283, + "rate": 233001.61575774205 + } + }, + "data_sent": { + "values": { + "count": 52448771, + "rate": 104721.83066454485 + }, + "type": "counter", + "contains": "data" + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 25, + "min": 1, + "max": 500 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.21488554612928348, + "min": 0, + "med": 0.0228, + "max": 6.9092, + "p(90)": 0.7384800000000004, + "p(95)": 0.889 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496, + "avg": 1035.2617010221381 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 116813, + "rate": 233.2346587380947 + } + }, + "vus_max": { + "values": { + "value": 500, + "min": 500, + "max": 500 + }, + "type": "gauge", + "contains": "default" + }, + "http_req_tls_handshaking": { + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + }, + "type": "trend", + "contains": "time" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9785510050337294, + "passes": 228613, + "fails": 5011 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08289572735911248, + "min": 0 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "max": 13.0249, + "p(90)": 0, + "p(95)": 0, + "avg": 0.006785917663273801, + "min": 0, + "med": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08072584044584098 + } + }, + "error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "fails": 111801, + "rate": 0.04289798993254118, + "passes": 5011 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1035.2635310789904, + "min": 9.2913, + "med": 722.947, + "max": 4459.4613, + "p(90)": 2587.6791200000002, + "p(95)": 2983.81523 + } + }, + "iteration_duration": { + "values": { + "avg": 1035.5725441161828, + "min": 9.794, + "med": 723.1911, + "max": 4460.3517, + "p(90)": 2588.0131300000003, + "p(95)": 2984.32758 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "contains": "time", + "values": { + "avg": 1035.2617010221381, + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496 + }, + "type": "trend" + }, + "iterations": { + "contains": "default", + "values": { + "count": 116812, + "rate": 233.23266208824631 + }, + "type": "counter" + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 116812, + "fails": 0 + }, + { + "passes": 111801, + "fails": 5011, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce" + } + ] + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users-2.json b/script/result/result-test2-max-users-2.json new file mode 100644 index 0000000..8466a2d --- /dev/null +++ b/script/result/result-test2-max-users-2.json @@ -0,0 +1,313 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 27799, + "fails": 7, + "name": "status 200" + }, + { + "fails": 1466, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 26340 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdErrTTY": true, + "testRunDurationMs": 225933.9728, + "isStdOutTTY": true + }, + "metrics": { + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0, + "max": 0 + } + }, + "vus": { + "values": { + "value": 300, + "min": 0, + "max": 300 + }, + "type": "gauge", + "contains": "default" + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 990.1580736747441, + "min": 12.6129, + "med": 455.3909, + "max": 8261.5448, + "p(90)": 2479.66465, + "p(95)": 3011.6099249999997 + } + }, + "stage_300vu_error_rate": { + "values": { + "rate": 0.14138438880706922, + "passes": 288, + "fails": 1749 + }, + "type": "rate", + "contains": "default" + }, + "stage_100vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0007383427046061226, + "passes": 13, + "fails": 17594 + } + }, + "checks": { + "contains": "default", + "values": { + "rate": 0.9735129108825433, + "passes": 54139, + "fails": 1473 + }, + "type": "rate" + }, + "stage_200vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1978.9006157559413, + "min": 13.53, + "med": 1966.41355, + "max": 8261.5448, + "p(90)": 3258.87942, + "p(95)": 3983.9422499999996 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.00025173517459632465, + "passes": 7, + "fails": 27800 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 500, + "min": 500, + "max": 500 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 990.4860431309825, + "min": 13.6323, + "med": 455.7493, + "max": 8262.1058, + "p(90)": 2480.364, + "p(95)": 3011.814425 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 27807, + "rate": 123.075780306024 + } + }, + "http_req_receiving": { + "contains": "time", + "values": { + "med": 0.5486, + "max": 2978.7396, + "p(90)": 15.5291, + "p(95)": 60.75906999999928, + "avg": 17.46784033876358, + "min": 0 + }, + "type": "trend" + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 12619777, + "rate": 55856.03990229131 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 28683588, + "rate": 126955.62178863258 + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "fails": 26333, + "rate": 0.05297417823491333, + "passes": 1473 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "iterations": { + "contains": "default", + "values": { + "count": 27806, + "rate": 123.07135423416058 + }, + "type": "counter" + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.11899819469917652, + "min": 0, + "med": 0, + "max": 25.3413, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_sending": { + "values": { + "avg": 0.009234523681087493, + "min": 0, + "med": 0, + "max": 2.6128, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 972.7194616247732, + "min": 11.8901, + "med": 446.1265, + "max": 8243.4226, + "p(90)": 2463.877420000001, + "p(95)": 2999.11065 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 2479.7101900000002, + "p(95)": 3011.65271, + "avg": 990.3601952913644, + "min": 12.6129, + "med": 455.4952, + "max": 8261.5448 + } + }, + "http_req_blocked": { + "contains": "time", + "values": { + "avg": 0.12223022260581864, + "min": 0, + "med": 0, + "max": 25.3413, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "stage_200vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.14359225679980397, + "passes": 1172, + "fails": 6990 + }, + "type": "rate" + }, + "stage_100vu_latency": { + "values": { + "med": 345.1903, + "max": 4493.0597, + "p(90)": 975.3703, + "p(95)": 1241.89681, + "avg": 457.0563624581138, + "min": 13.4874 + }, + "type": "trend", + "contains": "time" + }, + "stage_300vu_latency": { + "contains": "time", + "values": { + "avg": 1636.3069204712822, + "min": 12.6129, + "med": 1477.4297, + "max": 6485.7606, + "p(90)": 3250.21576, + "p(95)": 3729.77304 + }, + "type": "trend" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "min": 12.6129, + "med": 455.4546, + "max": 8261.5448, + "p(90)": 2479.6575000000003, + "p(95)": 3011.6039099999994, + "avg": 990.1965364872129 + }, + "thresholds": { + "p(95)<3000": { + "ok": false + } + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users.json b/script/result/result-test2-max-users.json new file mode 100644 index 0000000..78b1dc5 --- /dev/null +++ b/script/result/result-test2-max-users.json @@ -0,0 +1,344 @@ +{ + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 129949.7773 + }, + "metrics": { + "stage_200vu_latency": { + "contains": "time", + "values": { + "max": 5744.9292, + "p(90)": 2472.1483200000002, + "p(95)": 2952.373679999998, + "avg": 669.5951519816439, + "min": 10.1064, + "med": 76.8302 + }, + "type": "trend" + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.07551442090599034, + "min": 0, + "med": 0, + "max": 19.8249, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_waiting": { + "contains": "time", + "values": { + "min": 8.3058, + "med": 333.457, + "max": 5743.6426, + "p(90)": 993.1078600000001, + "p(95)": 1469.1275400000002, + "avg": 453.97617327664796 + }, + "type": "trend" + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "med": 0.5437, + "max": 3249.3321, + "p(90)": 10.070660000000004, + "p(95)": 30.984760000000037, + "avg": 9.86365794302735, + "min": 0 + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.050441967717140664, + "passes": 1050, + "fails": 19766 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 20817, + "rate": 160.19265621319383 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 20987859, + "rate": 161507.4641609255 + } + }, + "stage_100vu_latency": { + "values": { + "max": 4730.8337, + "p(90)": 779.74658, + "p(95)": 1251.6379399999998, + "avg": 437.0904259948959, + "min": 8.462, + "med": 340.8044 + }, + "type": "trend", + "contains": "time" + }, + "vus_max": { + "values": { + "value": 500, + "min": 500, + "max": 500 + }, + "type": "gauge", + "contains": "default" + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 9435020, + "rate": 72605.12634983947 + } + }, + "iterations": { + "values": { + "count": 20816, + "rate": 160.18496093259563 + }, + "type": "counter", + "contains": "default" + }, + "stage_200vu_error_rate": { + "values": { + "passes": 1032, + "fails": 1365, + "rate": 0.4305381727158949 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + }, + "type": "rate", + "contains": "default" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "avg": 483.0746389450396, + "min": 12.5681, + "med": 343.5518, + "max": 5744.9292, + "p(90)": 1010.04806, + "p(95)": 1494.1072700000002 + } + }, + "http_req_connecting": { + "contains": "time", + "values": { + "avg": 0.07269279435077103, + "min": 0, + "med": 0, + "max": 19.8249, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.044675025219772305, + "passes": 930, + "fails": 19887 + } + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_100vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.0009772517509093871, + "passes": 18, + "fails": 18401 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "stage_500vu_error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 464.076828636626, + "min": 8.4621, + "med": 337.696, + "max": 5745.176, + "p(90)": 998.7164, + "p(95)": 1486.51165 + } + }, + "http_req_sending": { + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 2.3792, + "p(90)": 0, + "p(95)": 0, + "avg": 0.009415669885189989 + }, + "type": "trend" + }, + "latency_ms": { + "values": { + "med": 337.48785, + "max": 5744.9292, + "p(90)": 998.5424, + "p(95)": 1485.903225, + "avg": 463.8637651662176, + "min": 8.462 + }, + "type": "trend", + "contains": "time" + }, + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9747790161414297, + "passes": 40582, + "fails": 1050 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "med": 337.4754, + "max": 5744.9292, + "p(90)": 998.5280600000002, + "p(95)": 1485.9025199999999, + "avg": 463.84924688956073, + "min": 8.462 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 197, + "min": 0, + "max": 197 + } + } + }, + "root_group": { + "checks": [ + { + "passes": 19886, + "fails": 930, + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1" + }, + { + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 20696, + "fails": 120, + "name": "latency < 3000ms" + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [] + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-100vu-2.json b/script/result/result-test2-stage-100vu-2.json new file mode 100644 index 0000000..3d43c3c --- /dev/null +++ b/script/result/result-test2-stage-100vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "avg": 457.0563624581138, + "min": 13.4874, + "med": 345.1903, + "max": 4493.0597, + "p(90)": 975.3703, + "p(95)": 1241.89681 + }, + "errorRate": { + "rate": 0.0007383427046061226, + "passes": 13, + "fails": 17594 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-100vu.json b/script/result/result-test2-stage-100vu.json new file mode 100644 index 0000000..42794ed --- /dev/null +++ b/script/result/result-test2-stage-100vu.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "min": 8.462, + "med": 340.8044, + "max": 4730.8337, + "p(90)": 779.74658, + "p(95)": 1251.6379399999998, + "avg": 437.0904259948959 + }, + "errorRate": { + "rate": 0.0009772517509093871, + "passes": 18, + "fails": 18401 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-200vu-2.json b/script/result/result-test2-stage-200vu-2.json new file mode 100644 index 0000000..707892d --- /dev/null +++ b/script/result/result-test2-stage-200vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 1978.9006157559413, + "min": 13.53, + "med": 1966.41355, + "max": 8261.5448, + "p(90)": 3258.87942, + "p(95)": 3983.9422499999996 + }, + "errorRate": { + "rate": 0.14359225679980397, + "passes": 1172, + "fails": 6990 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-200vu.json b/script/result/result-test2-stage-200vu.json new file mode 100644 index 0000000..3f36fd2 --- /dev/null +++ b/script/result/result-test2-stage-200vu.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "p(90)": 2472.1483200000002, + "p(95)": 2952.373679999998, + "avg": 669.5951519816439, + "min": 10.1064, + "med": 76.8302, + "max": 5744.9292 + }, + "errorRate": { + "rate": 0.4305381727158949, + "passes": 1032, + "fails": 1365 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-300vu-2.json b/script/result/result-test2-stage-300vu-2.json new file mode 100644 index 0000000..77f83ad --- /dev/null +++ b/script/result/result-test2-stage-300vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "p(90)": 3250.21576, + "p(95)": 3729.77304, + "avg": 1636.3069204712822, + "min": 12.6129, + "med": 1477.4297, + "max": 6485.7606 + }, + "errorRate": { + "rate": 0.14138438880706922, + "passes": 288, + "fails": 1749 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-300vu.json b/script/result/result-test2-stage-300vu.json new file mode 100644 index 0000000..5924cc3 --- /dev/null +++ b/script/result/result-test2-stage-300vu.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "min": 5.3627, + "med": 63.597849999999994, + "max": 8747.4565, + "p(90)": 1748.8384300000002, + "p(95)": 2531.22286, + "avg": 408.5322729521228 + }, + "errorRate": { + "passes": 50941, + "fails": 8413, + "rate": 0.8582572362435557 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-400vu.json b/script/result/result-test2-stage-400vu.json new file mode 100644 index 0000000..2705528 --- /dev/null +++ b/script/result/result-test2-stage-400vu.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "p(90)": 131.39064999999997, + "p(95)": 1483.515249999999, + "avg": 256.0315454122147, + "min": 3.9399, + "med": 66.975, + "max": 15002.5695 + }, + "errorRate": { + "passes": 120020, + "fails": 6904, + "rate": 0.9456052440830733 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-500vu.json b/script/result/result-test2-stage-500vu.json new file mode 100644 index 0000000..25836d3 --- /dev/null +++ b/script/result/result-test2-stage-500vu.json @@ -0,0 +1,16 @@ +{ + "vu": 500, + "latency": { + "avg": 849.5360995466923, + "min": 5.3775, + "med": 137.7371, + "max": 12597.1923, + "p(90)": 3533.4163, + "p(95)": 4734.8399 + }, + "errorRate": { + "rate": 0.9146985158659519, + "passes": 44190, + "fails": 4121 + } +} \ No newline at end of file diff --git a/script/result/result-test3-max-users-revised-1.json b/script/result/result-test3-max-users-revised-1.json new file mode 100644 index 0000000..0b508c0 --- /dev/null +++ b/script/result/result-test3-max-users-revised-1.json @@ -0,0 +1,368 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 61283, + "fails": 0 + }, + { + "fails": 3108, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 58175 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 419958.1803 + }, + "metrics": { + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 61783, + "rate": 147.1170295000919 + } + }, + "http_req_tls_handshaking": { + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1147.446356384451, + "min": 7.4797, + "med": 984.0852, + "max": 7228.3906, + "p(90)": 2477.4257199999997, + "p(95)": 3000.9405199999997 + }, + "thresholds": { + "p(95)<3000": { + "ok": false + } + } + }, + "http_req_sending": { + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 14.0986, + "p(90)": 0, + "p(95)": 0, + "avg": 0.009921591699982203 + }, + "type": "trend" + }, + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "fails": 14146, + "rate": 0.06928087374169353, + "passes": 1053 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "stage_400vu_latency": { + "values": { + "avg": 2184.037259406897, + "min": 9.1254, + "med": 2037.2873, + "max": 7228.3906, + "p(90)": 3753.29586, + "p(95)": 4260.74442 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "min": 7.4797, + "med": 984.0852, + "max": 7228.3906, + "p(90)": 2477.4257199999997, + "p(95)": 3000.9405199999997, + "avg": 1147.446356384451 + } + }, + "checks": { + "values": { + "rate": 0.9746422335721162, + "passes": 119458, + "fails": 3108 + }, + "type": "rate", + "contains": "default" + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1155.9542654912543, + "min": 7.4797, + "med": 987.587, + "max": 7228.3906, + "p(90)": 2479.3944600000004, + "p(95)": 3002.9133899999997 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 61283, + "rate": 145.92643476124712 + } + }, + "stage_100vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "passes": 6, + "fails": 19960, + "rate": 0.0003005108684764099 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_300vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "med": 1506.8908, + "max": 6242.6114, + "p(90)": 2749.51864, + "p(95)": 3239.5377799999997, + "avg": 1596.0461405552958, + "min": 8.2033 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 61783 + } + }, + "data_sent": { + "contains": "data", + "values": { + "count": 27890768, + "rate": 66413.20328627969 + }, + "type": "counter" + }, + "error_rate": { + "values": { + "passes": 3108, + "fails": 58175, + "rate": 0.050715532855767506 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + }, + "type": "rate", + "contains": "default" + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.19134662129314536, + "passes": 1968, + "fails": 8317 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "max": 62.1971, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08041240308822814, + "min": 0, + "med": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 500, + "min": 500, + "max": 500 + } + }, + "stage_200vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "med": 998.0098, + "max": 4994.2516, + "p(90)": 1730.8779200000001, + "p(95)": 1993.4768399999998, + "avg": 1015.4606896671461, + "min": 8.8935 + } + }, + "stage_500vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_100vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 402.7560677802262, + "min": 7.4797, + "med": 286.3904, + "max": 4238.3995, + "p(90)": 956.65725, + "p(95)": 1030.56165 + } + }, + "http_req_connecting": { + "values": { + "avg": 0.07668177168476763, + "min": 0, + "med": 0, + "max": 62.1971, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.60727075894681, + "min": 0, + "med": 0.3768, + "max": 4241.1788, + "p(90)": 6.493060000000003, + "p(95)": 39.0664499999999 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 61681644, + "rate": 146875.68165939118 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 400, + "min": 0, + "max": 400 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 2479.56598, + "p(95)": 3003.1990199999996, + "avg": 1156.1535051107078, + "min": 8.3165, + "med": 987.6403, + "max": 7228.395 + } + }, + "http_req_waiting": { + "contains": "time", + "values": { + "p(90)": 2466.39102, + "p(95)": 2990.5399399999997, + "avg": 1128.8291640338048, + "min": 7.2059, + "med": 976.738, + "max": 7227.6546 + }, + "type": "trend" + }, + "stage_200vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.005115897176782669, + "passes": 81, + "fails": 15752 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test3-max-users-revised.json b/script/result/result-test3-max-users-revised.json new file mode 100644 index 0000000..fd74210 --- /dev/null +++ b/script/result/result-test3-max-users-revised.json @@ -0,0 +1,380 @@ +{ + "root_group": { + "checks": [ + { + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 245556, + "fails": 1065, + "name": "status 200", + "path": "::status 200" + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 244051, + "fails": 2570 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [] + }, + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 553720.9149 + }, + "metrics": { + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0, + "avg": 0.13912320401746525, + "min": 0, + "med": 0, + "max": 198.5864, + "p(90)": 0 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 490.3511144995734, + "min": 5.0978, + "med": 69.9706, + "max": 9661.3979, + "p(90)": 1727.9245, + "p(95)": 2197.4785 + } + }, + "stage_400vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "min": 6.934, + "med": 260.3311, + "max": 7910.6568, + "p(90)": 2008.9612, + "p(95)": 2496.1789249999993, + "avg": 863.2435207222827 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 111229870, + "rate": 200877.13323974353 + } + }, + "stage_100vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "fails": 90445, + "rate": 0, + "passes": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.03317386047000683, + "passes": 1262, + "fails": 36780 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.026625799573560767, + "passes": 999, + "fails": 36521 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_100vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 88.53316492343434, + "min": 9.3557, + "med": 38.8589, + "max": 2731.1081, + "p(90)": 230.3016, + "p(95)": 452.7225400000001 + } + }, + "vus_max": { + "contains": "default", + "values": { + "max": 500, + "value": 500, + "min": 500 + }, + "type": "gauge" + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "min": 5.0978, + "med": 69.5646, + "max": 9661.3979, + "p(90)": 1727.745, + "p(95)": 2197.3967, + "avg": 490.05636638485777 + } + }, + "error_rate": { + "contains": "default", + "values": { + "rate": 0.014597297067159731, + "passes": 3600, + "fails": 243021 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.1363648099514004, + "min": 0, + "med": 0, + "max": 198.5695, + "p(90)": 0, + "p(95)": 0 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 245811787, + "rate": 443927.22107018967 + } + }, + "stage_500vu_error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "passes": 1334, + "fails": 38976, + "rate": 0.033093525179856115 + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9926303923834547, + "passes": 489607, + "fails": 3635 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 489.2817069929304, + "min": 5.0978, + "med": 70.126, + "max": 9661.3979, + "p(90)": 1726.5174, + "p(95)": 2196.2316 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "stage_200vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0001240571655418817, + "passes": 5, + "fails": 40299 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_300vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 633.4955815309489, + "min": 5.0978, + "med": 242.11935, + "max": 9661.3979, + "p(90)": 1500.91932, + "p(95)": 1978.197795 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 247121, + "rate": 446.29161252583197 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 24, + "min": 0, + "max": 500 + } + }, + "iterations": { + "values": { + "count": 246621, + "rate": 445.38863056046716 + }, + "type": "counter", + "contains": "default" + }, + "stage_500vu_latency": { + "values": { + "p(90)": 2490.07565, + "p(95)": 2748.272275, + "avg": 1000.3222297717737, + "min": 7.0998, + "med": 250.83159999999998, + "max": 6335.1605 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "avg": 490.62029454473765, + "min": 8.9364, + "med": 71.28665000000001, + "max": 9661.3979, + "p(90)": 1728.1209, + "p(95)": 2196.91085 + } + }, + "http_req_waiting": { + "values": { + "med": 65.819, + "max": 9325.2787, + "p(90)": 1716.6991, + "p(95)": 2160.9547, + "avg": 481.6095870727298, + "min": 5.0978 + }, + "type": "trend", + "contains": "time" + }, + "stage_200vu_latency": { + "values": { + "p(90)": 1015.3241500000001, + "p(95)": 1250.43942, + "avg": 397.96258784488384, + "min": 9.0638, + "med": 210.1042, + "max": 4209.3936 + }, + "type": "trend", + "contains": "time" + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0043096296955742325, + "passes": 1065, + "fails": 246056 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 4.7031, + "avg": 7.661520920520775, + "min": 0, + "med": 0, + "max": 7938.8465, + "p(90)": 1.5495 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 6.3409, + "p(90)": 0, + "p(95)": 0, + "avg": 0.010598999680318601 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-100vu-1.json b/script/result/result-test3-stage-100vu-1.json new file mode 100644 index 0000000..05e85a7 --- /dev/null +++ b/script/result/result-test3-stage-100vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "avg": 402.7560677802262, + "min": 7.4797, + "med": 286.3904, + "max": 4238.3995, + "p(90)": 956.65725, + "p(95)": 1030.56165 + }, + "errorRate": { + "rate": 0.0003005108684764099, + "passes": 6, + "fails": 19960 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-100vu.json b/script/result/result-test3-stage-100vu.json new file mode 100644 index 0000000..9c67160 --- /dev/null +++ b/script/result/result-test3-stage-100vu.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "avg": 88.53316492343434, + "min": 9.3557, + "med": 38.8589, + "max": 2731.1081, + "p(90)": 230.3016, + "p(95)": 452.7225400000001 + }, + "errorRate": { + "rate": 0, + "passes": 0, + "fails": 90445 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-200vu-1.json b/script/result/result-test3-stage-200vu-1.json new file mode 100644 index 0000000..acc80ce --- /dev/null +++ b/script/result/result-test3-stage-200vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 1015.4606896671461, + "min": 8.8935, + "med": 998.0098, + "max": 4994.2516, + "p(90)": 1730.8779200000001, + "p(95)": 1993.4768399999998 + }, + "errorRate": { + "rate": 0.005115897176782669, + "passes": 81, + "fails": 15752 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-200vu.json b/script/result/result-test3-stage-200vu.json new file mode 100644 index 0000000..78648a2 --- /dev/null +++ b/script/result/result-test3-stage-200vu.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 397.96258784488384, + "min": 9.0638, + "med": 210.1042, + "max": 4209.3936, + "p(90)": 1015.3241500000001, + "p(95)": 1250.43942 + }, + "errorRate": { + "rate": 0.0001240571655418817, + "passes": 5, + "fails": 40299 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-300vu-1.json b/script/result/result-test3-stage-300vu-1.json new file mode 100644 index 0000000..75bbd0a --- /dev/null +++ b/script/result/result-test3-stage-300vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "p(90)": 2749.51864, + "p(95)": 3239.5377799999997, + "avg": 1596.0461405552958, + "min": 8.2033, + "med": 1506.8908, + "max": 6242.6114 + }, + "errorRate": { + "fails": 14146, + "rate": 0.06928087374169353, + "passes": 1053 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-300vu.json b/script/result/result-test3-stage-300vu.json new file mode 100644 index 0000000..4e9c73e --- /dev/null +++ b/script/result/result-test3-stage-300vu.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "min": 5.0978, + "med": 242.11935, + "max": 9661.3979, + "p(90)": 1500.91932, + "p(95)": 1978.197795, + "avg": 633.4955815309489 + }, + "errorRate": { + "rate": 0.03317386047000683, + "passes": 1262, + "fails": 36780 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-400vu-1.json b/script/result/result-test3-stage-400vu-1.json new file mode 100644 index 0000000..713536a --- /dev/null +++ b/script/result/result-test3-stage-400vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "p(90)": 3753.29586, + "p(95)": 4260.74442, + "avg": 2184.037259406897, + "min": 9.1254, + "med": 2037.2873, + "max": 7228.3906 + }, + "errorRate": { + "rate": 0.19134662129314536, + "passes": 1968, + "fails": 8317 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-400vu.json b/script/result/result-test3-stage-400vu.json new file mode 100644 index 0000000..32ac547 --- /dev/null +++ b/script/result/result-test3-stage-400vu.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "avg": 863.2435207222827, + "min": 6.934, + "med": 260.3311, + "max": 7910.6568, + "p(90)": 2008.9612, + "p(95)": 2496.1789249999993 + }, + "errorRate": { + "fails": 36521, + "rate": 0.026625799573560767, + "passes": 999 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-500vu.json b/script/result/result-test3-stage-500vu.json new file mode 100644 index 0000000..f5178c9 --- /dev/null +++ b/script/result/result-test3-stage-500vu.json @@ -0,0 +1,16 @@ +{ + "vu": 500, + "latency": { + "p(95)": 2748.272275, + "avg": 1000.3222297717737, + "min": 7.0998, + "med": 250.83159999999998, + "max": 6335.1605, + "p(90)": 2490.07565 + }, + "errorRate": { + "rate": 0.033093525179856115, + "passes": 1334, + "fails": 38976 + } +} \ No newline at end of file diff --git a/script/test1-baseline.js b/script/test1-baseline.js new file mode 100644 index 0000000..a3afec8 --- /dev/null +++ b/script/test1-baseline.js @@ -0,0 +1,111 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { SharedArray } from 'k6/data'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test1-baseline.js + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +// CSV에서 계정 목록 로드 +const accounts = new SharedArray('accounts', function () { + const csv = open(__ENV.ACCOUNTS_FILE); + const parsed = papaparse.parse(csv, { header: true, skipEmptyLines: true }); + return parsed.data.map((row) => row.username); +}); + +export const options = { + stages: [ + { duration: '20s', target: 300 }, // Ramp-Up + { duration: '5m', target: 300 }, // 유지 + { duration: '10s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + }, +}; + +// setup()에서 VU마다 다른 토큰 발급 후 재사용 (캐시 HIT 유도) +export function setup() { + const tokens = []; + const target = Math.min(300, accounts.length); + + console.log(`테스트 시작: ${new Date().toISOString()}`); + console.log(`토큰 발급 시작: ${target}개`); + + for (let i = 0; i < target; i++) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: accounts[i], password: 'password' }), + { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + } + ); + + if (res.status !== 200) { + console.warn(`로그인 실패 (status ${res.status}): ${accounts[i]}`); + continue; + } + + try { + const body = res.json(); + if (body && body.success && body.data && body.data.accessToken) { + tokens.push(body.data.accessToken); + } else { + console.warn(`토큰 없음: ${accounts[i]}`); + } + } catch (e) { + console.warn(`JSON 파싱 실패: ${accounts[i]} - ${e}`); + } + } + + console.log(`토큰 발급 완료: ${tokens.length}개`); + + if (tokens.length === 0) { + throw new Error('발급된 토큰이 없습니다. 테스트를 중단합니다.'); + } + + return { tokens }; +} + +export default function (data) { + // VU마다 다른 토큰 사용, 로그아웃 없이 재사용 → TTL 30초 내 캐시 HIT 유도 + const token = data.tokens[__VU % data.tokens.length]; + + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, + }); + + if (res.timings) { + latency.add(res.timings.duration); + } + errorRate.add(!ok); +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + return { + 'result/result-test1-baseline.json': JSON.stringify(rest, null, 2), + stdout: textSummary(data, { indent: ' ', enableColors: true }), + }; +} \ No newline at end of file diff --git a/script/test2-max-users.js b/script/test2-max-users.js new file mode 100644 index 0000000..c65eaae --- /dev/null +++ b/script/test2-max-users.js @@ -0,0 +1,186 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx test2-max-users.js + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +// 단계별 메트릭 +const stage100Latency = new Trend('stage_100vu_latency', true); +const stage200Latency = new Trend('stage_200vu_latency', true); +const stage300Latency = new Trend('stage_300vu_latency', true); +const stage400Latency = new Trend('stage_400vu_latency', true); +const stage500Latency = new Trend('stage_500vu_latency', true); + +const stage100Errors = new Rate('stage_100vu_error_rate'); +const stage200Errors = new Rate('stage_200vu_error_rate'); +const stage300Errors = new Rate('stage_300vu_error_rate'); +const stage400Errors = new Rate('stage_400vu_error_rate'); +const stage500Errors = new Rate('stage_500vu_error_rate'); + +export const options = { + stages: [ + { duration: '20s', target: 100 }, // Ramp-Up → 100명 + { duration: '1m', target: 100 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 + { duration: '20s', target: 200 }, // Ramp-Up → 200명 + { duration: '1m', target: 200 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 + { duration: '20s', target: 300 }, // Ramp-Up → 300명 + { duration: '1m', target: 300 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 + { duration: '20s', target: 400 }, // Ramp-Up → 400명 + { duration: '1m', target: 400 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 + { duration: '20s', target: 500 }, // Ramp-Up → 500명 + { duration: '1m', target: 500 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'stage_100vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_200vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_300vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_400vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_500vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + }, +}; + +// 단계 판별 함수 (경과 시간 기준) +// 각 단계: Ramp-Up 20s + 유지 60s + Ramp-Down 20s + 대기 10s = 110s +function getCurrentStage(elapsedSeconds) { + if (elapsedSeconds < 110) return 100; + if (elapsedSeconds < 220) return 200; + if (elapsedSeconds < 330) return 300; + if (elapsedSeconds < 440) return 400; + return 500; +} + +// setup()에서 토큰 1개 발급 후 전체 VU 재사용 +export function setup() { + console.log(`테스트 시작: ${new Date().toISOString()}`); + + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: 'user00001', password: 'password' }), + { headers: { 'Content-Type': 'application/json' } } + ); + + const body = res.json(); + if (!body.success || !body.data.accessToken) { + throw new Error(`로그인 실패: ${res.body}`); + } + + console.log('토큰 발급 완료 (최대 동시 접속자 수 탐색)'); + return { token: body.data.accessToken, startTime: Date.now() }; +} + +export default function (data) { + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: data.token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, + }); + + if (res.timings) { + latency.add(res.timings.duration); + } + errorRate.add(!ok); + + // 단계별 메트릭 기록 + const elapsedSeconds = (Date.now() - data.startTime) / 1000; + const stage = getCurrentStage(elapsedSeconds); + const duration = res.timings?.duration; + + switch (stage) { + case 100: + if (duration !== undefined) stage100Latency.add(duration); + stage100Errors.add(!ok); + break; + case 200: + if (duration !== undefined) stage200Latency.add(duration); + stage200Errors.add(!ok); + break; + case 300: + if (duration !== undefined) stage300Latency.add(duration); + stage300Errors.add(!ok); + break; + case 400: + if (duration !== undefined) stage400Latency.add(duration); + stage400Errors.add(!ok); + break; + case 500: + if (duration !== undefined) stage500Latency.add(duration); + stage500Errors.add(!ok); + break; + } +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + // 단계별 결과 요약 출력 + const stages = [100, 200, 300, 400, 500]; + let stageSummary = '\n===== 단계별 결과 요약 =====\n'; + + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageSummary += `\n[${vu} VU]\n`; + stageSummary += ` AVG: ${(l.values.avg).toFixed(2)}ms\n`; + stageSummary += ` P90: ${(l.values['p(90)']).toFixed(2)}ms\n`; + stageSummary += ` P95: ${(l.values['p(95)']).toFixed(2)}ms\n`; + stageSummary += ` MAX: ${(l.values.max).toFixed(2)}ms\n`; + stageSummary += ` 에러율: ${(e.values.rate * 100).toFixed(2)}%\n`; + } + }); + + stageSummary += '\n============================\n'; + + // 단계별 메트릭 분리 저장 + const stageResults = {}; + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageResults[`result/result-test2-stage-${vu}vu.json`] = JSON.stringify({ + vu, + latency: l.values, + errorRate: e.values, + }, null, 2); + } + }); + + return { + 'result/result-test2-max-users.json': JSON.stringify(rest, null, 2), + ...stageResults, + stdout: textSummary(data, { indent: ' ', enableColors: true }) + stageSummary, + }; +} \ No newline at end of file diff --git a/script/test3-max-users-revised.js b/script/test3-max-users-revised.js new file mode 100644 index 0000000..3f0ac41 --- /dev/null +++ b/script/test3-max-users-revised.js @@ -0,0 +1,223 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { SharedArray } from 'k6/data'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test3-max-users-revised.js + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +// CSV에서 계정 목록 로드 +const accounts = new SharedArray('accounts', function () { + const csv = open(__ENV.ACCOUNTS_FILE); + const parsed = papaparse.parse(csv, { header: true, skipEmptyLines: true }); + return parsed.data.map((row) => row.username); +}); + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +// 단계별 메트릭 +const stage100Latency = new Trend('stage_100vu_latency', true); +const stage200Latency = new Trend('stage_200vu_latency', true); +const stage300Latency = new Trend('stage_300vu_latency', true); +const stage400Latency = new Trend('stage_400vu_latency', true); +const stage500Latency = new Trend('stage_500vu_latency', true); + +const stage100Errors = new Rate('stage_100vu_error_rate'); +const stage200Errors = new Rate('stage_200vu_error_rate'); +const stage300Errors = new Rate('stage_300vu_error_rate'); +const stage400Errors = new Rate('stage_400vu_error_rate'); +const stage500Errors = new Rate('stage_500vu_error_rate'); + +export const options = { + setupTimeout: '3m', + stages: [ + { duration: '20s', target: 100 }, // Ramp-Up → 100명 + { duration: '1m', target: 100 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 200 }, // Ramp-Up → 200명 + { duration: '1m', target: 200 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 300 }, // Ramp-Up → 300명 + { duration: '1m', target: 300 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 400 }, // Ramp-Up → 400명 + { duration: '1m', target: 400 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 500 }, // Ramp-Up → 500명 + { duration: '1m', target: 500 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'stage_100vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_200vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_300vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_400vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_500vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + }, +}; + +// 단계 판별 함수 (경과 시간 기준) +// 각 단계: Ramp-Up 20s + 유지 60s + Ramp-Down 20s = 100s +function getCurrentStage(elapsedSeconds) { + if (elapsedSeconds < 100) return 100; + if (elapsedSeconds < 200) return 200; + if (elapsedSeconds < 300) return 300; + if (elapsedSeconds < 400) return 400; + return 500; +} + +// setup()에서 VU마다 다른 토큰 발급 후 재사용 +export function setup() { + console.log(`테스트 시작: ${new Date().toISOString()}`); + + const tokens = []; + const target = Math.min(500, accounts.length); + + console.log(`토큰 발급 시작: ${target}개`); + + const password = __ENV.PASSWORD || 'password'; + + for (let i = 0; i < target; i++) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: accounts[i], password }), + { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + } + ); + + if (res.status !== 200) { + console.warn(`로그인 실패 (status ${res.status}): ${accounts[i]}`); + continue; + } + + try { + const body = res.json(); + if (body && body.success && body.data && body.data.accessToken) { + tokens.push(body.data.accessToken); + } else { + console.warn(`토큰 없음: ${accounts[i]}`); + } + } catch (e) { + console.warn(`JSON 파싱 실패: ${accounts[i]} - ${e}`); + } + } + + console.log(`토큰 발급 완료: ${tokens.length}개 (최대 동시 접속자 수 탐색)`); + + if (tokens.length === 0) { + throw new Error('발급된 토큰이 없습니다. 테스트를 중단합니다.'); + } + + return { tokens, startTime: Date.now() }; +} + +export default function (data) { + // VU마다 다른 토큰 사용 + const token = data.tokens[__VU % data.tokens.length]; + + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, + }); + + if (res.timings) { + latency.add(res.timings.duration); + } + errorRate.add(!ok); + + // 단계별 메트릭 기록 + const elapsedSeconds = (Date.now() - data.startTime) / 1000; + const stage = getCurrentStage(elapsedSeconds); + const duration = res.timings?.duration; + + switch (stage) { + case 100: + if (duration !== undefined) stage100Latency.add(duration); + stage100Errors.add(!ok); + break; + case 200: + if (duration !== undefined) stage200Latency.add(duration); + stage200Errors.add(!ok); + break; + case 300: + if (duration !== undefined) stage300Latency.add(duration); + stage300Errors.add(!ok); + break; + case 400: + if (duration !== undefined) stage400Latency.add(duration); + stage400Errors.add(!ok); + break; + case 500: + if (duration !== undefined) stage500Latency.add(duration); + stage500Errors.add(!ok); + break; + } +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + // 단계별 결과 요약 출력 + const stages = [100, 200, 300, 400, 500]; + let stageSummary = '\n===== 단계별 결과 요약 =====\n'; + + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageSummary += `\n[${vu} VU]\n`; + stageSummary += ` AVG: ${(l.values.avg).toFixed(2)}ms\n`; + stageSummary += ` P90: ${(l.values['p(90)']).toFixed(2)}ms\n`; + stageSummary += ` P95: ${(l.values['p(95)']).toFixed(2)}ms\n`; + stageSummary += ` MAX: ${(l.values.max).toFixed(2)}ms\n`; + stageSummary += ` 에러율: ${(e.values.rate * 100).toFixed(2)}%\n`; + } + }); + + stageSummary += '\n============================\n'; + + // 단계별 메트릭 분리 저장 + const stageResults = {}; + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageResults[`result/result-test3-stage-${vu}vu.json`] = JSON.stringify({ + vu, + latency: l.values, + errorRate: e.values, + }, null, 2); + } + }); + + return { + 'result/result-test3-max-users-revised.json': JSON.stringify(rest, null, 2), + ...stageResults, + stdout: textSummary(data, { indent: ' ', enableColors: true }) + stageSummary, + }; +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/GatewayApplication.java b/src/main/java/org/pgsg/gateway/GatewayApplication.java index b1170a2..bf583f0 100644 --- a/src/main/java/org/pgsg/gateway/GatewayApplication.java +++ b/src/main/java/org/pgsg/gateway/GatewayApplication.java @@ -1,13 +1,18 @@ package org.pgsg.gateway; +import org.pgsg.config.AppCtx; +import org.pgsg.gateway.config.GatewayAppCtx; import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; @SpringBootApplication +@ImportAutoConfiguration(exclude = AppCtx.class) +@Import(GatewayAppCtx.class) public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } - -} +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/auth/AuthDto.java b/src/main/java/org/pgsg/gateway/auth/AuthDto.java new file mode 100644 index 0000000..3528e9a --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthDto.java @@ -0,0 +1,7 @@ +package org.pgsg.gateway.auth; + +public class AuthDto { + public record TokenVerifyRequest(String accessToken) {} + + public record TokenVerifyData(boolean isVerifiedToken) {} +} diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java new file mode 100644 index 0000000..ff389fe --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java @@ -0,0 +1,8 @@ +package org.pgsg.gateway.auth; + +import reactor.core.publisher.Mono; + +public interface AuthProvider { + + Mono verifyToken(String accessToken); +} diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java new file mode 100644 index 0000000..a8dc267 --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java @@ -0,0 +1,39 @@ +package org.pgsg.gateway.auth; + +import com.github.benmanes.caffeine.cache.Cache; +import lombok.extern.slf4j.Slf4j; +import org.pgsg.gateway.cache.CacheUtil; +import org.pgsg.gateway.client.AuthClient; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +public class AuthProviderImpl implements AuthProvider { + + private final Cache tokenVerifyCache; + private final AuthClient authClient; + + public AuthProviderImpl(AuthClient authClient, CacheUtil cacheUtil) { + this.authClient = authClient; + this.tokenVerifyCache = cacheUtil.getTokenVerifyCache(); + } + + @Override + public Mono verifyToken(String accessToken) { + Boolean cachedResult = tokenVerifyCache.getIfPresent(accessToken); + + if (cachedResult != null) { + return Mono.just(cachedResult); + } + + return authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)) + .map(response -> response != null + && response.success() + && response.data() != null + && response.data().isVerifiedToken()) + .doOnNext(result -> tokenVerifyCache.put(accessToken, result)) + .onErrorReturn(false); + } +} + diff --git a/src/main/java/org/pgsg/gateway/cache/CacheUtil.java b/src/main/java/org/pgsg/gateway/cache/CacheUtil.java new file mode 100644 index 0000000..493cffd --- /dev/null +++ b/src/main/java/org/pgsg/gateway/cache/CacheUtil.java @@ -0,0 +1,35 @@ +package org.pgsg.gateway.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class CacheUtil { + + private final Cache tokenVerifyCache; + private final Cache claimsCache; + + public CacheUtil() { + this.tokenVerifyCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10_000) + .build(); + + this.claimsCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10_000) + .build(); + } + + public Cache getTokenVerifyCache() { + return tokenVerifyCache; + } + + public Cache getClaimsCache() { + return claimsCache; + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/client/AuthClient.java b/src/main/java/org/pgsg/gateway/client/AuthClient.java new file mode 100644 index 0000000..b2be6ba --- /dev/null +++ b/src/main/java/org/pgsg/gateway/client/AuthClient.java @@ -0,0 +1,46 @@ +package org.pgsg.gateway.client; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.auth.AuthDto; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +//@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) +@Component +public class AuthClient { + + private final WebClient webClient; + + public AuthClient(WebClient.Builder builder) { + // WebClient 커넥션 풀 타임아웃 설정 추가 + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofSeconds(5)) + .doOnConnected(conn -> conn + .addHandlerLast(new ReadTimeoutHandler(5)) + .addHandlerLast(new WriteTimeoutHandler(5))); + + this.webClient = builder + .baseUrl("lb://user-service") + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } + + public Mono> verifyToken(AuthDto.TokenVerifyRequest request) { + return webClient.post() + .uri("/internal/v1/auth/verify") + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .onErrorReturn(new CommonResponse<>(false, "인증 서비스 장애", new AuthDto.TokenVerifyData(false), null)); + } +} diff --git a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java new file mode 100644 index 0000000..7fb12ad --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java @@ -0,0 +1,23 @@ +package org.pgsg.gateway.config; + +import org.pgsg.common.exception.ErrorConfigProperties; +import org.pgsg.config.json.JsonConfig; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@Import({ + JsonConfig.class, + ErrorConfigProperties.class +}) +public class GatewayAppCtx { + + @Bean + @LoadBalanced + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java new file mode 100644 index 0000000..98bc74d --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,23 @@ +package org.pgsg.gateway.config; + +import lombok.RequiredArgsConstructor; +import org.pgsg.config.security.SecurityConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration +@EnableWebFluxSecurity +@RequiredArgsConstructor +public class GatewaySecurityConfig implements SecurityConfig { + + @Bean + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(auth -> auth.anyExchange().permitAll()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/config/JwtConfig.java b/src/main/java/org/pgsg/gateway/config/JwtConfig.java new file mode 100644 index 0000000..063fc1c --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/JwtConfig.java @@ -0,0 +1,18 @@ +package org.pgsg.gateway.config; + +import org.pgsg.config.security.jwt.JwtProperties; +import org.pgsg.config.security.jwt.JwtTokenProvider; +import org.pgsg.config.security.token.TokenProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class JwtConfig { + + @Bean + public TokenProvider tokenProvider(JwtProperties jwtProperties) { + return new JwtTokenProvider(jwtProperties); + } +} diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java new file mode 100644 index 0000000..ee80e87 --- /dev/null +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -0,0 +1,217 @@ +package org.pgsg.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.micrometer.tracing.Tracer; +import lombok.extern.slf4j.Slf4j; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.config.security.jwt.JwtUtils; +import org.pgsg.config.security.token.TokenProvider; +import org.pgsg.config.security.token.TokenType; +import org.pgsg.gateway.auth.AuthProvider; +import org.pgsg.gateway.cache.CacheUtil; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; +import reactor.core.publisher.Mono; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Stream; + +@Slf4j +@Component +public class JwtGatewayFilter implements GlobalFilter, Ordered { + + private static final String HEADER_TRACE_ID = "X-Trace-Id"; + private static final List WHITELIST = Stream.of( + "/api/v1/auth/login", + "/api/v1/auth/signup", + "/api/v1/auth/reissue", + "/actuator/health", + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", + "/actuator/metrics", + "/actuator/metrics/**" + ).map(PathPatternParser.defaultInstance::parse) + .toList(); + + private final Tracer tracer; + private final TokenProvider jwtTokenProvider; + private final AuthProvider authProvider; + private final ObjectMapper objectMapper; + private final Cache claimsCache; + + public JwtGatewayFilter(Tracer tracer, TokenProvider jwtTokenProvider, + AuthProvider authProvider, ObjectMapper objectMapper, + CacheUtil cacheUtil) { + this.tracer = tracer; + this.jwtTokenProvider = jwtTokenProvider; + this.authProvider = authProvider; + this.objectMapper = objectMapper; + this.claimsCache = cacheUtil.getClaimsCache(); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + String traceId = resolveTraceId(); + + // 헤더 초기화: x-user-* 제거 + traceId 주입 + ServerHttpRequest sanitized = request.mutate() + .headers(headers -> { + headers.keySet().removeIf(key -> key.toLowerCase().startsWith("x-user-")); + headers.set(HEADER_TRACE_ID, traceId); + }) + .build(); + + log.debug("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), path); + + // 화이트리스트 통과 + if (isWhitelisted(path)) { + return chain.filter(exchange.mutate().request(sanitized).build()); + } + + // 토큰 누락 + String accessToken = JwtUtils.resolveToken( + request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + + if (accessToken == null) { + log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); + return onAuthError(exchange, "Access 토큰이 필요합니다.", traceId); + } + + return authenticate(exchange, sanitized, chain, accessToken, traceId); + } + + private Mono authenticate(ServerWebExchange exchange, ServerHttpRequest sanitized, + GatewayFilterChain chain, String accessToken, String traceId) { + // [Step 1] 로컬 검증 - 만료되지 않았더라도 검증은 수행 + Claims cachedClaims = claimsCache.getIfPresent(accessToken); + Mono localValidation = (cachedClaims != null && !isExpired(cachedClaims)) + ? Mono.just(true) + : Mono.fromCallable(() -> jwtTokenProvider.validateToken(accessToken)); + + return localValidation + .flatMap(valid -> { + if (!valid) { + return Mono.error(new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); + } + // [Step 2] 블랙리스트 검증 (항상 수행) + return authProvider.verifyToken(accessToken); + }) + .flatMap(verified -> { + if (!verified) { + return Mono.error(new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); + } + // [Step 3] Claims 파싱 (캐시 HIT 시 생략) + Claims cached = claimsCache.getIfPresent(accessToken); + if (cached != null) { + log.debug("[JwtGatewayFilter] 캐시 HIT (TraceID: {})", traceId); + return Mono.just(cached); + } + return Mono.fromCallable(() -> jwtTokenProvider.parseClaims(accessToken)) + .doOnNext(claims -> claimsCache.put(accessToken, claims)); + }) + .flatMap(claims -> { + String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); + if (!TokenType.ACCESS.matches(tokenType)) { + log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); + return Mono.error(new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + } + // [Step 4] 사용자 헤더 주입 + ServerHttpRequest mutated = injectUserHeaders(sanitized, claims); + log.debug("[JwtGatewayFilter] 인증 성공 (TraceID: {})", traceId); + return chain.filter(exchange.mutate().request(mutated).build()); + }) + .onErrorResume(InsufficientAuthenticationException.class, + e -> onAuthError(exchange, e.getMessage(), traceId)) + .onErrorResume(JwtException.class, e -> { + log.error("[JwtGatewayFilter] JWT 예외: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }) + .onErrorResume(IllegalArgumentException.class, e -> { + log.error("[JwtGatewayFilter] 잘못된 인자: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }); + } + + private boolean isExpired(Claims claims) { + Date expiration = claims.getExpiration(); + return expiration != null && expiration.before(new Date()); + } + + private Mono onAuthError(ServerWebExchange exchange, String message, String traceId) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + CommonResponse errorResponse = new CommonResponse<>( + false, + message, + null, + traceId + ); + + try { + byte[] body = objectMapper.writeValueAsBytes(errorResponse); + return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); + } catch (Exception e) { + log.error("[JwtGatewayFilter] JSON 직렬화 오류 (TraceID: {})", traceId, e); + return Mono.error(e); + } + } + + private ServerHttpRequest injectUserHeaders(ServerHttpRequest request, Claims claims) { + Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); + return request.mutate() + .header(JwtUtils.HEADER_USER_ID, claims.getSubject()) + .header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)) + .header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)) + .header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))) + .header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))) + .header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false") + .build(); + } + + private boolean isWhitelisted(String path) { + PathContainer pathContainer = PathContainer.parsePath(path); + return WHITELIST.stream().anyMatch(pattern -> pattern.matches(pathContainer)); + } + + private String resolveTraceId() { + if (tracer.currentSpan() != null) { + return Objects.requireNonNull(tracer.currentSpan()).context().traceId(); + } + return UUID.randomUUID().toString().substring(0, 8); + } + + private String encodeValue(String value) { + return Optional.ofNullable(value) + .map(v -> URLEncoder.encode(v, StandardCharsets.UTF_8)) + .orElse(null); + } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a65104f..ec75bb3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,24 +1,38 @@ -server: - port: 8090 - spring: application: name: gateway-server + config: + import: + - "optional:configserver:" + - "optional:file:.env[.properties]" cloud: config: - enabled: false - gateway: - server: - webmvc: - routes: - - id: trace-server - uri: lb://trace-server - predicates: - - Path=/api/v1/trace/** + allow-override: true + override-none: true + override-system-properties: false + discovery: + service-id: config-server + enabled: true eureka: + instance: + prefer-ip-address: true + ip-address: ${HOSTNAME:localhost} + hostname: ${HOSTNAME:localhost} + instance-id: "${HOSTNAME:${spring.application.name}}:${spring.application.name}:${server.port}" client: - fetch-registry: true register-with-eureka: true + fetch-registry: true service-url: - defaultZone: http://eureka-server:8761/eureka/ \ No newline at end of file + defaultZone: ${EUREKA_SERVER_URL:http://localhost:8761/eureka/} + +server: + port: 8090 + +management: + tracing: + sampling: + probability: 0.1 + zipkin: + tracing: + endpoint: ${ZIPKIN_ENDPOINT:http://localhost:9411/api/v2/spans} diff --git a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java new file mode 100644 index 0000000..12a6495 --- /dev/null +++ b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java @@ -0,0 +1,164 @@ +package org.pgsg.gateway; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.config.security.token.TokenProvider; +import org.pgsg.gateway.auth.AuthProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.pgsg.config.security.jwt.JwtUtils; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.mockito.Mockito.*; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "downstream.service.url=http://localhost:${wiremock.server.port}" +}) +@AutoConfigureWebTestClient +@AutoConfigureWireMock(port = 0) +class JwtGatewayIntegrationTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private TokenProvider tokenProvider; + + @MockitoBean + private AuthProvider authProvider; + + @TestConfiguration + static class TestRouteConfig { + @Bean + public RouteLocator testRoutes(RouteLocatorBuilder builder, @Value("${downstream.service.url}") String downstreamUrl) { + return builder.routes() + .route("test_route", r -> r.path("/test/**") + .filters(f -> f.prefixPath("/internal")) + .uri(downstreamUrl)) + .route("auth_route", r -> r.path("/api/v1/auth/**") + .uri(downstreamUrl)) + .build(); + } + } + + @Test + @DisplayName("유효한 토큰 요청 시 사용자 헤더가 정상 주입되어야 한다") + void success_token_injection() throws JsonProcessingException { + String token = "valid-token-final"; + String userId = "00000000-0000-0000-0000-000000000001"; + String role = "ROLE_USER"; + + when(tokenProvider.validateToken(token)).thenReturn(true); + Claims claims = Jwts.claims() + .subject(userId) + .add(JwtUtils.CLAIM_USER_ROLE, role) + .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") + .add(JwtUtils.CLAIM_USERNAME, "tester") + .build(); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + // CommonResponse를 사용하여 JSON 바디 생성 + String responseBody = objectMapper.writeValueAsString( + new CommonResponse<>(true, "OK", Map.of("status", "passed"), "test-trace-id") + ); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(userId)) + .withHeader(JwtUtils.HEADER_ROLES, equalTo(role)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBody(responseBody))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.success").isEqualTo(true) + .jsonPath("$.data.status").isEqualTo("passed"); + } + + @Test + @DisplayName("검증한 토큰이 블랙리스트에 포함되어 있다면 401 에러를 반환해야 한다") + void fail_blacklisted_token() { + String token = "blacklisted-token-final"; + when(tokenProvider.validateToken(token)).thenReturn(true); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(false)); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("이미 로그아웃되었거나 사용할 수 없는 토큰입니다."); + } + + @Test + @DisplayName("화이트리스트에 포함된 경로는 유효한 토큰 없이도 통과되어야 한다") + void success_whitelist() { + stubFor(post(urlEqualTo("/api/v1/auth/login")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.post().uri("/api/v1/auth/login") + .exchange() + .expectStatus().isOk(); + } + + @Test + @DisplayName("화이트리스트에 포함되지 않은 경로는 유효한 토큰이 없으면 차단되어야 한다") + void fail_nonWhitelist_noToken() { + webTestClient.get().uri("/test/headers") + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("Access 토큰이 필요합니다."); + } + + @Test + @DisplayName("외부에서 주입한 보안 헤더(x-user-)는 무시되어야 한다") + void success_spoofing_protection() { + String token = "spoofing-check-final"; + String realUserId = "00000000-0000-0000-0000-000000000001"; + + when(tokenProvider.validateToken(token)).thenReturn(true); + Claims claims = Jwts.claims().subject(realUserId).add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER").add(JwtUtils.CLAIM_TOKEN_TYPE, "access").build(); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(realUserId)) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(JwtUtils.HEADER_USER_ID, "99999") // 스푸핑 시도 + .exchange() + .expectStatus().isOk(); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 649397b..201a3c9 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -5,8 +5,14 @@ spring: config: enabled: false config: - import: "" + import: "optional:file:.env[.properties]" eureka: client: - enabled: false \ No newline at end of file + enabled: false + +jwt: + # 환경변수로 주입(테스트용이므로 jwt.secret도 기본값 부여) + secret: ${JWT_SECRET:test-jwt-secret-for-local-ci-only-32bytes-min} + access-token-expiration: ${JWT_ACCESS_EXPIRATION:1800000} # 30분 (ms) + refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 (ms) \ No newline at end of file