CI/CD 파이프라인 구축
GitHub Actions로 서버와 클라이언트 패키지를 고정한 Node.js에서 설치하고 검사하고 빌드하는 CI를 만들고, 두 앱의 Dockerfile과 이미지를 원격 서버에 배포하는 작업을 더한 뒤, 같은 산출물을 검증해 승격하는 목표 구조와 이 예제의 차이를 정리합니다.
앞의 두 절에서 API 서버와 클라이언트를 만들었습니다. 이제 코드를 바꿀 때마다 사람이 손으로 설치하고, 검사하고, 빌드하고, 서버에 올리는 대신, 그 과정을 자동으로 실행하는 파이프라인을 만듭니다. CI/CD는 지속적 통합(Continuous Integration)과 지속적 전달(Continuous Delivery) 또는 지속적 배포(Continuous Deployment)를 함께 부르는 말입니다.
지속적 통합은 코드를 자주 메인 브랜치에 합치되, 합칠 때마다 자동으로 빌드하고 테스트해 문제를 일찍 찾는 방식입니다. 지속적 전달은 통합을 통과한 코드를 언제든 배포할 수 있는 상태로 두고 사람이 승인하면 배포하는 방식이고, 지속적 배포는 그 승인까지 자동으로 하는 방식입니다. 이 절은 GitHub Actions로 검사와 빌드를 자동화하고, Docker 이미지를 만들어 원격 서버에 배포하는 작업을 더합니다.
GitHub Actions의 단위
GitHub Actions는 저장소의 .github/workflows 폴더에 YAML 파일로 자동화를 정의합니다. 파일 하나가 워크플로(workflow)이고, 워크플로는 push나 pull_request 같은 이벤트가 일어나면 실행됩니다.
워크플로는 하나 이상의 작업(job)으로 이루어지며, 각 작업은 GitHub가 준비한 깨끗한 가상 머신인 러너(runner)에서 따로 실행됩니다. 작업은 차례로 실행되는 단계(step)로 이루어지고, 단계는 셸 명령이거나 actions/checkout처럼 다른 사람이 만들어 둔 액션(action)을 부르는 것입니다. 작업끼리는 기본으로 동시에 실행되며, needs로 다른 작업이 성공한 뒤에 실행되게 할 수 있지만 그 작업이 만든 파일을 넘겨받지는 않습니다.
검사와 빌드
먼저 main 브랜치에 푸시하거나 풀 리퀘스트를 열 때마다 두 패키지를 검사하고 빌드하는 워크플로를 만듭니다. 프로젝트 구조 설계에서 정한 대로 server와 client는 각자의 잠금 파일로 따로 설치하므로, 작업도 패키지마다 나눕니다.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
server:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/server
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.21.0
cache: npm
cache-dependency-path: packages/server/package-lock.json
- run: npm ci
- run: npm run build
- run: npm test
client:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/client
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.21.0
cache: npm
cache-dependency-path: packages/client/package-lock.json
- run: npm ci
- run: npm run buildactions/checkout은 저장소의 코드를 러너로 가져오고, actions/setup-node는 API 서버 구현에서 고정한 Node.js 24.21.0을 설치합니다. cache: npm은 내려받은 패키지를 다음 실행에 다시 쓰도록 저장하며, 잠금 파일이 패키지 폴더 안에 있으므로 cache-dependency-path로 그 위치를 알려 줍니다. defaults.run.working-directory는 이 작업의 모든 run 명령을 그 패키지 폴더에서 실행합니다.
npm ci는 package-lock.json에 적힌 그대로 설치하며, package.json과 잠금 파일이 맞지 않으면 고치지 않고 실패합니다. 그래서 개발자의 컴퓨터와 CI가 같은 버전의 패키지를 씁니다. 서버의 npm run build는 nest build로 타입을 검사하며 컴파일하고, npm test는 NestJS 템플릿의 Jest 테스트를 실행합니다.
클라이언트의 npm run build는 Vite 템플릿의 tsc -b && vite build여서 타입 검사를 통과해야 번들을 만듭니다. 클라이언트는 paths로 packages/shared/src의 타입을 읽으므로, 저장소 전체를 가져온 러너에서 그 파일을 찾을 수 있습니다. 클라이언트 템플릿에는 테스트 스크립트가 없으므로 이 워크플로는 빌드까지만 확인하며, 테스팅과 성능 최적화의 Vitest를 더했다면 npm test 단계도 더합니다.
두 앱의 Dockerfile
배포할 결과물은 Docker 이미지로 만듭니다. 이미지에는 앱과 그 실행에 필요한 Node.js, 패키지가 함께 들어가므로, 어느 서버에서든 같은 방식으로 실행할 수 있습니다. 서버의 Dockerfile은 빌드에 쓰는 단계와 실행에 쓰는 단계를 나눕니다.
FROM node:24.21.0-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24.21.0-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 4100
CMD ["node", "dist/main.js"]node_modules
dist
.env첫 단계는 잠금 파일로 개발 의존성까지 설치해 빌드한 뒤, npm prune --omit=dev로 실행에 필요 없는 개발 의존성을 지웁니다. 둘째 단계는 깨끗한 Node.js 이미지에 실행에 필요한 node_modules와 dist만 옮기므로, 컴파일러와 소스 코드가 최종 이미지에 들어가지 않습니다. 패키지 파일을 소스보다 먼저 복사한 것은, 소스만 바뀌었을 때 Docker가 의존성 설치 단계를 캐시에서 다시 쓰게 하기 위해서입니다.
.dockerignore는 이미지를 만들 때 Docker에 보내지 않을 파일을 정합니다. 개발자의 컴퓨터에서 만든 node_modules와 dist가 섞여 들어가지 않게 하고, 특히 .env의 비밀 값이 이미지에 담기지 않게 합니다. 데이터베이스의 주소와 JWT_SECRET은 이미지가 아니라 컨테이너를 실행할 때 환경 변수로 넣습니다.
클라이언트는 빌드한 정적 파일을 nginx로 제공합니다. 클라이언트의 타입 검사가 packages/shared의 파일을 읽어야 하므로, 이 Dockerfile은 저장소의 루트를 빌드 컨텍스트로 씁니다.
FROM node:24.21.0-alpine AS build
WORKDIR /repo/packages/client
COPY packages/client/package.json packages/client/package-lock.json ./
RUN npm ci
COPY packages/shared /repo/packages/shared
COPY packages/client ./
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
FROM nginx:stable-alpine AS runner
COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/packages/client/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]server {
listen 80;
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}클라이언트의 폴더 구조를 이미지 안에서도 /repo/packages/client와 /repo/packages/shared로 같게 두어, tsconfig.app.json의 ../shared/src/* 경로가 그대로 맞게 했습니다. VITE_API_BASE_URL은 빌드할 때 번들에 들어가는 값이므로, ARG로 받아 빌드 단계의 환경 변수로 넘깁니다. nginx의 try_files는 /profile처럼 파일이 없는 경로도 index.html을 돌려주어, 새로고침해도 React Router가 그 경로를 처리하게 합니다.
저장소의 루트에도 .dockerignore를 두어 **/node_modules, **/dist, **/.env, .git을 빼 둡니다. 루트에서 docker build -f packages/client/Dockerfile --build-arg VITE_API_BASE_URL=https://api.example.com .을 실행하면, -f는 읽을 Dockerfile을, 마지막의 .은 COPY가 읽을 수 있는 파일의 범위를 정합니다.
배포 작업
검사를 통과한 main 브랜치의 코드를 이미지로 만들어 레지스트리에 올리고, 원격 서버에서 그 이미지를 실행하는 작업을 워크플로에 더합니다. 이 예제는 Docker Hub와, Docker가 설치된 리눅스 서버 한 대를 쓴다고 가정합니다.
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [server, client]
runs-on: ubuntu-latest
environment: production
env:
SERVER_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/my-nest-app
CLIENT_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/my-react-app
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: packages/server
push: true
tags: ${{ env.SERVER_IMAGE }}:latest,${{ env.SERVER_IMAGE }}:${{ github.sha }}
- uses: docker/build-push-action@v7
with:
context: .
file: packages/client/Dockerfile
build-args: VITE_API_BASE_URL=${{ vars.PROD_API_BASE_URL }}
push: true
tags: ${{ env.CLIENT_IMAGE }}:latest,${{ env.CLIENT_IMAGE }}:${{ github.sha }}
- uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.SERVER_IMAGE }}:latest
docker pull ${{ env.CLIENT_IMAGE }}:latest
docker rm -f my-nest-app my-react-app || true
docker run -d --restart always --name my-nest-app -p 4100:4100 --env-file /opt/my-app/server.env ${{ env.SERVER_IMAGE }}:latest
docker run -d --restart always --name my-react-app -p 80:80 ${{ env.CLIENT_IMAGE }}:latest이 작업은 if로 main에 푸시했을 때만 실행되어 풀 리퀘스트에서는 배포하지 않고, needs로 두 검사 작업이 모두 성공해야 시작합니다. environment: production은 GitHub의 환경 설정을 쓰는 것으로, 그 환경에 승인자를 정해 두면 배포 전에 사람의 승인을 받게 할 수 있습니다. 이미지는 latest와 커밋의 해시인 github.sha 두 가지 태그로 올라가므로, 어떤 커밋에서 만든 이미지인지를 나중에 찾을 수 있습니다.
appleboy/ssh-action은 SSH로 원격 서버에 접속해 스크립트를 실행합니다. 서버는 새 이미지를 받은 뒤 기존 컨테이너를 지우고 새 컨테이너를 실행하며, --restart always는 서버가 재부팅되어도 컨테이너를 다시 띄웁니다. 데이터베이스와 JWT의 비밀 값은 워크플로를 거치지 않고, 서버에 운영자가 미리 만들어 둔 /opt/my-app/server.env 파일에서 --env-file로 읽습니다. 이 파일에는 브라우저가 클라이언트를 여는 주소도 CORS_ORIGIN=http://<서버 주소>처럼 넣어야 합니다. 서버의 CORS가 http://localhost:3000만 허용하면, 배포한 화면에서 보낸 요청의 응답을 브라우저가 막습니다.
이 방식에는 분명한 한계가 있습니다. 기존 컨테이너를 먼저 지우므로 새 컨테이너가 뜰 때까지 서비스가 멈추고, 새 서버가 실제로 요청을 처리할 수 있는지 확인하는 단계가 없으며, 실패해도 이전 버전으로 자동으로 되돌리지 않습니다. 데이터베이스의 스키마를 바꾸는 마이그레이션도 이 작업에 들어 있지 않은데, 운영 이미지는 NODE_ENV=production이어서 synchronize도 꺼지므로 새 데이터베이스에 처음 배포하면 테이블이 없어 요청이 실패합니다. 운영 데이터베이스의 테이블은 배포 전에 마이그레이션으로 만들어 두어야 합니다. 중단 없이 배포하는 전략은 다음 절에서 다룹니다.
비밀 값과 변수
워크플로가 쓰는 값은 저장소의 Settings에서 Secrets and variables의 Actions 메뉴에 등록합니다. 비밀 값(secret)은 등록한 뒤에는 다시 볼 수 없고 로그에 찍히면 가려지며, 변수(variable)는 공개되어도 되는 설정입니다.
| 이름 | 종류 | 쓰는 곳 |
|---|---|---|
DOCKERHUB_USERNAME, DOCKERHUB_TOKEN | secret | Docker Hub 로그인과 이미지 이름 |
EC2_HOST, EC2_USER, EC2_SSH_PRIVATE_KEY | secret | 원격 서버의 SSH 접속 |
PROD_API_BASE_URL | variable | 클라이언트를 빌드할 때의 API 주소 |
Docker Hub의 비밀번호 대신 권한을 좁힌 액세스 토큰을 쓰고, SSH 키도 배포에만 쓰는 키를 따로 만들어 그 서버에서 필요한 일만 할 수 있는 사용자에게 줍니다. PROD_API_BASE_URL은 번들에 들어가 브라우저에 공개되는 값이므로 비밀 값으로 둘 필요가 없습니다.
더 깊이: 워크플로가 실행하는 남의 코드와 토큰의 권한
uses: actions/checkout@v7처럼 태그로 적은 액션은 실행할 때마다 그 태그가 지금 가리키는 코드를 내려받습니다. GitHub Actions의 보안 강화 안내는 워크플로 파일에서 액션을 커밋 SHA로 고정하라고 권하며, 액션의 작성자를 믿더라도 그 저장소에 접근한 공격자가 태그를 옮기거나 지울 수 있다는 위험을 그 이유로 듭니다. 이 절의 배포 작업은 서버에 접속하는 SSH 키를 액션에 넘기므로, 그 액션의 코드가 바뀌면 비밀 값도 함께 위험해집니다.
같은 안내는 비밀 값을 다루는 방법도 정리합니다. JSON이나 XML, YAML 같은 덩어리에 비밀 값을 담아 하나의 비밀로 등록하지 말고, 비밀 값에서 만들어 낸 다른 값도 비밀로 따로 등록하라고 합니다. 또 워크플로가 자동으로 받는 GITHUB_TOKEN에는 필요한 최소한의 권한만 주고, 더 필요한 권한은 그 작업에서만 늘리라고 권합니다. 이 절의 워크플로는 저장소에 쓰지 않으므로, 읽기 권한만으로 충분한지 확인해 두면 좋습니다.
같은 산출물을 검증해 승격하기
이 절의 워크플로는 검사 작업과 배포 작업에서 각각 빌드합니다. 검사 작업이 확인한 것은 러너에서 빌드한 결과이고, 실제로 배포되는 것은 배포 작업이 Docker로 다시 만든 이미지이므로, 둘이 정확히 같은 결과물이라는 보장은 없습니다.
BUILD ONCE · PROMOTE
한 번 만든 digest를 검증해 환경 사이로 승격한다
그림은 build once·동일 digest 승격을 위한 목표입니다. 본문 deploy job은 Docker image를 다시 빌드해 latest로 보냅니다. artifact 전달·승인·health/smoke·조건부 rollback은 아직 구현되지 않았습니다. 로컬 workspace companion에서 typecheck·test·build를 확인해도 동일 digest 승격이 검증되는 것은 아닙니다.
- Commit → Quality gates
의도한 commit을 review·test·typecheck합니다. 본문의 needs는 job 순서만 정하고 build 산출물을 자동 전달하지 않습니다.
- 한 번 Build → Artifact digest
검증할 image를 만들고 digest·commit 연결을 보존합니다. mutable latest만으로 동일 산출물을 보장하지 않습니다.
- Protected promote → Migrate
같은 digest를 승격합니다. 실행 설정·비밀과 이전 버전 호환 migration을 별도로 관리합니다.
- Health·smoke → 성공 시 관찰
실제 readiness와 핵심 기능을 확인합니다. 성공하면 관찰을 지속합니다.
- 실패 시 조건부 Rollback
이전 image·설정·트래픽 경로를 준비하고 데이터 호환성을 확인합니다. image rollback만으로 DB 변경이 취소되지는 않습니다.
- 핵심 경계
- 진행·선택 경로
VITE_*는 client build에 고정됩니다. 환경별 API 주소를 바꾸며 같은 client digest를 승격하려면 런타임 공개 설정을 별도로 설계해야 합니다. Dockerfile의 workspace·pnpm 의존 구조와 server 준비 완료도 실제 프로젝트에서 검증해야 합니다. 성공 시 관찰을 지속하며, 실패 시에만 데이터 호환성을 확인한 뒤 rollback합니다. image rollback만으로 DB 변경이 취소되지는 않습니다.
그림은 이것을 고친 목표 구조입니다. 검토한 커밋에서 테스트와 타입 검사를 통과하면 이미지를 한 번만 만들고, 그 이미지의 내용으로 정해지는 다이제스트(digest)를 기록합니다. 그다음 테스트 환경과 운영 환경에 같은 다이제스트의 이미지를 차례로 올리고, 이전 버전과 함께 동작할 수 있는 마이그레이션을 적용한 뒤, 상태 확인과 핵심 기능의 확인을 통과하면 계속 지켜보고 실패하면 데이터의 호환성을 확인한 뒤 되돌립니다.
latest는 새 이미지를 올릴 때마다 가리키는 대상이 바뀌는 태그여서, 테스트 환경에서 확인한 이미지와 운영에 올린 이미지가 같다는 것을 보장하지 못합니다. 이 절의 워크플로는 다시 빌드한 이미지를 latest로 배포하며, 산출물의 전달과 승인, 상태 확인, 조건부 되돌리기는 구현하지 않았습니다. 또 VITE_ 값은 클라이언트를 빌드할 때 번들에 고정되므로, 같은 클라이언트 이미지를 여러 환경에 승격하려면 API 주소를 실행할 때 읽는 설정을 따로 설계해야 합니다.
공유 패키지에서 타입이 아니라 실행되는 코드까지 나누게 되면, pnpm이나 npm의 workspaces로 패키지를 연결하고 공유 패키지를 먼저 빌드하도록 워크플로와 Dockerfile을 그 구조에 맞게 바꿔야 합니다. 어느 구조든, 로컬에서 빌드와 테스트가 통과했다는 것이 운영에 올라간 이미지를 검증했다는 뜻은 아니므로, 배포한 뒤의 확인을 파이프라인 안에 두어야 합니다.
정리
- CI는 코드를 합칠 때마다 자동으로 빌드하고 테스트하며, 지속적 전달은 배포할 수 있는 상태를 유지하고, 지속적 배포는 승인까지 자동으로 합니다.
- GitHub Actions의 워크플로는 이벤트로 실행되는 작업들로 이루어지며, 작업은 각자 깨끗한 러너에서 실행되고
needs는 순서만 정할 뿐 파일을 넘기지 않습니다. - CI는 고정한 Node.js에서
npm ci로 잠금 파일 그대로 설치하고, 패키지마다 빌드와 테스트를 실행합니다. - Dockerfile은 빌드 단계와 실행 단계를 나누어 실행에 필요한 파일만 최종 이미지에 담고, 비밀 값은 이미지가 아니라 실행할 때 넣습니다.
- 클라이언트의
VITE_값은 빌드할 때 번들에 고정되므로 빌드 인수로 넘기며, 공개되는 값이어서 비밀로 둘 필요가 없습니다. - 목표는 한 번 만든 이미지의 다이제스트를 검증하며 환경 사이로 승격하는 것이며,
latest를 다시 빌드해 배포하는 이 예제는 그 단계들을 아직 갖추지 않았습니다.
연습 문제
1. 잠금 파일이 맞지 않을 때
개발자가 package.json에 패키지를 더했지만 package-lock.json을 갱신하지 않고 푸시했습니다. CI의 npm ci 단계는 어떻게 되나요? npm install을 썼다면 무엇이 달라지나요?
해설 보기
npm ci는 두 파일이 맞지 않으면 잠금 파일을 고치지 않고 실패하므로, 그 작업은 설치 단계에서 멈춥니다. npm install은 잠금 파일을 새로 계산해 설치를 계속하므로 CI가 통과할 수 있지만, 그때 고른 버전이 개발자의 컴퓨터와 다를 수 있어 재현할 수 없는 빌드가 됩니다.
2. 이미지에 들어간 .env
.dockerignore 없이 서버 이미지를 만들었더니, 개발자의 .env 파일이 COPY . .으로 이미지에 들어갔습니다. 무엇이 문제이고, 이미 레지스트리에 올렸다면 어떻게 해야 하나요?
해설 보기
이미지를 받을 수 있는 사람은 누구나 그 안의 .env를 꺼내 데이터베이스의 비밀번호와 JWT_SECRET을 볼 수 있습니다. 이미 올렸다면 이미지를 지우는 것만으로는 부족하므로, 그 비밀 값들을 모두 새로 바꾸어 이전 값을 쓸 수 없게 만들고, .dockerignore에 .env를 넣어 다시 빌드합니다.
3. 테스트 환경에서 확인한 이미지
테스트 환경에 latest를 배포해 확인한 뒤, 한 시간 후 운영 환경에 latest를 배포했습니다. 그사이에 다른 커밋이 main에 합쳐졌다면 운영에는 무엇이 올라가나요? 어떻게 바꾸면 확인한 이미지를 그대로 올릴 수 있나요?
해설 보기
그사이의 커밋이 새 이미지를 latest로 올렸으므로, 운영에는 테스트 환경에서 확인하지 않은 이미지가 올라갑니다. 테스트 환경에서 확인한 이미지의 다이제스트나 커밋 해시 태그를 기록해 두고, 운영에는 latest가 아니라 그 다이제스트를 지정해 배포하면 확인한 것과 같은 이미지가 올라갑니다.
다음 절 배포 및 운영 전략에서는 서비스를 멈추지 않고 새 버전을 올리는 배포 방식과, 운영 중의 신호를 지켜보고 대응하는 방법을 다룹니다.