"수동 배포, 언제까지 할 건가요?" - CI/CD 파이프라인과 GitHub Actions

댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!

댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
코드를 수정하고, 테스트를 돌리고, 도커 이미지를 빌드하고, 레지스트리에 푸시하고, 서버에 접속해서 이미지를 pull 받고, 컨테이너를 재시작한다. 배포할 때마다 이 과정을 반복한다.
한두 번이야 괜찮다. 그런데 하루에 몇 번씩 배포하면? 명령어를 빼먹거나, 테스트를 건너뛰거나, 브랜치를 잘못 배포하는 실수가 생기기 시작한다. 사람이 반복하면 실수가 생기고, 실수는 장애로 이어진다.
CI/CD는 이 반복을 자동화하는 방식이다. 코드를 푸시하면 테스트부터 배포까지 파이프라인이 알아서 돌아간다. 이 글에서는 CI/CD가 무엇인지, 왜 필요한지, 그리고 GitHub Actions로 어떻게 구성하는지 기초부터 정리한다.
배포 절차가 사람 머릿속에만 있으면 문제가 생긴다. 팀원마다 배포 방식이 조금씩 다르고, 바쁠 때는 단계를 건너뛰기도 한다. "테스트는 로컬에서 돌렸으니 괜찮겠지"라고 넘어간 코드가 프로덕션에서 터진다.
수동 배포가 반복되면 배포 자체가 부담스러운 작업이 된다. 금요일 오후에는 배포를 안 하고, 배포할 때마다 긴장하고, 롤백 절차도 없어서 문제가 생기면 땀을 흘린다. 배포가 무서우면 배포 주기가 길어지고, 한 번에 많은 변경이 쌓여서 더 위험해지는 악순환이 시작된다.
지금 프로덕션에 올라간 코드가 어떤 커밋인지, 누가 언제 배포했는지 기록이 남지 않는다. 장애가 나면 "마지막에 뭘 배포했지?"부터 추적해야 한다.

CI는 코드를 변경할 때마다 자동으로 빌드하고 테스트하는 것이다. 개발자가 코드를 푸시하면 즉시 "이 코드가 정상인지" 검증이 돌아간다.
핵심은 문제를 빨리 발견하는 것이다. 코드를 합치기 전에 테스트가 깨지면 알림이 오고, 깨진 채로 머지되는 걸 막을 수 있다. 변경을 작게, 자주 통합하고 매번 검증하는 습관이 CI의 본질이다.
CD는 검증된 코드를 자동으로 배포 가능한 상태로 만드는 것이다. CI를 통과한 코드가 자동으로 스테이징이나 프로덕션까지 나가는 흐름을 말한다.
CD에는 두 가지 단계가 있다.
| 구분 | 의미 |
|---|---|
| Continuous Delivery | 배포 가능한 상태까지 자동화, 실제 배포는 수동 승인 |
| Continuous Deployment | 프로덕션 배포까지 전부 자동화 |
처음에는 Continuous Delivery로 시작해서 배포 전에 한 번 확인하는 방식이 안전하다. 파이프라인에 대한 신뢰가 쌓이면 Continuous Deployment로 전환할 수 있다.
CI와 CD를 합쳐서 하나의 흐름으로 만든 것이 파이프라인이다.
코드 푸시 → 빌드 → 테스트 → 이미지 빌드 → 레지스트리 푸시 → 배포
각 단계가 성공해야 다음 단계로 넘어간다. 어느 단계에서든 실패하면 파이프라인이 멈추고 알림이 온다. 실패를 빠르게 알려주는 것도 파이프라인의 중요한 역할이다.
GitHub Actions는 GitHub 저장소에서 바로 쓸 수 있는 CI/CD 도구다. 별도의 서버를 구축하거나 외부 서비스를 연동할 필요 없이, 저장소에 YAML 파일 하나를 추가하면 된다.
Jenkins, GitLab CI, CircleCI 같은 도구도 있지만, GitHub을 쓰고 있다면 Actions가 가장 진입장벽이 낮다. 저장소와 같은 곳에 파이프라인 설정이 있기 때문에 관리 포인트가 줄어든다.
GitHub Actions를 구성하는 요소는 네 가지다.
| 요소 | 역할 |
|---|---|
| Workflow | 파이프라인 전체. .github/workflows/ 아래 YAML로 정의 |
| Event | 워크플로우를 실행하는 트리거 (push, PR, 수동 등) |
| Job | 워크플로우 안의 작업 단위. 독립된 환경에서 실행 |
| Step | Job 안의 개별 실행 단계. 명령어 또는 액션 |

구조를 그림으로 보면 이렇다.
Workflow
└─ Event (push, pull_request ...)
└─ Job 1
│ ├─ Step 1 (체크아웃)
│ ├─ Step 2 (의존성 설치)
│ └─ Step 3 (테스트)
└─ Job 2
├─ Step 1 (이미지 빌드)
└─ Step 2 (레지스트리 푸시)
Node.js 프로젝트의 CI 워크플로우를 예로 들어본다. .github/workflows/ci.yaml 파일을 만든다.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 코드 체크아웃
uses: actions/checkout@v6
- name: Node.js 설정
uses: actions/setup-node@v6
with:
node-version: 20
- name: 의존성 설치
run: npm ci
- name: 린트 검사
run: npm run lint
- name: 테스트 실행
run: npm test
이 워크플로우는 main 브랜치에 push하거나 PR을 올릴 때 실행된다. 코드를 체크아웃하고, Node.js를 설정하고, 의존성을 설치한 뒤 린트와 테스트를 돌린다.
**on** — 언제 실행할지 정의한다. push와 pull_request가 가장 흔하다. PR에서 돌리면 머지 전에 문제를 잡을 수 있다.
**runs-on** — 어떤 환경에서 실행할지 지정한다. ubuntu-latest가 가장 보편적이다. GitHub이 제공하는 가상 머신에서 돌아가기 때문에 직접 서버를 관리할 필요가 없다.
**uses** — 남이 만들어둔 액션을 가져다 쓴다. actions/checkout은 저장소 코드를 가져오는 공식 액션이다. GitHub Marketplace에 수천 개의 액션이 공개되어 있다.
**run** — 셸 명령어를 직접 실행한다. 로컬에서 쓰던 명령어를 그대로 넣으면 된다.

CI를 통과한 코드를 도커 이미지로 빌드해서 레지스트리에 푸시하는 단계를 추가해본다.
name: CI/CD
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Docker Hub 로그인
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 이미지 빌드 및 푸시
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/my-app:latest
${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
**needs: test** — test Job이 성공한 뒤에만 build-and-push가 실행된다. 테스트가 깨진 코드는 이미지로 빌드되지 않는다.
**secrets** — Docker Hub 비밀번호 같은 민감한 값은 YAML에 직접 쓰지 않는다. GitHub 저장소의 Settings → Secrets에 등록하면 ${{ secrets.이름 }} 으로 참조할 수 있다. 시크릿은 로그에도 마스킹 처리되기 때문에 노출 위험이 줄어든다.
태그 전략 — latest와 커밋 SHA를 함께 태깅한다. latest는 항상 최신 버전을 가리키고, SHA 태그는 특정 커밋의 이미지를 정확히 찾을 수 있게 해준다. 롤백할 때 이전 SHA 태그의 이미지를 쓰면 된다.
파이프라인에서 가장 빨리 끝나는 검사를 앞에 둔다. 린트 검사는 몇 초면 끝나지만 E2E 테스트는 몇 분이 걸린다. 린트에서 이미 실패할 코드를 E2E까지 돌릴 필요가 없다.
린트 (10초) → 단위 테스트 (30초) → 통합 테스트 (2분) → 이미지 빌드 (3분)
의존성 설치는 매번 시간이 걸린다. actions/cache를 쓰면 node_modules를 캐싱해서 다음 실행 때 재사용할 수 있다.
- name: 의존성 캐시
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm ci
package-lock.json이 바뀌지 않으면 캐시를 그대로 쓴다. 파이프라인 실행 시간이 눈에 띄게 줄어든다.
GitHub에서 main 브랜치에 Branch Protection Rule을 설정하면, CI가 통과하지 않은 PR은 머지할 수 없다. 워크플로우를 만들어두고도 무시하고 머지할 수 있으면 의미가 없다. 자동화는 강제할 때 효과가 있다.
파이프라인이 실패했는데 아무도 모르면 소용없다. GitHub 기본 이메일 알림 외에도 Slack으로 알림을 보내면 팀 전체가 빠르게 인지할 수 있다.
수동 배포는 결국 실수를 만든다. CI/CD는 코드를 푸시하면 자동으로 테스트하고, 빌드하고, 배포하는 흐름을 만들어서 반복 작업에서 사람을 빼는 것이다. GitHub Actions를 쓰면 별도의 인프라 없이 저장소 안에서 바로 시작할 수 있다.
지금 프로젝트에 .github/workflows/ 디렉토리가 없다면, 하나 만들어보자. 린트와 테스트만 돌리는 간단한 CI부터 시작하면 된다. PR을 올렸을 때 초록색 체크 표시가 뜨는 순간, "이 코드는 검증됐다"는 신뢰가 생기기 시작한다.

Dockerfile을 작성하고, 이미지를 빌드하고, 컨테이너를 띄웠다. 여기까지는 잘 된다. 그런데 실제 서비스는 앱 하나로 돌아가지 않는다. 웹 서버 뒤에는 데이터베이스가 있고, 캐시가 있고, 때로는 메시지 큐도 있다. 이걸 전부 으로 하나씩 띄우면 어떻게 될까? 컨테이너마다 네트워크를 연결하고, 포트를 매핑하고, 실행 순서를 맞추는 걸 매번 수동으로 해

도커로 컨테이너를 하나 띄우는 건 어렵지 않다. 한 줄이면 끝난다. 그런데 컨테이너가 10개, 100개로 늘어나면 얘기가 달라진다. 어느 서버에 올릴지, 몇 개를 띄울지, 하나가 죽으면 누가 다시 띄울지 — 직접 관리하기 시작하면 감당하기 어려워진다. 여기서 문제가 생긴다. 컨테이너는 만드는 것보다 운영하는 게 훨씬 어렵다. 서버가 죽어도 서비스는 살아 있

분명 내 로컬에서는 잘 돌아갔다. 그런데 서버에 올리니까 안 된다. 개발하다 보면 누구나 한 번쯤 겪는 상황이다. 원인은 단순하다. 개발 환경과 실행 환경이 다르기 때문이다. Node.js 버전, 운영체제, 설치된 패키지 — 어느 하나만 달라도 문제가 생길 수 있다. 도커는 이 환경 차이를 줄이기 위해 사용하는 도구다. 이 글에서는 도커의 기본 개념부터 이