[ci/cd] 대학생 팀의 배포 파이프라인 진화기
PR 하나면 끝나는 무중단 배포까지, 삽질의 기록
1. 시작은 단순했다
"서버에 올려야 하는데... 어떻게 하지?"
Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다.
1. SSH로 서버 접속
2. git pull
3. ./gradlew build
4. java -jar app.jar
당연히 문제가 생겼다.
빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥)
빌드가 실패하면 이전 버전도 날아감
누가 마지막에 배포했는지 아무도 모름
그래서 GitHub Actions로 자동화를 시작했다. (Issue #3)
2. 1단계: CI + CD 기본기 만들기
CI — "최소한 빌드는 되는 코드만 머지하자"
가장 먼저 만든 건 CI(Continuous Integration) 워크플로우였다.
# .github/workflows/ci.yml
name: Finders CI (Build & Test)
on:
pull_request:
branches: [ "develop", "main" ]
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
- name: Cache Gradle packages # ⭐ 빌드 속도 최적화
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
- name: Test & Build
run: ./gradlew clean build
핵심 포인트:
PR을 올리면 자동으로 빌드 + 테스트 실행
실패하면 머지 불가 (Branch Protection Rule)
Gradle 캐시로 의존성 다운로드 시간 절약 (체감 40~50% 단축)

GitHub Actions가 PR마다 자동으로 빌드/테스트를 실행한다
CD — "머지하면 알아서 배포되게"
CI가 돌아가니, 이제 배포도 자동화하고 싶었다. 처음에는 deploy.yml 하나와 docker-compose.prod.yml 하나만 있었다.
main에 push → Docker 이미지 빌드 → GCE 서버에 배포
이때의 구조는 아주 단순했다:
.github/workflows/
├── ci.yml ← PR 빌드/테스트
└── deploy.yml ← main push 시 배포
docker-compose.prod.yml ← 서버에서 Docker로 실행
Dockerfile ← 멀티스테이지 빌드
Docker 멀티스테이지 빌드
Dockerfile도 2단계로 나눠서 최적화했다:
# Stage 1: 빌드 (JDK 포함 — 무거움)
FROM eclipse-temurin:21-jdk AS builder
COPY . .
RUN ./gradlew build -x test --no-daemon
# Stage 2: 실행 (JRE만 — 가벼움)
FROM eclipse-temurin:21-jre
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
왜 이렇게 할까?
빌드에 필요한 JDK, Gradle, 소스코드는 런타임에 필요 없음
최종 이미지 크기가 확 줄어듦 → 배포 속도 향상
3. 2단계: "prod랑 dev를 분리해야겠다"
처음엔 main 브랜치 하나에 prod 환경만 있었다. 그런데 프론트엔드 팀과 API 연동을 시작하면서 문제가 터졌다.
프론트엔드: "이 API 아직도 안 돼요...?"
백엔드: "아직요... main에 머지가 안 됐어서..."
프론트엔드: "그게 언제 될까요?"
백엔드: "승인 받아야 하는데..." 😰
main 브랜치에 머지하려면 팀원 최소 1명의 approve가 필요했다. 코드 리뷰는 꼭 필요한 과정이지만, API 연동 초기에는 매번 작은 수정마다 main PR → 승인 대기 → 머지 → 배포 사이클을 돌려야 했고, 프론트엔드 팀은 계속 기다려야 했다.
이게 반복되면서 결국 깨달았다: 개발용 서버가 따로 있어야 한다.
develop브랜치에 머지하면 → dev 서버에 바로 배포 (연동 테스트용)main브랜치에 머지하면 → prod 서버에 배포 (코드 리뷰 후)
이렇게 하면 프론트엔드 팀은 dev 서버에서 자유롭게 연동 테스트를 하고, prod에는 검증된 코드만 올라가게 된다.
application.yml 분리
src/main/resources/
├── application.yml ← 공통 설정
├── application-local.yml ← 로컬 개발 (Docker MySQL)
├── application-dev.yml ← 개발 서버 (GCE)
└── application-prod.yml ← 운영 서버 (GCE)
환경별로 뭐가 다를까?
| 설정 | Dev | Prod |
| DB DDL | update (스키마 자동 수정) | validate (검증만) |
| 로그 레벨 | DEBUG | WARN |
| JWT 만료 | 24시간 (테스트 편의) | 30분 (보안) |
| Swagger | 활성화 | 비활성화 |
| Sentry 샘플링 | 100% | 30% |
Docker Compose & CD 워크플로우 분리
.github/workflows/
├── ci.yml
├── deploy.yml ← main → prod 배포
└── deploy-dev.yml ← develop → dev 배포 🆕
docker-compose.infra.yml ← 공통 인프라 (Traefik, Cloudflared)
docker-compose.prod.yml ← prod 앱 설정
docker-compose.dev.yml ← dev 앱 설정 🆕
이제 develop 브랜치에 머지하면 dev 서버, main 브랜치에 머지하면 prod 서버에 자동 배포된다!
4. 3단계: "배포할 때 서버가 죽어요" → 무중단 배포
환경 분리까지는 좋았다. 그런데 배포할 때마다 서버가 죽는 문제가 있었다.
배포 과정이 이랬다:
1. docker compose down ← 여기서 서버 사망 💀
2. 새 이미지 Pull
3. docker compose up
4. Spring Boot 기동 대기...
docker compose down을 하는 순간부터 서버가 내려간다. 새 컨테이너가 뜨고 Spring Boot가 완전히 기동될 때까지 짧으면 3분, 길면 5분. 그 사이에 들어오는 모든 요청은 503 에러.
팀원들이 API를 테스트하다가 갑자기 503이 뜨기 시작하면...
"서버 왜 안 돼요?"
"아 배포 중이에요... 5분만 기다려주세요..."
"..." 😐
특히 데모 준비할 때 배포하면 대참사였다. 🤦
Nginx vs Traefik, 뭘 쓸까?
무중단 배포를 위해 리버스 프록시가 필요했다. 두 가지를 비교했다:
| Nginx | Traefik | |
| 설정 방식 | 정적 설정 파일 (nginx.conf) | Docker 라벨로 자동 감지 ⭐ |
| 새 서비스 추가 시 | 설정 파일 수정 + reload 필요 | 라벨만 붙이면 자동 인식 |
| 학습 곡선 | 자료 많음 | 상대적으로 적음 |
| Blue-Green 적합도 | 스크립트로 직접 구현 필요 | 프로필 전환만으로 가능 |
Traefik을 선택한 이유: Docker Compose의 labels만으로 라우팅이 설정되니까, Blue-Green 배포가 훨씬 간단했다!
Blue-Green 배포란?

출처: BlueGreenDeployment — Martin Fowler
서버를 2개 슬롯(Blue, Green)으로 운영하는 방식이다:
[사용자] → [Traefik] → [🔵 Blue 슬롯] ← 현재 트래픽 처리 중
[🟢 Green 슬롯] ← 대기 중 (비어있음)
배포할 때:
Green에 새 버전 올리기 (같은 Traefik 라벨 등록)
Green 헬스체크 통과 확인
Traefik이 새로 뜬 Green 컨테이너를 자동 감지해서 라우팅
Blue 컨테이너 제거 → Green만 남음
핵심: 항상 1대만 트래픽을 받되, 전환 사이에 다운타임이 0!
참고: 로드밸런서처럼 2대가 동시에 트래픽을 나누는 게 아니다. Traefik은 리버스 프록시로, Docker 컨테이너가 뜨고 내려가는 것을 감지해서 라우팅 대상을 자동 전환해주는 역할이다.
Docker Compose로 구현한 Blue-Green
# docker-compose.prod.yml (핵심만)
services:
prod-blue:
image: ${DOCKER_IMAGE}:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.prod.rule=Host(`api.finders.it.kr`)"
profiles: [blue] # ← 프로필로 선택적 실행
prod-green:
image: ${DOCKER_IMAGE}:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.prod.rule=Host(`api.finders.it.kr`)"
profiles: [green] # ← 프로필로 선택적 실행
Traefik의 핵심: Docker 컨테이너에 같은 라우팅 라벨(Host)이 붙어있으면, Traefik이 자동으로 감지한다. Green이 올라오면 Traefik이 Green을 인식하고, Blue를 내리면 자연스럽게 Green만 남아서 라우팅된다. 별도의 설정 변경이나 reload 없이!
Health Check: "진짜 살아있어?"
Green 컨테이너가 올라왔다고 바로 Blue를 내리면 안 된다. Spring Boot가 완전히 기동될 때까지 기다려야 한다.
# docker-compose.prod.yml
healthcheck:
test: ["CMD", "wget", "--spider", "-q",
"http://localhost:8080/api/actuator/health"]
interval: 20s
timeout: 5s
retries: 5
start_period: 60s # 기동 대기 시간
배포 스크립트에서 최대 180초 동안 체크하고, 실패하면 자동 롤백:
for i in {1..36}; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' $GREEN)
if [[ "$STATUS" == 'healthy' ]]; then
echo "✅ 정상! 기존 슬롯 제거"
break
fi
if [[ "$STATUS" == 'unhealthy' ]]; then
echo "❌ 비정상! 롤백"
docker stop $GREEN && docker rm -f $GREEN
exit 1 # GitHub Actions 실패 → 팀에게 알림
fi
sleep 5
done
5. 보너스: GCP 인증은 비밀번호 없이
GitHub Actions에서 GCP에 접근하려면 인증이 필요하다. 보통은 서비스 계정 키(JSON 파일)를 쓰는데, 이건 유출 위험이 있다.
우리는 Workload Identity Federation(WIF)을 사용했다:
# 키 파일 없이 인증!
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
키 파일 없이 GitHub → GCP 인증이 가능한 원리 (GCP 공식 문서)
원리 (한 줄 요약):
GitHub Actions가 "나 이 리포에서 온 워크플로우야"라고 증명하면, GCP가 임시 권한을 줌. 키 파일 불필요!
6. 최종 모습: 전체 파이프라인
| 워크플로우 | 파일 | 트리거 | 역할 |
| CI | ci.yml | PR → develop/main | 빌드 + 테스트 |
| CD (Dev) | deploy-dev.yml | develop push | Dev Blue-Green 배포 |
| CD (Prod) | deploy.yml | main push | Prod Blue-Green 배포 |
| IaC | terraform.yml | infra/ 변경 | Terraform Plan/Apply |
| Release | auto-release.yml | main push | 자동 버전 태깅 |
7. 마치며: 삽질의 연대기
돌아보면 이런 순서로 진화했다:
| 단계 | 뭘 했나 | 왜 했나 |
| 0단계 | SSH + 수동 빌드 | "일단 돌아가게만..." |
| 1단계 | CI + CD (GitHub Actions) | "수동 배포 지겨워..." |
| 2단계 | prod/dev 환경 분리 | "프론트가 테스트할 서버가 필요해..." |
| 3단계 | Traefik + Blue-Green | "배포할 때 서버 죽는 거 못 참겠어..." |
매번 "이 정도면 됐지" 싶었는데, 실제로 운영하다 보면 다음 문제가 찾아왔다. 결국 CI/CD는 한 번에 완성되는 게 아니라, 필요에 따라 점진적으로 발전시키는 거라는 걸 배웠다.
지금은 개발자가 PR만 올리면 나머지는 전부 자동이다. "서버 배포해주세요"라는 말이 사라졌다. 😄