
CI/CD 완전 정복: 코드를 커밋하면 자동으로 배포되는 마법
FTP로 파일 올리던 시절부터 GitHub Actions 자동 배포까지. CI/CD 파이프라인의 개념, 구조, 주요 도구 비교, 배포 전략, 그리고 Next.js 앱을 자동으로 배포하는 실전 예제까지 한 번에 정리한다.

FTP로 파일 올리던 시절부터 GitHub Actions 자동 배포까지. CI/CD 파이프라인의 개념, 구조, 주요 도구 비교, 배포 전략, 그리고 Next.js 앱을 자동으로 배포하는 실전 예제까지 한 번에 정리한다.
금요일 오후 5시 47분. 퇴근 13분 전이다.
팀장이 슬랙에 메시지를 남긴다. "이거 오늘 안에 반영해야 합니다." 코드 변경은 3줄이다. 간단하다. 하지만 배포 과정은 간단하지 않다.
파일 하나를 빠뜨렸다. 다시 FTP를 열고, 파일을 찾고, 업로드하고, 확인한다. 시계는 6시 23분. 퇴근은 물 건너갔다.
이것이 2010년대 초반까지 많은 개발팀의 현실이었다. 그리고 이런 경험이 쌓이면서 업계에 하나의 불문율이 생겼다:
이 글은 그 공포에서 벗어나는 방법에 대한 이야기다. 코드를 커밋하는 순간, 테스트가 자동으로 돌고, 빌드가 만들어지고, 운영 서버에 안전하게 배포되는 시스템 — CI/CD 파이프라인을 처음부터 끝까지 파헤친다.

2000년대 초반, 소프트웨어 개발은 이런 식이었다:
이것을 "통합 지옥(Integration Hell)"이라 불렀다. 각자 오래 작업할수록 합칠 때의 고통은 기하급수적으로 증가했다.
소프트웨어 엔지니어링의 거장 Martin Fowler는 2006년 CI를 이렇게 정의했다:
핵심은 "자주(frequently)"다. 2주에 한 번이 아니라, 하루에 여러 번. 통합을 자주 하면 충돌이 작아지고, 작은 충돌은 해결하기 쉽다.
CI는 도구가 아니라 관행(practice)이다. Jenkins를 설치했다고 CI를 하는 것이 아니다. 팀원 모두가 하루에 여러 번 코드를 통합하고, 자동 빌드와 테스트로 그 통합이 안전한지 확인하는 것 — 그것이 CI다.
CD는 두 가지 의미로 쓰인다. Continuous Delivery와 Continuous Deployment. 이름이 비슷해서 많은 사람이 혼동하지만, 의미는 명확히 다르다.
코드 변경이 자동으로 빌드, 테스트를 거쳐 배포 가능한 상태(release-ready)가 되는 것.
실제 운영 환경에 배포하려면 사람이 버튼을 누른다. 자동으로 만들어진 배포 패키지를 검토하고, "이거 운영에 올려도 되겠다"고 판단한 후 수동으로 배포를 승인한다.
코드 변경이 자동으로 빌드, 테스트를 거쳐 운영 환경까지 자동 배포되는 것.
사람의 개입이 없다. 테스트를 통과하면 바로 운영에 반영된다.
현실적으로, 많은 사람이 "CI/CD"라고 말할 때는 Continuous Delivery를 의미한다. Continuous Deployment는 더 높은 수준의 자동화 성숙도를 요구한다.

파이프라인(Pipeline)이란 코드가 개발자의 로컬 환경에서 운영 서버까지 도달하는 자동화된 경로를 말한다. 물이 파이프를 따라 흐르듯, 코드가 각 단계를 순서대로 통과한다.
각 단계를 하나씩 파헤쳐 보자.
파이프라인의 시작점이다. 트리거(trigger)라고도 한다.
v1.0.0 같은 릴리스 태그가 생성되면 실행가장 일반적인 패턴은 main 브랜치에 push되면 자동 실행이다.
소스 코드를 실행 가능한 형태로 변환하는 단계다.
npm install, pip install, gradle build 등으로 필요한 라이브러리를 설치한다
빌드가 실패하면 파이프라인은 즉시 멈춘다. 타입 에러, 문법 오류, 의존성 충돌 등이 이 단계에서 잡힌다.
빌드된 코드가 제대로 동작하는지 검증하는 단계다. 이 단계에서 실행되는 테스트의 종류는 뒤에서 자세히 다룬다.
테스트를 통과한 코드를 실제 환경에 반영하는 단계다.
보통 환경을 여러 단계로 나눈다:
Continuous Delivery에서는 STAGING까지 자동이고, PROD 배포는 수동 승인 후 실행된다. Continuous Deployment에서는 PROD까지 전부 자동이다.
시장에는 수많은 CI/CD 도구가 있다. 각각의 특성과 적합한 상황을 비교해 보자.
2011년에 등장한 오픈소스 CI/CD 서버. Java로 작성되었고, 1,800개 이상의 플러그인이 있다. 가장 오래되고 가장 널리 쓰이는 CI/CD 도구다.
// Jenkinsfile (Declarative Pipeline)
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'npm install'
sh 'npm run build'
}
}
stage('Test') {
steps {
sh 'npm test'
}
}
stage('Deploy') {
steps {
sh 'docker build -t my-app .'
sh 'docker push my-app:latest'
}
}
}
}
장점: 극도의 유연성, 온프레미스 설치 가능, 거대한 플러그인 생태계 단점: 설정이 복잡하다. Jenkins 서버 자체를 관리해야 한다. "Jenkins 관리자"라는 역할이 따로 필요할 정도.
2019년에 출시된 GitHub 네이티브 CI/CD. GitHub 저장소와 완벽하게 통합된다. 설정 파일 하나로 파이프라인을 정의한다.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- run: npm test
- name: Deploy to AWS
run: aws s3 sync ./out s3://my-bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
장점: GitHub 사용자라면 별도 설정 없이 바로 사용. YAML 기반으로 직관적. 마켓플레이스에 수만 개의 재사용 가능한 Actions. 단점: GitHub에 종속. 대규모 프로젝트에서 비용이 높아질 수 있음. 무료 티어는 월 2,000분.
GitLab에 내장된 CI/CD. .gitlab-ci.yml 파일로 파이프라인을 정의한다.
# .gitlab-ci.yml
stages:
- build
- test
- deploy
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- .next/
test:
stage: test
script:
- npm test
deploy:
stage: deploy
script:
- docker build -t my-app .
- docker push my-app:latest
only:
- main
장점: Git 저장소부터 CI/CD, 컨테이너 레지스트리, 모니터링까지 올인원. Self-hosted 가능. 단점: GitLab 생태계에 종속. UI가 복잡할 수 있음.
| 도구 | 호스팅 | 설정 방식 | 무료 티어 | 난이도 |
|---|---|---|---|---|
| Jenkins | Self-hosted | Jenkinsfile (Groovy) | 완전 무료 (오픈소스) | 높음 |
| GitHub Actions | Cloud (GitHub) | YAML | 월 2,000분 | 낮음 |
| GitLab CI | Cloud / Self-hosted | YAML | 월 400분 | 중간 |
| CircleCI | Cloud / Self-hosted | YAML | 월 6,000 크레딧 | 중간 |
| AWS CodePipeline | Cloud (AWS) | Console / CloudFormation | 월 1 파이프라인 무료 | 높음 |
파이프라인을 실제로 구성하려면 세 가지 개념을 반드시 알아야 한다.
빌드 결과물을 의미한다. 파이프라인의 한 단계에서 생성되어 다음 단계로 전달되는 파일이다.
아티팩트의 예시:
.next/, dist/, build/)파이프라인 실행 시 주입되는 설정값이다. 코드에 직접 쓰지 않고 환경 변수로 분리하면, 같은 코드를 다른 환경에서 다른 설정으로 실행할 수 있다.
API_URL=https://dev-api.example.comDB_HOST=dev-db.internalLOG_LEVEL=debugAPI_URL=https://staging-api.example.comDB_HOST=staging-db.internalLOG_LEVEL=infoAPI_URL=https://api.example.comDB_HOST=prod-db.internalLOG_LEVEL=warn
같은 Docker 이미지를 세 환경에 배포하되, 환경 변수만 다르게 주입한다. 코드는 process.env.API_URL로 값을 읽기만 하면 된다.
절대로 노출되면 안 되는 민감한 값이다. API 키, 데이터베이스 비밀번호, 인증서, 토큰 등이 여기에 해당한다.
GitHub Actions에서 시크릿을 사용하는 예:
# 저장소 Settings → Secrets에서 등록한 시크릿 사용
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: aws s3 sync ./out s3://my-bucket
${{ secrets.AWS_ACCESS_KEY_ID }}는 실행 시 실제 값으로 치환되지만, 로그에는 ***로 마스킹된다. 실수로 출력해도 값이 노출되지 않는다.
"자동 테스트 없는 CI/CD는 자동화된 재앙 배포 시스템이다." 테스트 없이 자동 배포만 하면, 버그를 더 빠르게 운영에 올릴 뿐이다. 파이프라인에서 실행되는 테스트의 종류를 알아보자.
아래로 갈수록 많이, 위로 갈수록 적게 — 이것이 테스트 피라미드(Test Pyramid)의 원칙이다.
| 테스트 종류 | 검증 대상 | 실행 시간 | 도구 예시 |
|---|---|---|---|
| Linting | 코드 스타일, 문법 오류 | 수 초 | ESLint, Prettier, Pylint |
| Type Check | 타입 안전성 | 수 초 ~ 수십 초 | TypeScript tsc, mypy |
| Unit Test | 개별 함수/컴포넌트 | 수 초 ~ 수 분 | Jest, Vitest, pytest |
| Integration Test | 모듈 간 상호작용, API | 수 분 | Supertest, Testcontainers |
| E2E Test | 사용자 시나리오 전체 | 수 분 ~ 수십 분 | Playwright, Cypress |
| Security Scan | 취약한 의존성, 코드 취약점 | 수 분 | Snyk, Trivy, npm audit |
파이프라인에서 테스트는 빠르고 싼 것부터 실행한다. 린팅에서 오류가 발견되면 굳이 10분짜리 E2E 테스트를 기다릴 필요가 없다.
job으로 분리하면 동시에 돌릴 수 있다. 이렇게 하면 총 파이프라인 시간을 크게 단축할 수 있다.
코드가 모든 테스트를 통과했다. 이제 운영 서버에 올려야 한다. 하지만 어떻게 올리느냐에 따라 위험도가 완전히 달라진다.
가장 원시적인 방법. 기존 버전을 내리고, 새 버전을 올린다. 전환 중에는 서비스가 중단된다.
서버가 여러 대일 때, 한 대씩 순서대로 새 버전으로 교체한다.
동일한 환경 두 벌을 준비한다. Blue(현재 운영)와 Green(새 버전).
Green에서 문제가 발견되면? 로드 밸런서를 Blue로 되돌리면 끝. 롤백이 수 초 만에 가능하다.
광산에서 유독 가스를 감지하기 위해 카나리아 새를 먼저 보낸 것에서 유래했다. 소수의 사용자에게만 새 버전을 노출하고, 문제가 없으면 점진적으로 비율을 늘린다.
| 전략 | 다운타임 | 롤백 속도 | 비용 | 복잡도 |
|---|---|---|---|---|
| Big Bang | 있음 | 느림 | 낮음 | 낮음 |
| Rolling | 없음 | 중간 | 낮음 | 중간 |
| Blue-Green | 없음 | 매우 빠름 | 높음 (2배) | 중간 |
| Canary | 없음 | 빠름 | 중간 | 높음 |
이론은 충분하다. 실제로 Next.js 앱을 GitHub Actions + Docker + AWS에 자동 배포하는 파이프라인을 만들어 보자.
먼저 Next.js 앱을 Docker 이미지로 만들기 위한 Dockerfile이 필요하다.
# Multi-stage build로 이미지 크기 최적화
FROM node:20-alpine AS base
# Stage 1: 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: 빌드
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: 프로덕션 이미지
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# 보안: root가 아닌 사용자로 실행
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
Multi-stage build를 사용해서 최종 이미지에는 빌드 도구와 devDependencies가 포함되지 않는다. 이미지 크기가 1GB에서 150MB로 줄어든다.
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: my-nextjs-app
ECS_CLUSTER: production
ECS_SERVICE: nextjs-service
jobs:
# Job 1: 린팅과 타입 체크 (빠른 피드백)
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
# Job 2: 유닛 테스트
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
# Job 3: 보안 스캔
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=high
- uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'CRITICAL,HIGH'
# Job 4: 빌드 & 배포 (lint, test, security 모두 통과 후)
deploy:
needs: [lint, test, security]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster $ECS_CLUSTER \
--service $ECS_SERVICE \
--force-new-deployment
이 파이프라인의 구조를 분석해 보자.
핵심 포인트:
lint, test, security 3개 Job이 병렬로 실행된다. 총 파이프라인 시간은 가장 느린 Job의 시간과 같다deploy Job은 needs: [lint, test, security]로 3개 모두 통과해야 실행된다if: github.ref == 'refs/heads/main' 조건으로 main 브랜치에 push할 때만 배포가 실행된다FTP로 파일 올리던 시절의 불안한 30분이, 자동화된 안전한 5분으로 바뀌었다.
파이프라인이 느리면 개발자들이 CI/CD를 꺼린다. "빌드 기다리느니 그냥 수동으로 올리자"가 되면 모든 노력이 물거품이 된다. 파이프라인을 빠르게 만드는 실전 기법들을 알아보자.
가장 효과적인 최적화. node_modules를 매번 새로 설치하는 대신, 이전 빌드의 결과를 재사용한다.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # package-lock.json이 변경되지 않으면 캐시 사용
package-lock.json의 해시를 키로 사용해서, 의존성이 변경되지 않았으면 npm ci가 수 초 만에 끝난다.
독립적인 작업은 병렬로 실행한다. 위의 예시처럼 lint, test, security를 동시에 돌리면 총 시간이 줄어든다.
변경된 파일에 따라 필요한 테스트만 실행한다.
# 프론트엔드 파일이 변경됐을 때만 E2E 테스트 실행
- name: Check for frontend changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
frontend:
- 'src/**'
- 'public/**'
- name: Run E2E tests
if: steps.changes.outputs.frontend == 'true'
run: npx playwright test
README만 수정했는데 E2E 테스트를 10분간 돌리는 것은 자원 낭비다.
Docker 빌드 시 변경되지 않은 레이어는 캐시에서 재사용한다. Dockerfile에서 변경이 적은 것을 위에, 변경이 잦은 것을 아래에 배치하는 것이 핵심이다.
package.json이 바뀌지 않았으면 npm ci까지 캐시에서 가져온다. 빌드 시간이 5분에서 1분으로 줄어들 수 있다.
CI/CD는 도구가 아니라 문화다. 아무리 좋은 파이프라인을 만들어도 팀이 그것을 사용하지 않으면 의미가 없다.
이것은 CI/CD 문화의 핵심 철학이다. 고통스러운 일은 더 자주 하라.
배포가 무섭다고 2주에 한 번 하면? 2주치 변경이 한꺼번에 올라가니까 더 무섭다. 하루에 10번 배포하면? 각 배포의 변경량이 작으니까 문제가 생겨도 원인을 찾기 쉽고, 롤백할 범위도 작다.
Google의 DORA(DevOps Research and Assessment) 팀이 6년간 수만 개 팀을 연구해서 도출한 소프트웨어 딜리버리 성과 지표다.
| DORA 메트릭 | 의미 | Elite 수준 | Low 수준 |
|---|---|---|---|
| 배포 빈도 | 얼마나 자주 운영에 배포하는가 | 하루 여러 번 | 월 1회 미만 |
| 변경 리드 타임 | 커밋 → 운영 반영까지 걸리는 시간 | 1시간 미만 | 1개월 이상 |
| 변경 실패율 | 배포 중 장애를 일으키는 비율 | 5% 미만 | 46~60% |
| 장애 복구 시간 | 장애 발생 → 복구까지 걸리는 시간 | 1시간 미만 | 1주일 이상 |
이 4가지 지표는 서로 양의 상관관계를 가진다. 배포를 자주 하는 팀이 변경 실패율도 낮고, 복구도 빠르다. "자주 배포하면 위험하다"는 직관은 데이터로 반박된다. 자주 배포하는 팀이 오히려 더 안정적이다.
CI/CD를 도입할 때 많은 팀이 빠지는 함정들이 있다. 미리 알고 피하자.
Jenkins를 설치했다고 CI를 하는 것이 아니다. 팀원들이 하루에 한 번도 메인 브랜치에 통합하지 않으면, 그건 Jenkins를 "자동 빌드 도구"로 쓰는 것이지 CI가 아니다.
feature/payment-v2 브랜치를 3주 동안 키운 후 main에 머지한다. 충돌 100개. 이것은 Continuous Integration의 정반대다. 브랜치 수명은 짧을수록 좋다. 이상적으로는 하루 이내.
CI가 실패한 상태에서 새 커밋을 계속 쌓는다. "나중에 한꺼번에 고치겠다." 빌드가 계속 빨간 상태면 팀이 CI를 무시하게 된다. "Broken Windows" 이론 — 깨진 창문 하나를 방치하면 빌딩 전체가 망가진다.
# 절대 이렇게 하지 마세요
env:
API_KEY: "sk-1234567890abcdef" # Git에 커밋됨 = 전 세계에 공개됨
GitHub에 커밋된 시크릿은 몇 분 내에 봇에 의해 탐지된다. AWS 키가 노출되면 수 시간 만에 수천 달러의 요금이 발생할 수 있다.
"CI/CD를 도입하고 싶은데, 어디서부터 시작하지?" 한 번에 완벽한 파이프라인을 만들 필요 없다. 단계적으로 성숙도를 높여가면 된다.
오늘 당장 할 수 있는 것: GitHub 저장소에 .github/workflows/ci.yml 파일을 만들고, npm run build와 npm run lint를 자동으로 실행하게 설정한다. 10분이면 된다. 그것이 Level 1이다.
# 최소한의 CI 파이프라인 — 10분이면 만든다
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint
- run: npm run build
이 15줄이 당신의 CI/CD 여정의 시작이다.
이 글에서 다룬 내용을 정리하자.
FTP로 파일을 올리던 시절, 배포는 이벤트였다. 날을 잡고, 체크리스트를 만들고, 긴장하고, 기도했다. "금요일 오후에는 배포하지 마라"는 규칙이 필요했다.
CI/CD가 정착된 팀에서 배포는 일상이다. 코드를 커밋하면 테스트가 돌고, 빌드가 만들어지고, 스테이징에 올라간다. 확인하고 승인하면 운영에 반영된다. 금요일이든 월요일이든, 오전이든 오후든 상관없다. 파이프라인이 안전을 보장한다.
"아프면 더 자주 하라." 배포가 무서우면 더 자주 배포하라. 통합이 어려우면 더 자주 통합하라. 테스트가 귀찮으면 더 자주 테스트하라. 빈도를 높이면 각각의 크기가 작아지고, 작아지면 쉬워지고, 쉬워지면 무섭지 않다.
오늘 GitHub Actions YAML 파일 15줄을 작성하는 것으로 시작하자. 그것이 "금요일 오후 배포 공포"에서 벗어나는 첫걸음이다.