icon
16장 : 실전 프로젝트

CI/CD 파이프라인 구축


이전 절들에서는 타입스크립트 기반의 API 서버와 React 프론트엔드 애플리케이션을 성공적으로 개발했습니다. 이제 개발된 애플리케이션을 사용자에게 제공하고, 향후 업데이트를 효율적으로 배포하기 위한 핵심 단계인 CI/CD 파이프라인 구축에 대해 알아보겠습니다.

CI/CD는 지속적 통합(Continuous Integration)과 지속적 배포(Continuous Deployment) 또는 지속적 전달(Continuous Delivery)을 의미합니다. 이는 개발부터 배포까지의 모든 과정을 자동화하여, 소프트웨어 개발 주기를 단축하고 안정성을 높이며, 더 빠른 피드백 루프를 가능하게 합니다.

이 절에서는 타입스크립트 기반의 풀 스택 Monorepo 프로젝트를 위한 CI/CD 파이프라인을 GitHub Actions를 사용하여 구축하는 방법을 설명합니다. GitHub Actions는 GitHub 저장소에서 직접 워크플로우를 자동화할 수 있는 강력한 CI/CD 도구입니다.


CI/CD의 개념 및 중요성

지속적 통합

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

EXPOSE 4000

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;
    }
}

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 4000:4000 \
              --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)로 확인합니다.


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

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

결론

CI/CD 파이프라인 구축은 현대 소프트웨어 개발에서 빠르고 안정적인 소프트웨어 배포를 위한 필수적인 요소입니다. GitHub Actions는 Monorepo 프로젝트에서도 효율적인 CI/CD 워크플로우를 구축할 수 있는 강력하고 유연한 도구입니다.

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

CI/CD를 통해 개발 프로세스를 자동화하면, 개발자는 기능 구현에 더 집중할 수 있고, 사용자들은 더 빠르고 안정적인 서비스를 경험할 수 있습니다. 이로써 16장 "실전 프로젝트"와 함께 책의 모든 내용이 마무리됩니다. 학습한 지식들을 바탕으로 더욱 복잡하고 견고한 타입스크립트 애플리케이션을 구축하는 데 성공하시기를 바랍니다.