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

> PR 하나면 끝나는 무중단 배포까지, 삽질의 기록

---

## 1\. 시작은 단순했다

> "서버에 올려야 하는데... 어떻게 하지?"

Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다.

```plaintext
1. SSH로 서버 접속
2. git pull
3. ./gradlew build
4. java -jar app.jar
```

당연히 문제가 생겼다.

* 빌드하는 동안 **서버가 꺼져있음** (프론트: "API 왜 안 돼요?" 🔥)
    
* 빌드가 실패하면 **이전 버전도 날아감**
    
* 누가 마지막에 배포했는지 **아무도 모름**
    

그래서 GitHub Actions로 자동화를 시작했다. (Issue [#3](https://github.com/Finders-Official/BE/issues/3))

---

## 2\. 1단계: CI + CD 기본기 만들기

### CI — "최소한 빌드는 되는 코드만 머지하자"

가장 먼저 만든 건 CI(Continuous Integration) 워크플로우였다.

```yaml
# .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 체크](https://docs.github.com/assets/cb-345/images/social-cards/actions.png align="left")

*GitHub Actions가 PR마다 자동으로 빌드/테스트를 실행한다*

### CD — "머지하면 알아서 배포되게"

CI가 돌아가니, 이제 **배포도 자동화**하고 싶었다. 처음에는 `deploy.yml` 하나와 [`docker-compose.prod`](http://docker-compose.prod)`.yml` 하나만 있었다.

```plaintext
main에 push → Docker 이미지 빌드 → GCE 서버에 배포
```

이때의 구조는 아주 단순했다:

```plaintext
.github/workflows/
├── ci.yml          ← PR 빌드/테스트
└── deploy.yml      ← main push 시 배포

docker-compose.prod.yml  ← 서버에서 Docker로 실행
Dockerfile               ← 멀티스테이지 빌드
```

```mermaid
graph LR
    A["🧑‍💻 코드 Push"] --> B["⚙️ GitHub Actions"]
    B --> C["🐳 Docker 이미지 빌드"]
    C --> D["🚀 서버 배포"]
```

### Docker 멀티스테이지 빌드

Dockerfile도 **2단계**로 나눠서 최적화했다:

```dockerfile
# 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 분리

```plaintext
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 워크플로우 분리

```plaintext
.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 서버**에 자동 배포된다!

```mermaid
graph TB
    F["feature/*"] -->|"PR + 코드리뷰"| D["develop"]
    D -->|"자동 배포"| DEV["🖥️ Dev 서버"]
    D -->|"PR + approve"| M["main"]
    M -->|"자동 배포"| PROD["🖥️ Prod 서버"]
```

---

## 4\. 3단계: "배포할 때 서버가 죽어요" → 무중단 배포

환경 분리까지는 좋았다. 그런데 배포할 때마다 **서버가 죽는 문제**가 있었다.

배포 과정이 이랬다:

```plaintext
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 배포란?

![Blue-Green 배포 개념도](https://martinfowler.com/bliki/images/blueGreenDeployment/blue_green_deployments.png align="left")

*출처:* [*BlueGreenDeployment*](https://martinfowler.com/bliki/BlueGreenDeployment.html) *— Martin Fowler*

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

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

배포할 때:

1. Green에 새 버전 올리기 (같은 Traefik 라벨 등록)
    
2. Green 헬스체크 통과 확인
    
3. Traefik이 새로 뜬 Green 컨테이너를 **자동 감지**해서 라우팅
    
4. Blue 컨테이너 제거 → Green만 남음
    

**핵심: 항상 1대만 트래픽을 받되, 전환 사이에 다운타임이 0!**

> 참고: 로드밸런서처럼 2대가 동시에 트래픽을 나누는 게 아니다. Traefik은 **리버스 프록시**로, Docker 컨테이너가 뜨고 내려가는 것을 감지해서 라우팅 대상을 자동 전환해주는 역할이다.

### Docker Compose로 구현한 Blue-Green

```yaml
# 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가 완전히 기동될 때까지 기다려야 한다.

```yaml
# 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초** 동안 체크하고, 실패하면 **자동 롤백**:

```bash
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)**을 사용했다:

```yaml
# 키 파일 없이 인증!
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
    service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
```

```mermaid
graph LR
    GH["GitHub Actions"] -->|"OIDC 토큰 발급"| STS["GCP STS"]
    STS -->|"임시 토큰 교환"| SA["Service Account"]
    SA -->|"GCP 리소스 접근"| GCE["GCE / Artifact Registry"]
```

*키 파일 없이 GitHub → GCP 인증이 가능한 원리 (*[*GCP 공식 문서*](https://cloud.google.com/iam/docs/workload-identity-federation)*)*

**원리 (한 줄 요약):**

> GitHub Actions가 "나 이 리포에서 온 워크플로우야"라고 증명하면, GCP가 임시 권한을 줌. 키 파일 불필요!

---

## 6\. 최종 모습: 전체 파이프라인

```mermaid
graph LR
    A["🧑‍💻 코드 작성"] --> B["📋 PR 생성"]
    B --> C["✅ CI: 빌드+테스트"]
    C --> D{"코드 리뷰 & 승인"}
    D -->|develop merge| E["🚀 Dev Blue-Green 배포"]
    D -->|main merge| F["🚀 Prod Blue-Green 배포"]
    F --> G["🏷️ 자동 릴리즈"]
```

| 워크플로우 | 파일 | 트리거 | 역할 |
| --- | --- | --- | --- |
| 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\. 마치며: 삽질의 연대기

```mermaid
graph LR
    S0["🔧 수동 배포"] --> S1["⚡ CI + CD"]
    S1 --> S2["🔀 환경 분리"]
    S2 --> S3["🔄 Blue-Green"]
```

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

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

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

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

---

### 📎 참고 이슈

* [#3 CI/CD 파이프라인 구축](https://github.com/Finders-Official/BE/issues/3)
    
* [#101 CI/CD 무중단 배포 구현](https://github.com/Finders-Official/BE/issues/101)
    
* [#194 Cloudflare Tunnel 도입 및 WIF 기반 CI/CD 고도화](https://github.com/Finders-Official/BE/issues/194)
