./gradlew clean build
시 테스트가 로컬/CI 모두 실패하는 문제 해결최종 결론
127.0.0.1
바인딩) 기동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
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
Gradle 빌드로 JAR 산출
PEM 저장 후, JAR, application-secret.yml, docker-compose.yml을 EC2 홈 디렉토리로 전송
원격 SSH에서
docker ps
로 qdrant 존재/상태를 판별 → start
또는 up -d
로 분기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 주입
${{ secrets.* }}
값을 원격 세션 환경변수로 export-Dspring.config.additional-location=...
로 앱에 전달Qdrant 접속 정보
QDRANT_HTTP_BASE_URL=http://127.0.0.1:6333
등 환경변수 export@Value
/ConfigurationProperties
또는 WebClient
Bean baseUrl)./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
으로 붙는 식으로 접근성을 확보하면 된다.
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로 시작하고 운영에서만 켜는 방향을 먼저 생각하는 습관을 계속 가져가려 한다.