EC2에 Qdrant 적용 + CI/CD & 테스트 실패 해결기

송현진·2025년 9월 25일
0

프로젝트 회고

목록 보기
22/22

배경

  • 목표1: 추천 품질 향상을 위해 Qdrant(벡터 DB) 를 EC2에 올리고 GitHub Actions 기반 CI/CD로 빌드/배포 자동화까지 한 번에 구성
  • 목표2: ./gradlew clean build테스트가 로컬/CI 모두 실패하는 문제 해결

최종 결론

  • Qdrant는 docker-compose로 EC2에 안전하게(127.0.0.1 바인딩) 기동
  • CI/CD는 JAR 배포 + 원격 docker compose로 정리
  • 테스트 실패는 벡터 기능을 프로파일/플래그로 격리하고 OpenAI 자동설정 제외로 해결

해결방법

1. EC2에 Qdrant 올리기 — docker-compose.yml

version: "3.8"

services:
  qdrant:
    image: qdrant/qdrant:v1.15.3
    container_name: qdrant
    volumes:
      - /var/qdrant/storage:/qdrant/storage
    environment:
      TZ: Asia/Seoul
      QDRANT__STORAGE__STORAGE_PATH: /qdrant/storage
      QDRANT__STORAGE__ON_DISK: "true"
      QDRANT__STORAGE__DISABLE_FS_AVAILABILITY_CHECK: "true"
    ports:
      - "127.0.0.1:6333:6333"
      - "127.0.0.1:6334:6334"
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:6333/healthz"]
      interval: 10s
      timeout: 3s
      retries: 10
    restart: unless-stopped

운영 보안의 기본은 기본 차단이다. Qdrant는 애플리케이션과 동일 EC2에서만 접근하면 되므로 퍼블릭 노출이 필요 없었다. 그래서 포트를 127.0.0.1로 바인딩해 외부 트래픽을 원천 차단하는 방식으로 구성했다. 영속성은 컨테이너 수명과 무관하게 데이터를 보존하려고 /var/qdrant/storage를 호스트 볼륨으로 마운트했으며 on-disk 저장 옵션을 명시해 EBS 디스크를 활용하도록 했다. 헬스체크는 배포 스크립트가 정상 기동을 기계적으로 판단할 수 있게 해주어 이후 단계(애플리케이션 재기동)의 타이밍을 정확히 맞출 수 있도록 설정했다.

운영 중 점검/접속

상태 확인 : curl -sf http://127.0.0.1:6333/healthz && echo OK
웹 UI/REST를 로컬에서 보고 싶을 때만 SSH 포트포워딩

ssh -i ec2_key.pem -N -L 6333:127.0.0.1:6333 ec2-user@<EC2_PUBLIC_IP>
# 이후 http://localhost:6333

2. GitHub Actions로 배포 — deploy.yml

핵심 흐름

name: CD - Deploy to EC2 (Main Only)

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      # 1. 소스 체크아웃
      - name: Checkout source
        uses: actions/checkout@v4

      # 2. JDK 21 설정
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      # 3. Gradle wrapper 권한
      - name: Grant execute permission for Gradle wrapper
        run: chmod +x ./gradlew

      # 4. 빌드 (테스트 포함)
      - name: Build with Gradle
        run: ./gradlew clean build

      # 5. EC2 SSH용 PEM 저장
      - name: Save PEM key to file
        run: |
          echo "${{ secrets.EC2_KEY }}" > ec2_key.pem
          chmod 600 ec2_key.pem

      # 6. 산출물 및 compose 전송 (홈 디렉토리)
      - name: Copy artifacts to EC2
        run: |
          scp -i ec2_key.pem -o StrictHostKeyChecking=no \
            build/libs/giftrecommender-0.0.1-SNAPSHOT.jar \
            docker-compose.yml \
            ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:~

      # 7. 원격 배포 (멱등성 + 헬스 대기 + 앱 재기동)
      - name: SSH into EC2 and deploy
        run: |
          ssh -i ec2_key.pem -o StrictHostKeyChecking=no \
            ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }} << 'EOF'
          set -euo pipefail

          # ---- (A) Secrets 값을 원격 세션 환경변수로 주입 ----
          export EC2_HOST="${{ secrets.EC2_HOST }}"
          export DB_PASSWORD="${{ secrets.DB_PASSWORD }}"
          export NAVER_CLIENT_ID="${{ secrets.NAVER_CLIENT_ID }}"
          export NAVER_CLIENT_SECRET="${{ secrets.NAVER_CLIENT_SECRET }}"
          export OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}"

          echo "[INFO] Ensure Docker, Compose plugin, curl, Java"
          # ---- (B) 배포 호스트 준비: docker/compose/curl/java 없으면 설치 ----
          if ! command -v docker >/dev/null 2>&1; then
            if command -v apt-get >/dev/null 2>&1; then
              sudo apt-get update -y
              sudo apt-get install -y docker.io
            else
              sudo yum install -y docker
            fi
            sudo systemctl enable docker
            sudo systemctl start docker
          fi
          if ! sudo docker compose version >/dev/null 2>&1; then
            if command -v apt-get >/dev/null 2>&1; then
              sudo apt-get install -y docker-compose-plugin || true
            else
              sudo yum install -y docker-compose-plugin || true
            fi
          fi
          if ! command -v curl >/dev/null 2>&1; then
            if command -v apt-get >/dev/null 2>&1; then
              sudo apt-get install -y curl
            else
              sudo yum install -y curl
            fi
          fi
          if ! command -v java >/dev/null 2>&1; then
            if command -v apt-get >/dev/null 2>&1; then
              sudo apt-get update -y && sudo apt-get install -y openjdk-21-jre
            else
              sudo yum install -y java-21-amazon-corretto-headless || sudo dnf install -y java-21-openjdk
            fi
          fi

          # ---- (C) Qdrant 멱등 기동: 있으면 유지, 꺼져있으면 start, 없으면 up -d ----
          sudo mkdir -p /var/qdrant/storage
          cd "$HOME"

          if sudo docker ps --format '{{.Names}}' | grep -q '^qdrant$'; then
            echo "[INFO] Qdrant already running. Skipping compose up."
          else
            if sudo docker ps -a --format '{{.Names}}' | grep -q '^qdrant$'; then
              echo "[INFO] Qdrant container exists but not running. Starting..."
              sudo docker start qdrant
            else
              echo "[INFO] Qdrant not present. Bringing up via docker-compose.yml"
              sudo docker compose -f docker-compose.yml up -d
            fi

            echo "[INFO] Wait for Qdrant health"
            for i in {1..30}; do
              if curl -sf http://127.0.0.1:6333/healthz >/dev/null; then
                echo "[INFO] Qdrant healthy"
                break
              fi
              echo "[INFO] Waiting Qdrant... ($i/30)"
              sleep 2
              if [ "$i" -eq 30 ]; then
                echo "[ERROR] Qdrant did not become healthy"
                sudo docker ps
                sudo docker logs qdrant --tail=200 || true
                exit 1
              fi
            done
          fi

          # ---- (D) application-secret.yml 템플릿 → envsubst로 민감값 주입 ----
          echo "[INFO] Write application-secret.yml (template + envsubst)"
          cat > "$HOME/application-secret.yml.tmpl" <<'EOT'
          spring:
            config:
              activate:
                on-profile: prod
            datasource:
              url: jdbc:mysql://${EC2_HOST}:3306/gift_recommendation?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
              password: "${DB_PASSWORD}"
            redis:
              host: "${EC2_HOST}"
              port: 6379
          naver:
            client-id: "${NAVER_CLIENT_ID}"
            client-secret: "${NAVER_CLIENT_SECRET}"
          openai:
            api:
              key: "${OPENAI_API_KEY}"
          EOT

          envsubst < "$HOME/application-secret.yml.tmpl" > "$HOME/application-secret.yml"

          # ---- (E) 기존 Spring Boot 프로세스 종료 (graceful) ----
          echo "[INFO] Stop existing Spring Boot application (if any)"
          PID=$(pgrep -f 'giftrecommender-0.0.1-SNAPSHOT.jar' || true)
          if [ -n "$PID" ]; then
            kill -15 $PID || true
            for i in {1..60}; do
              sleep 1
              if ! ps -p $PID >/dev/null; then
                echo "[INFO] Process $PID terminated"
                break
              fi
              if [ "$i" -eq 60 ]; then
                echo "[WARN] Force killing $PID"
                kill -9 $PID || true
              fi
            done
          else
            echo "[INFO] No existing process"
          fi

          # ---- (F) 앱 기동: Qdrant를 로컬호스트로 바라보도록 환경변수 제공 ----
          echo "[INFO] Start Spring Boot (point to local Qdrant)"
          export QDRANT_HTTP_BASE_URL="http://127.0.0.1:6333"
          export QDRANT_GRPC_HOST="127.0.0.1"
          export QDRANT_GRPC_PORT="6334"

          nohup java -jar \
            -Dspring.profiles.active=prod \
            -Dspring.config.additional-location="$HOME/application-secret.yml" \
            "$HOME/giftrecommender-0.0.1-SNAPSHOT.jar" \
            > "$HOME/app.log" 2>&1 &

          echo "[INFO] Tail recent logs"
          tail -n 200 "$HOME/app.log" || true
          EOF

      # 8. 민감키 정리
      - name: Clean up PEM key
        run: rm -f ec2_key.pem
  1. Gradle 빌드로 JAR 산출

  2. PEM 저장 후, JAR, application-secret.yml, docker-compose.yml을 EC2 홈 디렉토리로 전송

  3. 원격 SSH에서

    • Docker/Compose/Java 없는 경우 설치
    • Qdrant 멱등 기동(있으면 패스/미기동이면 start/없으면 up -d)
    • Qdrant healthz 대기 루프
    • 기존 Spring Boot 프로세스 graceful stop 후 재기동
    • 최신 로그 tail로 확인

배포 스크립트의 포인트 설명

  • 멱등성
    • docker ps로 qdrant 존재/상태를 판별 → start 또는 up -d로 분기
    • 여러 번 실행해도 같은 상태로 수렴(운영 안전성↑)
  • health 대기
for i in {1..30}; do
  if curl -sf http://127.0.0.1:6333/healthz >/dev/null; then break; fi
  sleep 2
done

Qdrant 기동 완료 전 애플리케이션을 띄우지 않도록 준비 완료 신호를 기다림

  • Secrets 주입

    • Actions의 ${{ secrets.* }} 값을 원격 세션 환경변수로 export
    • 템플릿(`application-secret.yml.tmpl)에 ${VAR}를 작성하고 envsubst로 치환
    • 결과 파일을 -Dspring.config.additional-location=...로 앱에 전달
  • Qdrant 접속 정보

    • 앱 기동 전에 QDRANT_HTTP_BASE_URL=http://127.0.0.1:6333환경변수 export
    • 애플리케이션에서 이 값을 읽도록 구성(예: @Value/ConfigurationProperties 또는 WebClient Bean baseUrl)

3. 애플리케이션 설정 — 기능 플래그 + 오토컨피그 제외

./gradlew clean build가 중간에 실패했다. 원인은 빌드 단계에서 스프링 컨텍스트가 벡터/임베딩 관련 빈(예: QdrantClient, EmbeddingService, OpenAIClient)을 항상 생성하려고 시도했기 때문이다. 테스트 환경에는 Qdrant/OpenAI의 키나 엔드포인트가 없거나 접근이 막혀 있어 컨텍스트 로딩이 실패했다(예: OpenAI 자동구성 실패, Qdrant 호스트 해석 실패).
이에 따라 테스트 프로파일에서만 외부 연동을 OFF로 두고 로컬과 운영은 ON으로 유지하는 방향으로 정리했다. 이렇게 하면 테스트는 외부 의존 없이 통과하고 로컬/운영에서는 실제 연동을 켜둔 채 E2E 검증이 가능하다.

조건부 빈 등록

@Configuration
@ConditionalOnProperty(prefix = "openai", name = "enabled", havingValue = "true")
public class OpenAiConfig { ... }

@ConditionalOnProperty(prefix = "vector", name = "enabled", havingValue = "true")
@Service
public class EmbeddingService { ... }

@ConditionalOnProperty로 외부 시스템을 쓰는 빈을 프로퍼티로 제어한다. 테스트에선 vector.enabled=false, openai.enabled=false로 두어 컨텍스트 로딩만 가볍게 올리고 로컬 및 운영에선 true로 켜서 실제 Qdrant/OpenAI 연동을 사용한다. 이 방식은 테스트 안정성을 확보하면서도 로컬에서 실제 시나리오를 검증할 수 있는 현실적인 균형을 제공한다.

프로파일별 플래그

# application-test.yml (테스트: OFF)
vector:
  enabled: false
openai:
  enabled: false

# application-prod.yml (운영: ON)
vector:
  enabled: true
openai:
  enabled: true

# (선택) application-local.yml (로컬: ON)
vector:
  enabled: true
openai:
  enabled: true

테스트 프로파일만 OFF로 고정하면 CI/로컬 테스트 실행 시 외부 연동이 완전히 제거되어 키/네트워크 유무와 무관하게 항상 통과한다. 반면 로컬 및 운영에선 ON으로 유지해 실제 임베딩/검색 흐름을 확인할 수 있다. 로컬에서 ON을 쓰려면 로컬 Qdrant 컨테이너를 띄우거나 EC2 Qdrant로 SSH 터널링을 열어 http://localhost:6333으로 붙는 식으로 접근성을 확보하면 된다.

OpenAI 자동설정 제외

spring:
  autoconfigure:
    exclude:
      - com.openai.springboot.OpenAIClientAutoConfiguration

OpenAI 스타터의 자동구성은 기본적으로 OpenAIClient 빈 생성을 시도한다. 테스트에서는 이 자동구성이 키/네트워크 의존을 강제하므로 바로 실패로 이어질 수 있다. 자동구성을 명시적으로 제외하고 위의 OpenAiConfig처럼 직접 구성 + 조건부 활성화로 전환하면 테스트에선 끄고 로컬 및 운영에서만 켜는 정밀 제어가 가능하다. (로컬에서도 켜려면 .env나 IDE 환경변수로 OPENAI_API_KEY를 제공해 주면 된다.)

느낀점

이번 작업의 핵심은 “테스트는 가볍고 결정적이어야 한다”였다. 처음엔 로컬/CI에서 ./gradlew clean build가 괜찮다가도 외부 의존성에서 계속 발목이 잡혔다. 그래서 아예 테스트 프로파일에서는 외부 연동을 끊고(OFF), 로컬/운영에서만 ON으로 돌리는 쪽으로 틀었다. @ConditionalOnProperty로 빈을 걸고 OpenAI 자동구성을 제외해 컨텍스트 로딩을 외부 상태와 분리하니 빌드가 다시 예측 가능해졌다.

인프라 쪽은 멱등성이 살렸다. 배포 스크립트가 여러 번 돌아도 같은 상태로 수렴하게 만들고 Qdrant는 healthz로 준비 상태를 확인한 뒤 앱을 띄우도록 했다. 이게 사소해 보여도 진짜 큰 차이를 만든다. 야간에 한 번 더 눌러도 불안하지 않고 실패 지점이 명확해진다. 개인적으로는 “배포=멱등 루틴”이 몸에 배게 됐다.

보안/구성은 기본 차단 + 필요할 때만 열기가 정답이었다. Qdrant를 127.0.0.1로 묶어 외부 노출을 막고 필요하면 SSH 포워딩으로만 본다. 비밀값은 레포에 두지 않고 원격에서 템플릿+envsubst로 생성해 런타임에만 주입한다. 이렇게 하니 설정이 “살아있는 값”으로 관리되고 누가 어디서 값을 바꿨는지 추적하기도 쉬워졌다.

마지막으로 이번에 느낀 건 규칙이 쌓이면 속도가 붙는다는 것이다. “테스트는 외부 OFF”, “배포는 멱등”, “외부 서비스는 플래그로 제어”, “네트워크는 기본 차단” 같은 룰을 몇 개 정리해두니 다음 이슈가 와도 프레임이 이미 있어서 빠르게 대응할 수 있었다. 앞으로도 새 의존성을 붙일 땐 기본 OFF로 시작하고 운영에서만 켜는 방향을 먼저 생각하는 습관을 계속 가져가려 한다.

profile
개발자가 되고 싶은 취준생

0개의 댓글