
CI/CD: 금요일에도 두렵지 않은 배포
코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.

코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

수동으로 배포하던 시절에는 이런 불문율이 있었습니다.
"금요일엔 절대 배포하지 말 것."
이유는 간단했습니다. 배포하다가 버그가 터지면 → 주말 야근 확정.
직접 경험해본 적 있는 상황입니다. 금요일 오후 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 파이프라인: 코드를 푸시하면 로봇이 자동으로 테스트하고 배포하는 것.
"그럼 사람이 일일이 안 해도 되는 건가?" 맞습니다. 로봇이 알아서 합니다. 금요일 사고를 방지하기 위한 구조가 바로 이것이었습니다.
깨달았습니다. 제가 겪은 문제는 개인의 실수만이 아니라, 자동화되지 않은 프로세스 자체가 문제였다는 걸 말이죠.
# 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)"
용어가 너무 추상적이었습니다. 그리고 더 큰 문제는, 주변에 아무도 정확히 설명해주는 사람이 없었다는 겁니다. 다들 "아 그거? 자동화하는 거지 뭐" 정도로만 설명하고 넘어갔습니다.
그리고 "테스트를 자동으로 돌린다"는 건 알겠는데, 그게 왜 중요한지 몰랐습니다. "테스트 코드 안 짜도 되는데 굳이?" 하는 마음이 컸습니다.
자동차 공장 비유가 CI/CD를 이해하는 데 가장 도움이 됐습니다.
"옛날에는 장인 한 명이 처음부터 끝까지 차를 만들었습니다. (수동 배포)
현대 공장은 컨베이어 벨트입니다.
- 1번 로봇: 차체를 용접 (빌드)
- 2번 로봇: 페인트 칠 (Lint & Format)
- 3번 로봇: 불량품 검수 (Test)
- 4번 로봇: 출고 트럭에 적재 (배포)
사람은 설계도(코드)만 그립니다. 나머지는 로봇이 알아서 합니다."
바로 이거였습니다. CI/CD는 결국 "배포 공장 자동화"였던 겁니다. 이 비유로 모든 게 이해됐습니다. 수동으로 FTP 업로드하던 행위는, 마치 자동차를 손으로 일일이 조립하는 것과 같았던 거죠.
여러 개발자가 동시에 코드를 짜면, 누군가의 코드가 충돌할 수 있습니다. CI는 코드를 자주(Continuous) 합치고(Integration), 매번 자동으로 테스트해서 문제를 조기에 발견하는 시스템입니다.
개발자 A → Git Push → CI 서버
↓
1. 코드 Pull
2. 의존성 설치
3. 빌드 (Build)
4. Lint 검사
5. 단위 테스트 실행
6. 통합 테스트 실행
↓
모두 성공 → ✅ 머지 허용
하나라도 실패 → ❌ 머지 차단 + 개발자에게 알림
// 개발자 A의 Mac
const path = require('path')
const filePath = 'data/users.json' // 잘 작동
// 개발자 B의 Windows
const filePath = 'data\\users.json' // 잘 작동
// 프로덕션 서버 (Linux)
// 둘 다 푸시했더니 → 경로 충돌 → 서비스 다운
로컬에선 다 작동하는데, 프로덕션에선 터집니다. 이게 바로 제가 금요일 밤에 당한 문제였습니다.
# .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
코드를 푸시하는 순간:
프로덕션에 배포되기 전에 문제를 발견합니다. 이제 "내 컴퓨터에서는 됐는데요"라는 변명이 통하지 않습니다.
월요일 오전:
개발자 5명이 동시에 PR 날림
→ 시니어가 수동으로 하나씩 코드 리뷰 후 머지
→ 1번 PR 머지: OK
→ 2번 PR 머지: OK
→ 3번 PR 머지: OK
→ 빌드 시도 → 💥 실패
→ "누가 빌드 깬 거야?" 범인 찾기 시작
→ 30분 후 발견: 2번 PR과 3번 PR이 같은 함수 수정
→ Slack에 "@개발자C, 빌드 고쳐주세요"
→ 개발자C: "지금 회의 중인데요..."
→ 1시간 후 수정
→ 전체 팀 개발 지연
월요일 오전:
개발자 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의 핵심입니다. 결국 이거였다는 걸 이해했습니다.
저는 처음엔 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 브랜치에 푸시하면 자동으로:
# 금요일 오후 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시
# 금요일 오후 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 알림]
# 장점
- GitHub와 완벽 통합 (PR에 바로 결과 표시)
- Public repo는 무료
- Private repo도 월 2,000분 무료
- YAML 문법 간단
- Marketplace에 수천 개 액션 (설치 한 줄이면 됨)
- 서버 관리 불필요
# 단점
- Private repo 무료 시간 초과하면 유료
- 복잡한 파이프라인은 YAML이 길어짐
- 디버깅이 약간 불편 (로컬에서 못 돌림)
제 선택: 99% GitHub Actions를 씁니다. GitHub 쓰고 있으면 이게 제일 편합니다.
// 장점
- 완전 오픈소스 (무료)
- 플러그인 생태계 방대
- 커스터마이징 자유도 최고
- 복잡한 파이프라인 구성 가능
// 단점
- 직접 서버 띄워야 함 (EC2 비용 발생)
- UI가 2010년대 느낌
- 초기 설정 복잡 (Groovy 문법 배워야 함)
- 보안 업데이트 직접 관리
언제 쓰나? 대기업, 레거시 시스템, 온프레미스 환경
# 장점
- GitLab과 완벽 통합
- Self-hosted 가능
- Docker 기반 러너
- UI/UX가 GitHub Actions보다 좋음
# 단점
- GitLab 써야 함 (GitHub 쓰면 의미 없음)
# 장점
- 속도가 제일 빠름 (특히 Docker 빌드)
- UI/UX 훌륭
- 병렬 실행 최적화 잘 됨
# 단점
- 비쌈 (Free tier 너무 제한적)
- Small팀 기준 월 $30부터 시작
저의 선택: 작은 프로젝트는 GitHub Actions, 큰 회사는 Jenkins, 빠른 속도가 필요하면 CircleCI.
"인프라를 코드로 관리한다면, 배포 상태도 코드로 관리해야 한다."
CI 도구(Jenkins)가 kubectl apply 명령어로 클러스터에 배포를 밀어넣습니다.
kubectl edit로 클러스터 수정하면 Git과 상태 불일치클러스터 내부에 ArgoCD가 있고, ArgoCD가 Git 저장소를 계속 지켜봅니다.
Git Repo (manifest.yaml)
image: myapp:v2
↑
│ (ArgoCD가 5초마다 체크)
│
ArgoCD (클러스터 안)
↓
"어? Git에는 v2인데, 지금 클러스터는 v1이네?"
↓
자동 Sync
↓
클러스터 상태 = Git 상태
장점:
"보안 팀은 배포 직전에 막는 사람이 아니라, 파이프라인에 녹아있어야 한다."
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 취약점을 자동으로 발견해서 배포 전에 막아줬습니다. 수동으로 했으면 놓쳤을 겁니다.
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분으로 줄어듭니다.
# ❌ 절대 이러지 마세요
- 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 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 등을 쓰세요.
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초
// ❌ 시간에 의존하는 테스트 (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 });
});
| 항목 | 월 비용 | 시간 비용 |
|---|---|---|
| 수동 배포 (하루 2회) | $0 | 40시간/월 |
| 긴급 핫픽스 | $0 | 10시간/월 |
| 버그로 인한 다운타임 | 매출 손실 | - |
| 합계 | $0 | 50시간/월 |
| 항목 | 월 비용 | 시간 비용 |
|---|---|---|
| GitHub Actions (Pro) | $21/월 | 0시간 |
| 긴급 핫픽스 | $0 | 2시간/월 |
| 다운타임 | 거의 없음 | - |
| 합계 | $21/월 | 2시간/월 |
월 $21 투자로 48시간 절약. 시급 $50으로 환산 → 월 $2,400 절약 ($2,379 순이익)
처음엔 "테스트 짜기 귀찮은데... 시간 낭비 아닌가?"였지만, 지금은 "CI/CD 없이 어떻게 개발했지?" 싶습니다.
결국 이거였습니다: CI/CD는 단순한 도구가 아니라, 현대 개발의 기본 인프라입니다.
이제 저는 금요일 오후에도 배포합니다.
$ git push origin main
그리고 6시에 퇴근합니다.
로봇이 알아서 테스트하고, 빌드하고, 배포하고, 검증하고, 알려줍니다.
이게 2025년의 개발 방식입니다.