본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
16장 : 실전 프로젝트

CI/CD 파이프라인 구축

GitHub Actions로 모노레포 패키지의 설치·검사·빌드·컨테이너 배포를 자동화하고 비밀값과 실패 로그를 관리합니다.

이전 절들에서는 타입스크립트 기반 API 서버와 React 프론트엔드 애플리케이션을 개발했습니다.

이제는 서비스를 사용자에게 제공하고 업데이트를 안정적으로 배포하기 위한 핵심 단계, CI/CD 파이프라인 구축을 다룹니다.

CI/CD는 지속적 통합(Continuous Integration)과 지속적 배포(Continuous Deployment) 또는 지속적 전달(Continuous Delivery)을 의미합니다.

개발부터 배포까지 과정을 자동화해 개발 주기를 줄이고 안정성을 높이며, 더 빠른 피드백 루프를 가능하게 합니다.

이 절에서는 타입스크립트 기반의 풀 스택 Monorepo 프로젝트를 위한 CI/CD 파이프라인을 GitHub Actions를 사용하여 구축하는 방법을 설명합니다.

GitHub Actions는 GitHub 저장소에서 직접 워크플로우를 자동화할 수 있는 강력한 CI/CD 도구입니다.


CI/CD의 개념 및 중요성

Monorepo 지속적 통합

  • 개념: 개발자들이 각자 작업한 코드를 메인 코드베이스에 자주(하루에도 여러 번) 통합하는 것을 의미합니다. 통합 시에는 자동화된 빌드 및 테스트 과정을 거쳐 새로운 변경 사항이 기존 코드에 문제를 일으키지 않는지 검증합니다.
  • 목표: 통합으로 인한 버그를 조기에 발견하고 해결하여, 통합에 드는 시간과 노력을 최소화합니다.
  • 주요 활동
    • 코드 변경 감지: 저장소에 새로운 코드가 푸시될 때마다 워크플로우가 자동으로 트리거됩니다.
    • 의존성 설치: 프로젝트에 필요한 라이브러리 및 패키지를 설치합니다.
    • 코드 빌드: 타입스크립트 코드를 자바스크립트로 컴파일하고, 프론트엔드 애플리케이션을 빌드합니다.
    • 테스트 실행: 단위 테스트, 통합 테스트, E2E 테스트 등을 자동으로 실행하여 코드의 정확성을 검증합니다.
    • 정적 분석: 린트(Lint) 검사 등을 통해 코드 스타일과 잠재적 문제를 확인합니다.

지속적 배포/전달

  • 개념: CI 단계를 통과한 코드를 자동으로 테스트 환경, 스테이징 환경, 또는 프로덕션 환경까지 배포하는 것을 의미합니다.
    • 지속적 전달(Continuous Delivery): CI를 통과한 코드를 배포 가능한 상태로 유지하고, 수동 승인 후 언제든지 배포할 수 있도록 준비합니다.
    • 지속적 배포(Continuous Deployment): CI를 통과한 코드가 자동으로 프로덕션 환경까지 배포됩니다.
  • 목표: 소프트웨어를 빠르고 안전하게 사용자에게 제공하여, 새로운 기능과 버그 수정을 신속하게 반영합니다.
  • 주요 활동
    • 도커 이미지 빌드 및 푸시: 애플리케이션을 도커 이미지로 만들고 컨테이너 레지스트리(Docker Hub, GitHub Container Registry 등)에 업로드합니다.
    • 배포: 빌드된 이미지를 클라우드 환경(AWS EC2/ECS/EKS, Azure, GCP 등) 또는 자체 서버에 배포합니다.
    • 롤백: 문제가 발생할 경우 이전 버전으로 되돌릴 수 있는 전략을 포함합니다.

GitHub Actions 기본 개념

GitHub Actions는 GitHub 저장소의 /.github/workflows 디렉토리에 YAML 파일을 생성하여 워크플로우를 정의합니다.

  • Workflow (워크플로우): 하나 이상의 Job으로 구성되며, 특정 이벤트(예: push, pull_request)에 의해 트리거됩니다.
  • Job (작업): 여러 Step으로 구성되며, 독립적인 가상 환경(Runner)에서 실행됩니다. Job 간에는 의존성을 설정할 수 있습니다.
  • Step (단계): Job 내에서 순차적으로 실행되는 개별 명령 또는 액션입니다.
  • Action (액션): 재사용 가능한 코드 블록으로, 복잡한 작업을 단순화합니다 (예: actions/checkout@v4, actions/setup-node@v4).

Monorepo를 위한 CI/CD 파이프라인 구축

우리가 설계한 Monorepo 프로젝트를 위한 CI/CD 파이프라인을 main 브랜치에 푸시될 때마다 실행되도록 구성해봅시다.

이 예시에서는 백엔드(NestJS)와 프론트엔드(React)를 각각 도커 이미지로 빌드하고 배포하는 과정을 포함합니다.

전제 조건
  • GitHub 저장소에 프로젝트가 푸시되어 있어야 합니다.
  • 각 애플리케이션(client, server)에 대한 Dockerfile이 준비되어 있어야 합니다.
  • AWS EC2 또는 Docker 컨테이너를 실행할 수 있는 원격 서버가 있다고 가정합니다. SSH 키와 접속 정보가 필요합니다.
  • GitHub Secrets에 배포 관련 민감 정보(AWS 자격 증명, SSH 키 등)를 저장합니다.

Dockerfile 준비

각 패키지 폴더(packages/client, packages/server)에 Dockerfile을 생성합니다.

packages/server/Dockerfile (NestJS)
# packages/server/Dockerfile
# 1단계: 빌드 환경
FROM node:20-alpine AS builder

# 작업 디렉토리 설정
WORKDIR /app

# 루트의 package.json, lock 파일 복사 (monorepo root)
COPY package.json pnpm-lock.yaml ./

# server 및 shared 패키지의 package.json, lock 파일 복사
COPY packages/server/package.json packages/server/pnpm-lock.yaml ./packages/server/
COPY packages/shared/package.json packages/shared/pnpm-lock.yaml ./packages/shared/

# pnpm install (monorepo root에서 실행)
# pnpm install -r --frozen-lockfile 또는 npm ci --prefix packages/server
RUN npm install -g pnpm && pnpm install --frozen-lockfile --prod=false

# 모든 소스 코드 복사
COPY . .

# NestJS 서버 빌드
WORKDIR /app/packages/server
RUN pnpm build

# 2단계: 프로덕션 환경
FROM node:20-alpine AS runner

WORKDIR /app

# pnpm install --prod (프로덕션 의존성만 설치)
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
COPY --from=builder /app/packages/server/package.json /app/packages/server/pnpm-lock.yaml ./packages/server/
COPY --from=builder /app/packages/shared/package.json /app/packages/shared/pnpm-lock.yaml ./packages/shared/
RUN npm install -g pnpm && pnpm install --frozen-lockfile --prod

# 빌드된 NestJS 애플리케이션 복사
COPY --from=builder /app/packages/server/dist ./packages/server/dist
COPY --from=builder /app/packages/server/node_modules ./packages/server/node_modules # prod dependencies (pnpm)
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/node_modules ./packages/shared/node_modules

# 환경 변수 (예: DATABASE_URL 등)
ENV NODE_ENV=production
ENV PORT=4100

EXPOSE 4100

WORKDIR /app/packages/server
CMD ["node", "dist/main.js"]
packages/client/Dockerfile (React)
# packages/client/Dockerfile
# 1단계: 빌드 환경 (React 앱 빌드)
FROM node:20-alpine AS builder

WORKDIR /app

# 루트의 package.json, lock 파일 복사 (monorepo root)
COPY package.json pnpm-lock.yaml ./

# client 및 shared 패키지의 package.json, lock 파일 복사
COPY packages/client/package.json packages/client/pnpm-lock.yaml ./packages/client/
COPY packages/shared/package.json packages/shared/pnpm-lock.yaml ./packages/shared/

# pnpm install (monorepo root에서 실행)
RUN npm install -g pnpm && pnpm install --frozen-lockfile --prod=false

# 모든 소스 코드 복사
COPY . .

# React 앱 빌드
WORKDIR /app/packages/client
RUN pnpm build

# 2단계: 프로덕션 서빙 환경 (Nginx 사용)
FROM nginx:alpine

# Nginx 기본 설정 파일 삭제
RUN rm /etc/nginx/conf.d/default.conf

# 사용자 정의 Nginx 설정 파일 복사
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 빌드된 React 앱 정적 파일 복사
COPY --from=builder /app/packages/client/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

packages/client/nginx.conf: (Nginx 설정 파일)

# packages/client/nginx.conf
server {
    listen 80;
    server_tokens off; # Nginx 버전 숨기기

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html; # SPA 라우팅을 위한 설정
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

Monorepo 배포용 GitHub Secrets 설정

GitHub 저장소의 Settings -> Secrets and variables -> Actions 에서 다음 Secret들을 추가합니다.

  • DOCKERHUB_USERNAME: Docker Hub 사용자 이름
  • DOCKERHUB_TOKEN: Docker Hub Access Token (Settings -> Security -> New Access Token)
  • AWS_ACCESS_KEY_ID: AWS IAM 사용자 액세스 키
  • AWS_SECRET_ACCESS_KEY: AWS IAM 사용자 시크릿 액세스 키
  • EC2_SSH_PRIVATE_KEY: EC2 인스턴스에 접근할 수 있는 SSH 프라이빗 키 (base64 인코딩 권장)
  • EC2_USER: EC2 인스턴스 사용자 이름 (예: ubuntu, ec2-user)
  • EC2_HOST: EC2 인스턴스 퍼블릭 IP 또는 DNS

GitHub Actions 워크플로우 정의

프로젝트 루트에 /.github/workflows/ci-cd.yml 파일을 생성합니다.

# .github/workflows/ci-cd.yml
name: Fullstack CI/CD Pipeline

on:
  push:
    branches:
      - main # main 브랜치에 푸시될 때마다 실행

env:
  NODE_VERSION: '20' # 사용할 Node.js 버전
  DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
  DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
  # 서버 이미지 이름/태그
  SERVER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/my-nest-app
  SERVER_IMAGE_TAG: latest
  # 클라이언트 이미지 이름/태그
  CLIENT_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/my-react-app
  CLIENT_IMAGE_TAG: latest

jobs:
  # CI Job: 빌드 및 테스트 (서버 및 클라이언트)
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'pnpm' # pnpm 캐싱 설정

      - name: Install pnpm
        run: npm install -g pnpm

      - name: Install Dependencies (Monorepo Root)
        run: pnpm install --frozen-lockfile --prod=false # 모든 의존성 설치

      - name: Build Server
        run: pnpm --filter server build # server 패키지 빌드

      - name: Run Server Tests
        run: pnpm --filter server test # server 패키지 테스트 (단위/통합)

      - name: Build Client
        run: pnpm --filter client build # client 패키지 빌드

      - name: Run Client Tests
        run: pnpm --filter client test # client 패키지 테스트 (단위)
        # E2E 테스트는 별도 Job 또는 Cypress Cloud 등 이용

  # CD Job: 도커 이미지 빌드 및 푸시, 서버 배포
  deploy:
    needs: build-and-test # build-and-test Job이 성공해야 실행
    runs-on: ubuntu-latest
    environment: production # GitHub Environment 설정 (선택 사항, 배포 보호 등)
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ env.DOCKERHUB_USERNAME }}
          password: ${{ env.DOCKERHUB_TOKEN }}

      # --- Server (NestJS) Docker Image Build and Push ---
      - name: Build and Push Server Docker Image
        uses: docker/build-push-action@v5
        with:
          context: . # Monorepo 루트에서 Dockerfile을 찾음
          file: ./packages/server/Dockerfile
          push: true
          tags: ${{ env.SERVER_IMAGE_NAME }}:${{ env.SERVER_IMAGE_TAG }}
          cache-from: type=gha # GitHub Actions 캐시 사용
          cache-to: type=gha,mode=max

      # --- Client (React) Docker Image Build and Push ---
      - name: Build and Push Client Docker Image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./packages/client/Dockerfile
          push: true
          tags: ${{ env.CLIENT_IMAGE_NAME }}:${{ env.CLIENT_IMAGE_TAG }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # --- Deploy to EC2 via SSH ---
      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1.0.3 # SSH를 통해 원격 서버에 명령 실행
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
          script: |
            # Docker 컨테이너 중지 및 삭제
            docker stop my-nest-app || true
            docker rm my-nest-app || true
            docker stop my-react-app || true
            docker rm my-react-app || true

            # 최신 이미지 풀
            docker pull ${{ env.SERVER_IMAGE_NAME }}:${{ env.SERVER_IMAGE_TAG }}
            docker pull ${{ env.CLIENT_IMAGE_NAME }}:${{ env.CLIENT_IMAGE_TAG }}

            # 서버 컨테이너 실행
            # Docker Hub로부터 이미지를 가져와 실행 시 .env 파일 직접 설정
            docker run -d --restart always \
              -p 4100:4100 \
              --name my-nest-app \
              -e DATABASE_HOST=${{ secrets.PROD_DATABASE_HOST }} \
              -e DATABASE_PORT=${{ secrets.PROD_DATABASE_PORT }} \
              -e DATABASE_USER=${{ secrets.PROD_DATABASE_USER }} \
              -e DATABASE_PASSWORD=${{ secrets.PROD_DATABASE_PASSWORD }} \
              -e DATABASE_NAME=${{ secrets.PROD_DATABASE_NAME }} \
              -e JWT_SECRET=${{ secrets.PROD_JWT_SECRET }} \
              ${{ env.SERVER_IMAGE_NAME }}:${{ env.SERVER_IMAGE_TAG }}

            # 클라이언트 컨테이너 실행
            docker run -d --restart always \
              -p 80:80 \
              --name my-react-app \
              ${{ env.CLIENT_IMAGE_NAME }}:${{ env.CLIENT_IMAGE_TAG }}

            echo "Deployment finished successfully!"
워크플로우 설명

name: 워크플로우 이름.

on: main 브랜치에 push 이벤트가 발생할 때 워크플로우를 트리거합니다.

env: 워크플로우 전체에서 사용될 환경 변수를 정의합니다.

jobs: 두 개의 주요 작업(build-and-test, deploy)을 정의합니다.

  • build-and-test
    • uses: actions/checkout@v4: 저장소 코드를 워크플로우 러너로 체크아웃합니다.
    • uses: actions/setup-node@v4: 지정된 Node.js 버전을 설정합니다. cache: 'pnpm'으로 의존성 캐싱을 활성화하여 빌드 시간을 단축합니다.
    • pnpm install --frozen-lockfile --prod=false: Monorepo 루트에서 모든 개발 및 프로덕션 의존성을 설치합니다. --frozen-lockfilepnpm-lock.yaml 파일과 정확히 일치하는 의존성을 설치하도록 강제하여 빌드의 일관성을 보장합니다.
    • pnpm --filter server build / pnpm --filter client build: Monorepo 도구(pnpm)의 filter 기능을 사용하여 특정 패키지만 빌드합니다.
    • pnpm --filter server test / pnpm --filter client test: 마찬가지로 특정 패키지의 테스트를 실행합니다.
  • deploy
    • needs: build-and-test: build-and-test 작업이 성공적으로 완료된 후에만 deploy 작업이 실행되도록 합니다.
    • uses: docker/login-action@v3: Docker Hub에 로그인합니다. GitHub Secrets에 저장된 자격 증명을 사용합니다.
    • uses: docker/build-push-action@v5: 각 패키지의 Dockerfile을 사용하여 도커 이미지를 빌드하고 Docker Hub에 푸시합니다. context: .은 Monorepo 루트에서 Dockerfile을 찾도록 합니다.
    • uses: appleboy/ssh-action@v1.0.3: SSH를 통해 원격 EC2 인스턴스에 접속하여 배포 스크립트를 실행합니다.
      • docker stop/rm: 현재 실행 중인 컨테이너를 중지하고 삭제합니다.
      • docker pull: Docker Hub에서 최신 이미지를 가져옵니다.
      • docker run: 새로운 도커 컨테이너를 실행합니다. -p로 포트 매핑, -d로 백그라운드 실행, --restart always로 서버 재시작 시 자동 실행, -e로 컨테이너 내 환경 변수를 설정합니다. 이때 프로덕션 환경용 데이터베이스 정보 등을 Secrets로 전달해야 합니다.

CI/CD 워크플로우 실행 및 모니터링

코드 푸시: main 브랜치에 코드 변경 사항을 푸시합니다.

git add .
git commit -m "feat: setup CI/CD pipeline"
git push origin main

GitHub Actions 확인: GitHub 저장소에서 Actions 탭으로 이동하면, 새로운 워크플로우 실행이 시작된 것을 확인할 수 있습니다.

실행 로그 확인: 실행 중인 워크플로우를 클릭하면 각 Job과 Step의 실시간 로그를 볼 수 있습니다.

빌드, 테스트, 도커 이미지 푸시, 그리고 최종 배포 명령어가 성공적으로 실행되는지 확인합니다.

배포 확인: 배포가 완료되면, 원격 서버의 도커 컨테이너가 정상적으로 실행되고 애플리케이션에 접근 가능한지 웹 브라우저나 API 클라이언트(Postman)로 확인합니다.


Monorepo CI/CD 파이프라인 최적화 및 고려사항

  • 테스트 커버리지: CI 단계에서 충분한 테스트 커버리지를 확보하여 잠재적인 버그를 미리 걸러냅니다.
  • 환경별 설정: 개발, 스테이징, 프로덕션 환경별로 다른 환경 변수나 설정 파일을 관리해야 합니다. GitHub Secrets는 프로덕션 환경의 민감한 정보를 안전하게 관리하는 데 필수적입니다.
  • 데이터베이스 마이그레이션: 배포 시 데이터베이스 스키마 변경(마이그레이션)이 필요한 경우, CI/CD 파이프라인에 마이그레이션 스크립트 실행 단계를 추가해야 합니다.
  • 롤백 전략: 배포 후 문제가 발생했을 때 신속하게 이전 안정 버전으로 되돌릴 수 있는 롤백 전략을 수립합니다. 도커 태그를 활용하거나 클라우드 서비스의 롤백 기능을 사용할 수 있습니다.
  • 캐싱: CI/CD 파이프라인에서 의존성 설치, 도커 이미지 빌드 등의 단계에서 캐싱을 적극적으로 활용하여 빌드 시간을 단축합니다.
  • 알림: CI/CD 워크플로우의 성공/실패 여부를 슬랙, 이메일 등으로 알림을 받도록 설정하여 빠르게 대응할 수 있습니다.
  • 환경 설정: 실제 운영 환경에서는 AWS ECS/EKS, Kubernetes, Heroku, Vercel 등 관리형 서비스를 사용하여 배포를 더욱 자동화하고 안정화할 수 있습니다. 각 서비스는 고유한 CI/CD 통합 기능을 제공합니다.

CI/CD 파이프라인은 단계 수보다 실패했을 때 어디서 멈추고 어떤 근거로 배포를 막는지가 더 중요합니다.


배포 자동화 요약

CI/CD 파이프라인은 빌드, 테스트, 이미지 생성, 배포 단계를 자동화하고 실행 이력을 남기는 구조입니다.

GitHub Actions는 Monorepo에서도 워크플로우 분리, 캐시, 매트릭스 전략을 조합해 사용할 수 있습니다.

이 절에서는 타입스크립트 기반의 NestJS 백엔드와 React 프론트엔드 애플리케이션을 위한 CI/CD 파이프라인을 GitHub Actions를 사용하여 설정하고, 도커 이미지를 빌드 및 푸시하며, 원격 서버에 배포하는 과정을 살펴보았습니다.

CI/CD를 통해 개발 프로세스를 자동화하면, 개발자는 기능 구현에 더 집중할 수 있고, 사용자들은 더 빠르고 안정적인 서비스를 경험할 수 있습니다.

이로써 16장 실전 프로젝트와 책의 내용을 마무리합니다.

아래 다이어그램은 CI/CD 파이프라인에서 워크플로 트리거, 모노레포 빌드 범위, 배포 전 검증 지점을 연결합니다.

CI/CD 파이프라인 구축을 마무리하기 전에는 트리거 조건, 모노레포 빌드 범위, 테스트 게이트, 이미지 생성, 배포 승인, 롤백 경로를 함께 확인합니다.

자동화는 빠르게 실행되는 것보다 실패했을 때 어디서 멈췄는지 설명할 수 있어야 합니다.