프롤로그 - "금요일 오후 5시, 배포 금지"
수동으로 배포하던 시절에는 이런 불문율이 있었습니다.
"금요일엔 절대 배포하지 말 것."
이유는 간단했습니다. 배포하다가 버그가 터지면 → 주말 야근 확정.
직접 경험해본 적 있는 상황입니다. 금요일 오후 4시 50분에 "작은 버그 픽스"를 배포했습니다. 코드 한 줄만 바꿨을 뿐인데... 배포 후 서비스가 완전히 다운됐습니다.
// Before
const result = data.filter(item => item.status === "active")
// After - "안전하게" optional chaining 추가
const result = data?.filter(item => item.status === "active")
// 문제: 다른 곳에서 data가 undefined일 때 예외 처리가 없었음
// → 연쇄 에러 → 서비스 전체 다운
"로컬 테스트는 다 통과했는데요?"라고 변명해 봤지만... 알고 보니 내 컴퓨터에서만 테스트하고 실제 프로덕션 환경에선 안 돌렸던 겁니다. 그날 밤 늦게까지 서버 복구하던 기억이 지금도 생생합니다.
왜 CI/CD를 공부하게 되었나 - 이 경험이 동기부여
그 사건 이후 CI/CD를 제대로 공부하기 시작했습니다. 배포 문서를 읽으면서 자동화 파이프라인의 개념이 처음 눈에 들어왔습니다.
CI/CD 파이프라인: 코드를 푸시하면 로봇이 자동으로 테스트하고 배포하는 것.
"그럼 사람이 일일이 안 해도 되는 건가?" 맞습니다. 로봇이 알아서 합니다. 금요일 사고를 방지하기 위한 구조가 바로 이것이었습니다.
깨달았습니다. 제가 겪은 문제는 개인의 실수만이 아니라, 자동화되지 않은 프로세스 자체가 문제였다는 걸 말이죠.
제가 처음 혼자 프로젝트를 배포할 때의 일과
# 1단계 - 로컬에서 빌드
$ npm run build
# 2단계 - FTP 프로그램(Filezilla) 실행
[드래그 앤 드롭으로 200개 파일 업로드]
[진행률: 32%... 서버 연결 끊김]
[다시 업로드]
# 3단계 - SSH로 서버 접속
$ ssh user@production-server.com
$ cd /var/www/app
$ pm2 restart app
# 4단계 - 브라우저로 확인
"어? 왜 화면이 안 뜨지?"
# 5단계 - 로그 확인
$ pm2 logs
"Error: Cannot find module..."
# 아, package.json 업데이트했는데 npm install 안 했네
# 6단계 - 다시 설치
$ npm install
$ pm2 restart app
# 7단계 - 또 확인
"또 안 돼..."
# 이번엔 .env 파일을 깜빡하고 안 올렸네
# 8단계 - .env 업로드
[다시 FTP로 .env 올리기]
# 9단계 - 30분 후 겨우 성공
하루에 10번 배포하면 미칩니다. 이게 2024년에도 제가 했던 작업 방식이었습니다. 말도 안 되죠?
처음엔 뭐가 이해가 안 갔나
"CI (Continuous Integration)"와 "CD (Continuous Delivery/Deployment)"
- Continuous가 "지속적"이라는데, 뭘 지속적으로 한다는 거지?
- Integration은 "통합"인데, 뭘 통합한다는 거야? 코드? 서버?
- Delivery와 Deployment는 또 뭐가 다르지? 배포는 배포 아닌가?
용어가 너무 추상적이었습니다. 그리고 더 큰 문제는, 주변에 아무도 정확히 설명해주는 사람이 없었다는 겁니다. 다들 "아 그거? 자동화하는 거지 뭐" 정도로만 설명하고 넘어갔습니다.
그리고 "테스트를 자동으로 돌린다"는 건 알겠는데, 그게 왜 중요한지 몰랐습니다. "테스트 코드 안 짜도 되는데 굳이?" 하는 마음이 컸습니다.
깨달음의 순간 - "공장 조립 라인"이라는 비유
자동차 공장 비유가 CI/CD를 이해하는 데 가장 도움이 됐습니다.
"옛날에는 장인 한 명이 처음부터 끝까지 차를 만들었습니다. (수동 배포)
현대 공장은 컨베이어 벨트입니다.
- 1번 로봇: 차체를 용접 (빌드)
- 2번 로봇: 페인트 칠 (Lint & Format)
- 3번 로봇: 불량품 검수 (Test)
- 4번 로봇: 출고 트럭에 적재 (배포)
사람은 설계도(코드)만 그립니다. 나머지는 로봇이 알아서 합니다."
바로 이거였습니다. CI/CD는 결국 "배포 공장 자동화"였던 겁니다. 이 비유로 모든 게 이해됐습니다. 수동으로 FTP 업로드하던 행위는, 마치 자동차를 손으로 일일이 조립하는 것과 같았던 거죠.
1. CI (Continuous Integration): 코드 통합의 자동화
정의를 나를 위해 정리해본다
여러 개발자가 동시에 코드를 짜면, 누군가의 코드가 충돌할 수 있습니다. CI는 코드를 자주(Continuous) 합치고(Integration), 매번 자동으로 테스트해서 문제를 조기에 발견하는 시스템입니다.
개발자 A → Git Push → CI 서버
↓
1. 코드 Pull
2. 의존성 설치
3. 빌드 (Build)
4. Lint 검사
5. 단위 테스트 실행
6. 통합 테스트 실행
↓
모두 성공 → ✅ 머지 허용
하나라도 실패 → ❌ 머지 차단 + 개발자에게 알림
왜 필요한가? 제가 겪은 "It works on my machine" 지옥
실제 사례 1 - OS별 경로 차이
// 개발자 A의 Mac
const path = require('path')
const filePath = 'data/users.json' // 잘 작동
// 개발자 B의 Windows
const filePath = 'data\\users.json' // 잘 작동
// 프로덕션 서버 (Linux)
// 둘 다 푸시했더니 → 경로 충돌 → 서비스 다운
로컬에선 다 작동하는데, 프로덕션에선 터집니다. 이게 바로 제가 금요일 밤에 당한 문제였습니다.
CI의 해결책 - 프로덕션과 동일한 환경에서 미리 테스트
# .github/workflows/ci.yml
name: CI Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest # 프로덕션과 동일한 환경
strategy:
matrix:
node-version: [16, 18, 20] # 여러 버전에서 테스트
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci # npm install보다 안전 (package-lock.json 기준)
- name: Run linter
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test -- --coverage
- name: Integration tests
run: npm run test:integration
- name: Build
run: npm run build
코드를 푸시하는 순간:
- 동일한 환경(ubuntu-latest)에서 빌드
- Lint, 타입 체크, 유닛 테스트, 통합 테스트 자동 실행
- 빌드까지 성공해야만 머지 허용
- 실패하면 → PR 머지 차단 + GitHub에 빨간 X 표시
프로덕션에 배포되기 전에 문제를 발견합니다. 이제 "내 컴퓨터에서는 됐는데요"라는 변명이 통하지 않습니다.
제가 직접 경험한 CI의 위력
Before CI: 범인 찾기 게임
월요일 오전:
개발자 5명이 동시에 PR 날림
→ 시니어가 수동으로 하나씩 코드 리뷰 후 머지
→ 1번 PR 머지: OK
→ 2번 PR 머지: OK
→ 3번 PR 머지: OK
→ 빌드 시도 → 💥 실패
→ "누가 빌드 깬 거야?" 범인 찾기 시작
→ 30분 후 발견: 2번 PR과 3번 PR이 같은 함수 수정
→ Slack에 "@개발자C, 빌드 고쳐주세요"
→ 개발자C: "지금 회의 중인데요..."
→ 1시간 후 수정
→ 전체 팀 개발 지연
After CI: 즉각 피드백
월요일 오전:
개발자 5명이 동시에 PR 날림
→ 각 PR마다 자동으로 CI 돌아감
→ 1번 PR: ✅ "All checks passed"
→ 2번 PR: ✅ "All checks passed"
→ 3번 PR: ❌ "Tests failed: 2 conflicts detected with PR #2"
→ 개발자C에게 즉시 GitHub 알림
→ 개발자C: 2번 PR 코드 확인 후 즉시 수정
→ 수정 후 재푸시
→ ✅ 통과 → 머지
→ 전체 팀 개발 계속 진행
문제를 즉시 발견하고, 해당 개발자가 즉시 수정. 이게 CI의 핵심입니다. 결국 이거였다는 걸 이해했습니다.
2. CD (Continuous Delivery/Deployment): 배포의 자동화
Delivery vs Deployment - 이 차이를 받아들였다
Continuous Delivery (반자동)
- 배포 준비까지만 자동화
- 실제 프로덕션 배포는 사람이 버튼 클릭
- "이 버전은 검증됐으니, 원하는 타이밍에 배포 가능합니다"
- 언제 쓰나? 금융, 의료 등 배포 시점이 비즈니스적으로 중요한 경우
Continuous Deployment (완전 자동)
- 배포까지 완전 자동화
- 테스트 통과하면 즉시 프로덕션에 자동 배포
- 사람 개입 없음
- 언제 쓰나? 스타트업, SaaS처럼 빠른 반복이 중요한 경우
저는 처음엔 Delivery(버튼 클릭)로 시작해서, 나중에 Deployment(완전 자동)로 진화했습니다. 처음부터 완전 자동화하면 무섭거든요.
실제 파이프라인 예시 - 제가 지금 쓰는 것
# .github/workflows/deploy.yml
name: Production Deploy
on:
push:
branches: [main] # main 브랜치에 푸시될 때만
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci # 전체 재설치 (npm install보다 안전)
- name: Run tests
run: npm test
- name: Build production
run: npm run build
env:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-args: '--prod'
- name: Wait for deployment
run: sleep 30
- name: Run smoke tests
run: |
npx wait-on https://my-app.vercel.app --timeout 60000
npx cypress run --spec "cypress/e2e/smoke.cy.js"
- name: Notify Slack on success
if: success()
uses: 8398a7/action-slack@v3
with:
status: custom
custom_payload: |
{
text: "✅ Deployment Successful",
attachments: [{
color: 'good',
text: `Version: ${{ github.sha }}\nAuthor: ${{ github.actor }}\nMessage: ${{ github.event.head_commit.message }}`
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Notify Slack on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: custom
custom_payload: |
{
text: "❌ Deployment Failed",
attachments: [{
color: 'danger',
text: `Check logs: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
main 브랜치에 푸시하면 자동으로:
- 의존성 설치 (캐싱으로 빠르게)
- 테스트 실행 (실패하면 여기서 중단)
- 프로덕션 빌드 (환경 변수 주입)
- Vercel에 배포
- 배포된 사이트가 올라올 때까지 대기
- Smoke Test (기본 동작 확인)
- Slack으로 성공/실패 알림
사람은 코드만 푸시합니다. 나머지는 완전 자동.
3. 적용 - Before/After 비교
Before: 수동 배포의 지옥
# 금요일 오후 5시 50분
$ ssh user@production-server.com
$ cd /var/www/my-app
$ git pull origin main
$ npm install
# [5분 대기]
$ npm run build
# [3분 대기]
$ pm2 restart app
# 6시 05분
> "어? 서버가 안 돌아가네?"
> 로그 확인: "Error: Missing environment variable"
> "아... .env 파일 업데이트 안 했네"
$ vim .env
[환경 변수 수동 입력]
$ pm2 restart app
# 6시 25분
> "이번엔 되는 것 같은데..."
> "어? API 응답이 이상한데?"
> "아 DB 마이그레이션을 안 돌렸네"
$ npm run migrate
# [에러 발생 - 이전 마이그레이션 스크립트가 꼬임]
$ npm run migrate:rollback
$ npm run migrate
# 7시 15분
> Slack 알림: "Production down - 500 errors"
> 팀장: "무슨 일이야?"
# 8시
> 겨우 복구 완료
> 퇴근은 10시
After: CI/CD 파이프라인
# 금요일 오후 5시 55분
$ git add .
$ git commit -m "Fix: Critical user authentication bug"
$ git push origin main
# GitHub Actions가 자동 실행
# [본인은 커피 마시러 감]
# 2분 후 (자동 진행)
✅ Checkout: 0.5s
✅ Install dependencies: 12s (cached)
✅ Lint: 3s
✅ Type check: 5s
✅ Unit tests: 18s (247/247 passed)
✅ Integration tests: 25s (58/58 passed)
✅ Build: 45s
✅ Deploy to Vercel: 30s
✅ Smoke tests: 15s (12/12 passed)
# 6시 02분
> Slack 알림:
> "✅ v1.2.3 deployed successfully by @yourname"
> "Deployment took 2m 53s"
> "Test coverage: 87.3%"
> "0 errors, 0 warnings"
> "Live at: https://my-app.vercel.app"
# 6시 05분
> 퇴근
금요일 배포가 더 이상 무섭지 않습니다. 이 차이를 직접 경험하고 나니, CI/CD 없이 어떻게 개발했나 싶습니다.
브랜치 전략 - 제가 실제로 쓰는 워크플로우
feature/user-auth
↓ (PR 생성)
[CI: 자동 테스트 + Lint]
[Preview Deploy: Vercel이 임시 URL 생성]
예: https://my-app-pr-123.vercel.app
↓ (코드 리뷰 + 승인)
develop
↓ (하루 1~2회 머지)
[CI: 다시 전체 테스트]
[CD: Staging 환경에 자동 배포]
예: https://staging.my-app.com
↓ (QA 팀 검증 + 승인)
main
↓ (자동 트리거)
[CI: 최종 테스트]
[CD: Production 자동 배포]
예: https://my-app.com
↓
[Slack/Email 알림]
4. 주요 CI/CD 도구 비교 - 제가 써본 것들
GitHub Actions
# 장점
- GitHub와 완벽 통합 (PR에 바로 결과 표시)
- Public repo는 무료
- Private repo도 월 2,000분 무료
- YAML 문법 간단
- Marketplace에 수천 개 액션 (설치 한 줄이면 됨)
- 서버 관리 불필요
# 단점
- Private repo 무료 시간 초과하면 유료
- 복잡한 파이프라인은 YAML이 길어짐
- 디버깅이 약간 불편 (로컬에서 못 돌림)
제 선택: 99% GitHub Actions를 씁니다. GitHub 쓰고 있으면 이게 제일 편합니다.
Jenkins
// 장점
- 완전 오픈소스 (무료)
- 플러그인 생태계 방대
- 커스터마이징 자유도 최고
- 복잡한 파이프라인 구성 가능
// 단점
- 직접 서버 띄워야 함 (EC2 비용 발생)
- UI가 2010년대 느낌
- 초기 설정 복잡 (Groovy 문법 배워야 함)
- 보안 업데이트 직접 관리
언제 쓰나? 대기업, 레거시 시스템, 온프레미스 환경
GitLab CI/CD
# 장점
- GitLab과 완벽 통합
- Self-hosted 가능
- Docker 기반 러너
- UI/UX가 GitHub Actions보다 좋음
# 단점
- GitLab 써야 함 (GitHub 쓰면 의미 없음)
CircleCI
# 장점
- 속도가 제일 빠름 (특히 Docker 빌드)
- UI/UX 훌륭
- 병렬 실행 최적화 잘 됨
# 단점
- 비쌈 (Free tier 너무 제한적)
- Small팀 기준 월 $30부터 시작
저의 선택: 작은 프로젝트는 GitHub Actions, 큰 회사는 Jenkins, 빠른 속도가 필요하면 CircleCI.
5. 최신 트렌드 1 - GitOps (선언적 배포)
"인프라를 코드로 관리한다면, 배포 상태도 코드로 관리해야 한다."
기존 방식 (Push 방식)
CI 도구(Jenkins)가 kubectl apply 명령어로 클러스터에 배포를 밀어넣습니다.
문제점:
- Jenkins가 클러스터 Admin 권한 필요 (보안 취약)
- 누가 수동으로
kubectl edit로 클러스터 수정하면 Git과 상태 불일치 - 롤백이 어려움
GitOps 방식 (Pull 방식) - ArgoCD
클러스터 내부에 ArgoCD가 있고, ArgoCD가 Git 저장소를 계속 지켜봅니다.
Git Repo (manifest.yaml)
image: myapp:v2
↑
│ (ArgoCD가 5초마다 체크)
│
ArgoCD (클러스터 안)
↓
"어? Git에는 v2인데, 지금 클러스터는 v1이네?"
↓
자동 Sync
↓
클러스터 상태 = Git 상태
장점:
- 외부에서 클러스터 접근 불필요 (보안 강화)
- Git = Single Source of Truth (Git이 진실의 유일한 원천)
- 누가 수동으로 바꿔도 자동으로 Git 상태로 되돌림
- 롤백 = Git revert (쉬움)
6. 최신 트렌드 2 - DevSecOps (보안을 파이프라인에)
"보안 팀은 배포 직전에 막는 사람이 아니라, 파이프라인에 녹아있어야 한다."
CI 파이프라인에 보안 단계 추가
jobs:
security-scan:
steps:
# 1. SAST (정적 분석): 코드 보안 취약점
- name: SonarQube Scan
run: sonar-scanner
# 2. SCA (종속성 검사): 라이브러리 취약점
- name: Snyk Dependency Check
run: snyk test
# 3. Secret Scanning: 하드코딩된 비밀번호
- name: GitLeaks
run: gitleaks detect
# 4. Container Scanning: Docker 이미지 취약점
- name: Trivy Scan
run: trivy image myapp:latest
실제 경험: Snyk가 Log4Shell 취약점을 자동으로 발견해서 배포 전에 막아줬습니다. 수동으로 했으면 놓쳤을 겁니다.
7. 최신 트렌드 3: Docker CI/CD
Docker 기반 파이프라인
name: Docker Build & Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to ECR
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:latest
123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:${{ github.sha }}
cache-from: type=registry,ref=myapp:buildcache
cache-to: type=inline
- name: Update Kubernetes manifest
run: |
sed -i "s|image: myapp:.*|image: myapp:${{ github.sha }}|" k8s/deployment.yaml
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add k8s/deployment.yaml
git commit -m "Update image to ${{ github.sha }}"
git push
핵심: Docker Layer Caching을 잘 쓰면 빌드 시간이 10분 → 1분으로 줄어듭니다.
8. 실수와 삽질담 (제가 직접 겪은 것들)
실수 1 - Secrets를 코드에 하드코딩
# ❌ 절대 이러지 마세요
- name: Deploy to AWS
run: |
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
aws s3 sync ./build s3://my-bucket
GitHub에 푸시한 지 5분 만에:
- GitHub Security Alert: "AWS credentials exposed"
- AWS에서 이메일: "Your key has been compromised"
- 봇이 제 계정으로 암호화폐 채굴 시도
- 계정 일시 정지
해결책:
# ✅ GitHub Secrets 사용
- name: Deploy to AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: aws s3 sync ./build s3://my-bucket
교훈: 절대 비밀 정보를 코드에 넣지 마세요. GitHub Secrets, AWS Secrets Manager 등을 쓰세요.
실수 2 - 테스트 시간 오래 걸림
E2E 테스트가 10분 걸려서 매번 PR 올릴 때마다 기다리기 힘들었습니다.
해결 1: 병렬 실행 (Matrix Strategy)
strategy:
matrix:
browser: [chrome, firefox, safari]
node-version: [16, 18, 20]
# 3 browsers × 3 versions = 9개 작업 동시 실행
# 10분 → 약 2분으로 단축
해결 2: 의존성 캐싱
- name: Cache node_modules
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# npm install 시간 - 2분 30초 → 8초
실수 3 - Flaky Tests (간헐적 실패)
// ❌ 시간에 의존하는 테스트 (CI 서버 느리면 실패)
test('debounce function', () => {
fireEvent.click(button);
setTimeout(() => {
expect(apiMock).toHaveBeenCalled();
}, 100); // CI 서버에서는 100ms로 부족할 수 있음
});
// ✅ 명시적 대기 (waitFor 사용)
test('debounce function', async () => {
fireEvent.click(button);
await waitFor(() => {
expect(apiMock).toHaveBeenCalled();
}, { timeout: 3000 });
});
9. 비용 분석 - ROI 계산
Before CI/CD
| 항목 | 월 비용 | 시간 비용 |
|---|---|---|
| 수동 배포 (하루 2회) | $0 | 40시간/월 |
| 긴급 핫픽스 | $0 | 10시간/월 |
| 버그로 인한 다운타임 | 매출 손실 | - |
| 합계 | $0 | 50시간/월 |
After CI/CD
| 항목 | 월 비용 | 시간 비용 |
|---|---|---|
| GitHub Actions (Pro) | $21/월 | 0시간 |
| 긴급 핫픽스 | $0 | 2시간/월 |
| 다운타임 | 거의 없음 | - |
| 합계 | $21/월 | 2시간/월 |
월 $21 투자로 48시간 절약. 시급 $50으로 환산 → 월 $2,400 절약 ($2,379 순이익)
정리하면 - 제가 CI/CD를 통해 이해한 것
- "로봇한테 맡겨라" - 사람은 실수하지만 스크립트는 절대 안 함
- "자주 통합, 자주 배포" - 작은 변경을 빠르게 반영할수록 리스크 감소
- "테스트는 선택이 아닌 필수" - CI 없으면 언젠가 프로덕션이 터짐
- "금요일 배포가 두렵지 않다" - 자동화가 자신감을 만듦
처음엔 "테스트 짜기 귀찮은데... 시간 낭비 아닌가?"였지만, 지금은 "CI/CD 없이 어떻게 개발했지?" 싶습니다.
결국 이거였습니다: CI/CD는 단순한 도구가 아니라, 현대 개발의 기본 인프라입니다.
마치며 - 이제 금요일 오후 5시 55분
이제 저는 금요일 오후에도 배포합니다.
$ git push origin main
그리고 6시에 퇴근합니다.
로봇이 알아서 테스트하고, 빌드하고, 배포하고, 검증하고, 알려줍니다.
이게 2025년의 개발 방식입니다.