Skip to main content

Command Palette

Search for a command to run...

[ci/cd] 대학생 팀의 배포 파이프라인 진화기

Published
7 min read

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 CI 체크

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)

환경별로 뭐가 다를까?

설정DevProd
DB DDLupdate (스키마 자동 수정)validate (검증만)
로그 레벨DEBUGWARN
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, 뭘 쓸까?

무중단 배포를 위해 리버스 프록시가 필요했다. 두 가지를 비교했다:

NginxTraefik
설정 방식정적 설정 파일 (nginx.conf)Docker 라벨로 자동 감지
새 서비스 추가 시설정 파일 수정 + reload 필요라벨만 붙이면 자동 인식
학습 곡선자료 많음상대적으로 적음
Blue-Green 적합도스크립트로 직접 구현 필요프로필 전환만으로 가능

Traefik을 선택한 이유: Docker Compose의 labels만으로 라우팅이 설정되니까, Blue-Green 배포가 훨씬 간단했다!

Blue-Green 배포란?

Blue-Green 배포 개념도

출처: BlueGreenDeployment — Martin Fowler

서버를 2개 슬롯(Blue, Green)으로 운영하는 방식이다:

[사용자] → [Traefik] → [🔵 Blue 슬롯] ← 현재 트래픽 처리 중
                        [🟢 Green 슬롯] ← 대기 중 (비어있음)

배포할 때:

  1. Green에 새 버전 올리기 (같은 Traefik 라벨 등록)

  2. Green 헬스체크 통과 확인

  3. Traefik이 새로 뜬 Green 컨테이너를 자동 감지해서 라우팅

  4. 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. 최종 모습: 전체 파이프라인

워크플로우파일트리거역할
CIci.ymlPR → develop/main빌드 + 테스트
CD (Dev)deploy-dev.ymldevelop pushDev Blue-Green 배포
CD (Prod)deploy.ymlmain pushProd Blue-Green 배포
IaCterraform.ymlinfra/ 변경Terraform Plan/Apply
Releaseauto-release.ymlmain push자동 버전 태깅

7. 마치며: 삽질의 연대기

돌아보면 이런 순서로 진화했다:

단계뭘 했나왜 했나
0단계SSH + 수동 빌드"일단 돌아가게만..."
1단계CI + CD (GitHub Actions)"수동 배포 지겨워..."
2단계prod/dev 환경 분리"프론트가 테스트할 서버가 필요해..."
3단계Traefik + Blue-Green"배포할 때 서버 죽는 거 못 참겠어..."

매번 "이 정도면 됐지" 싶었는데, 실제로 운영하다 보면 다음 문제가 찾아왔다. 결국 CI/CD는 한 번에 완성되는 게 아니라, 필요에 따라 점진적으로 발전시키는 거라는 걸 배웠다.

지금은 개발자가 PR만 올리면 나머지는 전부 자동이다. "서버 배포해주세요"라는 말이 사라졌다. 😄


📎 참고 이슈

More from this blog

[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어

-elastic ▶ 들어가며 이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다. Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다. 둘 다 데이터베이스라는 큰 틀 안에서 데...

Feb 17, 20264 min read

Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기

코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정 1. 시작 — "일단 돌아가게 만들자" Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다. "JPA 쓰면 쿼리 안 짜도 되는 거 아니야?" 솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, findById 쓰면 끝이니까. // 첫 번째 버전의 목록 조회 (QueryDS...

Feb 11, 20268 min read

[모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기

"서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지 1. 모니터링을 시작한 계기 프론트엔드: "API 안 되는데요?"백엔드: "엥? 언제부터요?"프론트엔드: "...2시간 전부터요?"백엔드: 😱 어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102) 2. 모니터링 도구 비교: 뭘 쓸까? 처음에는 여러 도구를 비교했다. 도구무료 티어장점단점 Sent...

Feb 11, 20268 min read

[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release

🚀 우리 auto-release.yml 바로 보러가기 → 이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다! main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다 1. 우리의 Git 전략: Git Flow (경량 버전) Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다. main ← 운영 서버 (prod) 배포 브...

Feb 11, 20265 min read
F

Finders Tech Blog

16 posts