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 도구입니다.
그림은 build-once·digest 승격의 목표 구조입니다. 아래 원문 워크플로는 CI 산출물을 전달하지 않고 deploy job에서 다시 빌드해 latest로 배포합니다. digest 고정·health/smoke 검사·자동 롤백은 구현하지 않으므로 같은 산출물의 검증된 승격으로 해석하면 안 됩니다.
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 변경이 취소되지는 않습니다.
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
# 의존성 복사; builder에는 개발 의존성도 포함될 수 있음
COPY --from=builder /app/packages/server/node_modules ./packages/server/node_modules
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;
}
}원문 Dockerfile은 프로젝트의 실제 workspace 구조에 맞춰 보완해야 하는 초안입니다. 설치 전에 pnpm-workspace.yaml을 복사하지 않고 패키지별 lockfile을 가정하며, shared의 선행 빌드와 pnpm 링크 대상도 연결해야 합니다. 루트 build context에서 client 설정 파일 경로는 packages/client/nginx.conf입니다. Dockerfile의 설명 주석은 COPY 인수로 해석되지 않도록 독립 행에 둡니다.
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 프라이빗 키 원문(예제 action의key입력에는 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/[email protected] # 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!"
setup-node가 pnpm 캐시를 조회하기 전에 pnpm을 준비하고 버전을 고정해야 합니다. needs는 job 성공에 의존할 뿐 파일을 옮기지 않으며, 이 예제는 실행 중 컨테이너를 먼저 중지하므로 중단 없는 배포도 아닙니다. 끝의 echo는 readiness 증거가 아닙니다. VITE_ 환경값은 client 빌드에 주입해야 하고, DB·JWT 비밀값은 실제 서버 환경에서 확인해야 합니다.
name: 워크플로우 이름.
on: main 브랜치에 push 이벤트가 발생할 때 워크플로우를 트리거합니다.
env: 워크플로우 전체에서 사용될 환경 변수를 정의합니다.
jobs: 두 개의 주요 작업(build-and-test, deploy)을 정의합니다.
-
build-and-testuses: actions/checkout@v4: 저장소 코드를 워크플로우 러너로 체크아웃합니다.uses: actions/setup-node@v4: 지정된 Node.js 버전을 설정합니다.cache: 'pnpm'으로 의존성 캐싱을 활성화하여 빌드 시간을 단축합니다.pnpm install --frozen-lockfile --prod=false: Monorepo 루트에서 모든 개발 및 프로덕션 의존성을 설치합니다.--frozen-lockfile은pnpm-lock.yaml파일과 정확히 일치하는 의존성을 설치하도록 강제하여 빌드의 일관성을 보장합니다.pnpm --filter server build/pnpm --filter client build: Monorepo 도구(pnpm)의filter기능을 사용하여 특정 패키지만 빌드합니다.pnpm --filter server test/pnpm --filter client test: 마찬가지로 특정 패키지의 테스트를 실행합니다.
-
deployneeds: 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에 푸시합니다.file이 읽을 Dockerfile을 선택하고,context: .은 COPY가 읽을 파일 집합을 Monorepo 루트로 정합니다.uses: appleboy/[email protected]: SSH를 통해 원격 EC2 인스턴스에 접속하여 배포 스크립트를 실행합니다.docker stop/rm: 현재 실행 중인 컨테이너를 중지하고 삭제합니다.docker pull: Docker Hub에서 최신 이미지를 가져옵니다.docker run: 새로운 도커 컨테이너를 실행합니다.-p로 포트 매핑,-d로 백그라운드 실행,--restart always로 서버 재시작 시 자동 실행,-e로 컨테이너 내 환경 변수를 설정합니다. 이때 프로덕션 환경용 데이터베이스 정보 등을 Secrets로 전달해야 합니다.
workspace 연결을 작은 실습으로 완성하기
앞 Dockerfile의 경고를 실제 파일과 명령에 연결해 보겠습니다. 구조 설계의 packages/client, packages/server, packages/shared 폴더를 같은 모양으로 사용하되, 기존 프로젝트 옆의 새 ci-workspace-check 폴더에 아래 파일을 만듭니다. 기존 앱의 package.json이나 lockfile을 덮어쓰지 않습니다.
API 서버는 Node.js 24.21.0·TypeScript 5.9.3과 npm의 package-lock.json을 사용했습니다. 클라이언트의 import type과 @shared/* paths는 공유 타입을 검사한 뒤 JavaScript에서 지우는 구성입니다. 따라서 그 코드만으로 shared/dist나 pnpm workspace가 생기지는 않습니다. 아래 실습은 공유 함수를 실행 시에도 가져오는 경우를 추가해 설치 → shared 빌드 → 소비자 빌드 → 실행 의존성 전달을 확인합니다.
이 실습의 server는 문자열을 출력하고 종료하는 진입 파일입니다. NestJS·DB·인증 API는 앞 절의 코드를 유지하고 별도로 검증합니다. client는 React·Vite의 최소 화면입니다. 두 앱의 이름은 폴더명이 아니라 package.json의 name에 각각 server, client로 정합니다. shared 이름은 @my-fullstack-app/shared입니다.
루트 설정과 공유 패키지
이 실습은 Node.js 24.21.0, pnpm 9.15.9, TypeScript 5.9.3을 고정합니다. 위 원본 Dockerfile의 Node 20이나 설치 시점의 최신 pnpm과 혼합하지 않습니다. pnpm 9.15.9의 Node 지원 조건은 >=18.12이지만, 이 실습의 실행 기준은 앞 서버 절과 같은 24.21.0입니다. 고정 버전의 package.json
{
"name": "ci-workspace-check",
"private": true,
"packageManager": "[email protected]",
"engines": { "node": "24.21.0" },
"scripts": {
"build:shared": "pnpm --filter @my-fullstack-app/shared --fail-if-no-match build",
"build:server": "pnpm --filter server --fail-if-no-match build",
"build:client": "pnpm --filter client --fail-if-no-match build",
"build": "pnpm run build:shared && pnpm run build:server && pnpm run build:client",
"prepare:runtime": "pnpm --filter server deploy --prod --offline --no-hoist --no-shamefully-hoist out/server",
"test": "node scripts/verify-artifacts.mjs"
}
}packages:
- 'packages/*'{
"name": "@my-fullstack-app/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"files": ["dist"],
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
},
"scripts": { "build": "tsc -p tsconfig.json" },
"devDependencies": { "typescript": "5.9.3" }
}{
"compilerOptions": {
"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
"rootDir": "src", "outDir": "dist", "declaration": true, "strict": true
},
"include": ["src/**/*.ts"]
}export interface PublicUserDto {
id: number;
email: string;
name: string;
}
export function formatPublicUser(user: PublicUserDto): string {
return `${user.id}:${user.name}`;
}workspace:*는 레지스트리의 동명 패키지로 대체하지 않고 이 workspace의 패키지를 요구합니다. shared의 exports.types는 타입 검사에, exports.import는 실행 중 import에 사용됩니다. files: ["dist"]이므로 실행용 복사에는 빌드한 JS와 선언 파일이 들어가야 합니다. 공개 사용자 모양은 앞 절처럼 id·email·name만 포함합니다. pnpm workspace
두 소비자를 공유 함수에 연결하기
{
"name": "server",
"version": "1.0.0",
"private": true,
"type": "module",
"files": ["dist"],
"scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/main.js" },
"dependencies": { "@my-fullstack-app/shared": "workspace:*" },
"devDependencies": { "typescript": "5.9.3", "@types/node": "24.19.1" }
}{
"compilerOptions": {
"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
"rootDir": "src", "outDir": "dist", "strict": true,
"lib": ["ES2022"], "types": ["node"]
},
"include": ["src/**/*.ts"]
}import { formatPublicUser } from '@my-fullstack-app/shared';
import type { PublicUserDto } from '@my-fullstack-app/shared';
const user: PublicUserDto = { id: 1, email: '[email protected]', name: '학습자' };
console.log(formatPublicUser(user));{
"name": "client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": { "build": "tsc -p tsconfig.json && vite build" },
"dependencies": {
"@my-fullstack-app/shared": "workspace:*",
"react": "19.1.1", "react-dom": "19.1.1"
},
"devDependencies": {
"typescript": "5.9.3", "vite": "6.3.5",
"@types/react": "19.1.10", "@types/react-dom": "19.1.9"
}
}{
"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true,
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}<!doctype html>
<html lang="ko">
<head><meta charset="UTF-8"><title>공유 패키지 빌드 확인</title></head>
<body><div id="root"></div><script type="module" src="/src/main.ts"></script></body>
</html>import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { formatPublicUser } from '@my-fullstack-app/shared';
import type { PublicUserDto } from '@my-fullstack-app/shared';
const user: PublicUserDto = { id: 1, email: '[email protected]', name: '학습자' };
const root = document.getElementById('root');
if (!root) throw new Error('index.html의 #root가 필요합니다.');
createRoot(root).render(createElement('p', null, formatPublicUser(user)));server와 client의 dependencies에 shared를 명시했으므로 설치가 실제 workspace 연결을 만듭니다. server는 NodeNext로 실행할 ESM을 출력하고, client는 TypeScript로 검사한 뒤 Vite로 브라우저 파일을 묶습니다. 이번 client는 JSX를 쓰지 않아 별도 React 변환 플러그인이 필요하지 않습니다. Vite 6.3.5와 React 19.1.1은 이 작은 실습의 고정 버전이며, 기존 앱의 다른 버전과 자동으로 호환된다는 뜻은 아닙니다. Vite 6.3.5의 Node 지원 범위
lockfile 생성부터 산출물 확인까지
이 실습의 파일을 뒤에 나오는 scripts/verify-artifacts.mjs까지 모두 만든 뒤, 새 실습 폴더 안에서만 실행합니다. pnpm이 준비되어 있지 않으면 npm exec [email protected] -- pnpm <명령>으로 같은 버전을 호출할 수 있습니다. 다음 명령에서는 pnpm 9.15.9가 PATH에 준비됐다고 가정합니다.
node --version
pnpm --version
pnpm install --lockfile-only
pnpm install --frozen-lockfile
pnpm run build
pnpm run prepare:runtime
pnpm test첫 설치 준비 명령은 루트의 pnpm-lock.yaml 하나를 생성합니다. 그 파일과 루트·각 패키지의 package.json, pnpm-workspace.yaml을 함께 보관합니다. 다음 설치의 --frozen-lockfile은 설정과 lockfile이 달라도 다시 계산해서 고치는 대신 실패하게 합니다. 각 패키지 폴더에 별도 pnpm-lock.yaml을 만들지 않습니다. 기존 npm 프로젝트를 이 실습으로 바꾸는 마이그레이션 명령도 아닙니다.
루트 build는 shared → server → client 순서입니다. 먼저 shared를 빌드해야 두 소비자의 exports가 가리키는 파일이 존재합니다. --fail-if-no-match는 패키지 이름 오타로 아무것도 검사하지 않는 경우를 실패로 만듭니다. 의존 패키지까지 선택하는 server...와 server의 차이도 알아두되, 여기서는 출력 순서를 직접 따라갈 수 있도록 세 명령을 나눴습니다. pnpm 필터
| 만들어져야 할 경로 | 이 파일을 만드는 단계 | 다음에 읽는 쪽 |
|---|---|---|
| packages/shared/dist/index.js·index.d.ts | shared의 tsc 빌드 | server의 Node·client의 타입 검사와 Vite |
| packages/server/dist/main.js | server의 tsc 빌드 | Node 실행 |
| packages/client/dist/index.html·assets의 JS | client의 tsc 검사 후 Vite 빌드 | 정적 웹 서버·브라우저 |
| out/server/dist·node_modules | prepare:runtime | 독립 runner |
prepare:runtime의 pnpm deploy는 서버에 배포하는 명령이 아닙니다. 이 고정 버전에서 workspace 패키지와 운영 의존성을 새 로컬 폴더로 복사하는 명령입니다. out/server는 존재하지 않는 새 대상이어야 하며, 기존 결과가 있으면 다른 새 대상 경로를 선택하고 test의 경로도 함께 맞춥니다. --prod로 개발 의존성을 제외하고 --offline으로 앞 설치의 로컬 저장소를 사용합니다. 이 버전의 deploy 자체는 frozen install을 수행하지 않으므로, 앞 설치의 lockfile 검증과 뒤 실행 확인을 생략하지 않습니다. pnpm 버전을 바꾸면 deploy 조건도 해당 버전에서 다시 확인합니다. pnpm 9.15.9 deploy 구현
이 실습의 prepare:runtime은 실행 폴더를 만들 때만 hoisting을 끕니다. Hoisting은 패키지의 별칭 링크를 공통 node_modules 위치에 모으는 방식입니다. pnpm 9.15.9의 기본 hoisting은 선택한 workspace 패키지의 원본 경로를 가리키는 server 자기 링크도 만들 수 있습니다. 이 링크가 있으면 모든 링크가 out/server 안에서 끝나야 한다는 독립 실행 폴더 조건을 위반해 아래 엄격한 검사가 실패합니다. 이 실습의 작은 main.js는 그 자기 링크를 사용하지 않으므로, 원래 workspace에서 1:학습자가 출력돼도 모든 링크의 이동 가능성을 증명하지는 못합니다. --no-hoist는 node_modules/.pnpm/node_modules의 private hoist를, --no-shamefully-hoist는 최상위 node_modules의 public hoist를 끕니다. 이 두 옵션은 앞의 설치·빌드 설정을 바꾸지 않습니다. pnpm 9.15.9의 hoist 링크 구현 · 같은 버전의 두 hoist 설정 처리
shared는 server가 명시한 직접 운영 의존성이므로 hoist 별칭을 끄는 것과 의존성을 제외하는 것은 다릅니다. 실행 폴더 안의 shared JS·package.json과 모든 심볼릭 링크의 실제 대상을 아래 검사로 확인합니다. 링크가 하나라도 실행 폴더 밖을 가리키면 실패이며, 그 링크를 사후에 지우거나 검사 예외로 넘기지 않습니다.
아래 확인 파일도 처음부터 scripts 폴더에 만듭니다. client는 산출물과 참조 경로를 확인하고, server는 별도로 구성한 실행 폴더에서 공유 함수를 실제로 불러옵니다. 이 폴더 안에 shared의 package.json과 JS가 있어야 하므로 상위 폴더의 의존성을 우연히 찾아 실행되는 경우를 성공으로 보지 않습니다.
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { spawnSync } from 'node:child_process';
for (const path of [
'packages/shared/dist/index.js', 'packages/shared/dist/index.d.ts',
'packages/server/dist/main.js', 'packages/client/dist/index.html',
'out/server/node_modules/@my-fullstack-app/shared/package.json',
'out/server/node_modules/@my-fullstack-app/shared/dist/index.js',
]) assert.ok(existsSync(path), `산출물 누락: ${path}`);
const html = readFileSync('packages/client/dist/index.html', 'utf8');
const scripts = [...html.matchAll(/<script\b[^>]*\bsrc="([^"]+)"/g)];
assert.ok(scripts.length > 0, 'client HTML의 JavaScript 진입 파일 누락');
for (const [, src] of scripts) {
assert.ok(src.startsWith('/assets/') && src.endsWith('.js'), src);
assert.ok(existsSync(`packages/client/dist${src}`), `client JS 누락: ${src}`);
}
const runtimeRoot = realpathSync('out/server');
function checkLinks(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) {
const target = realpathSync(path);
assert.ok(target === runtimeRoot || target.startsWith(runtimeRoot + sep),
`runner 밖을 가리키는 링크: ${path}`);
} else if (entry.isDirectory()) checkLinks(path);
}
}
checkLinks(runtimeRoot);
const child = spawnSync(process.execPath, ['dist/main.js'], {
cwd: runtimeRoot, encoding: 'utf8', env: { PATH: process.env.PATH ?? '' },
});
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stdout.trim(), '1:학습자');
console.log('shared JS·선언 / server 실행 / client 산출물 / runner 내부 링크 확인');성공하면 shared JS·선언 / server 실행 / client 산출물 / runner 내부 링크 확인이 출력됩니다. Node 실행의 기대 문자열은 1:학습자입니다. 출력 폴더의 모든 심볼릭 링크가 폴더 안에서 해결되는지도 검사하므로 server/node_modules만 떼어 복사한 뒤 원래 workspace를 참조하는 상태를 성공으로 보지 않습니다. 이 검사는 client 화면의 실제 브라우저 동작이나 Nest 서버의 readiness를 검사하지 않습니다.
풀이 예: shared를 빌드하기 전에는 package.json의 exports가 가리키는 dist/index.js·index.d.ts가 없습니다. 소비자만 빌드하면 공유 import의 타입 또는 파일 해석에서 막힐 수 있습니다. 경고를 없애려고 dist를 손으로 만드는 대신 그 파일의 생산자인 shared build를 먼저 연결하고 소비자 빌드와 독립 실행을 다시 확인합니다. 정확한 오류 문구는 설치된 도구에 따라 달라질 수 있습니다.
Docker의 COPY와 runner를 같은 파일 관계로 읽기
앞에서 제시한 원본 nginx.conf의 내용을 이 실습의 packages/client/nginx.conf에 그대로 둡니다. 아래는 이 작은 실습 전용 Dockerfile이며, 위 원본 두 Dockerfile을 덮어쓰지 않습니다.
**/node_modules
**/dist
out
.gitFROM node:24.21.0-alpine AS build
WORKDIR /app
RUN npm install --global [email protected]
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY packages/server/package.json ./packages/server/package.json
COPY packages/client/package.json ./packages/client/package.json
RUN pnpm install --frozen-lockfile
COPY packages ./packages
COPY scripts ./scripts
RUN pnpm run build
RUN pnpm run prepare:runtime
RUN pnpm test
FROM node:24.21.0-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/out/server/ ./
CMD ["node", "dist/main.js"]FROM node:24.21.0-alpine AS build
WORKDIR /app
RUN npm install --global [email protected]
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY packages/server/package.json ./packages/server/package.json
COPY packages/client/package.json ./packages/client/package.json
RUN pnpm install --frozen-lockfile
COPY packages ./packages
RUN pnpm --filter @my-fullstack-app/shared --fail-if-no-match build
RUN pnpm --filter client --fail-if-no-match build
FROM nginx:1.28-alpine AS runner
COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/packages/client/dist/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]두 파일의 build context는 ci-workspace-check 루트입니다. 예를 들어 해당 폴더에서 docker build -f packages/client/Dockerfile.workspace-check .를 실행하면, -f는 어떤 Dockerfile을 읽을지, 마지막 .는 COPY가 접근할 파일 집합을 정합니다. Dockerfile이 client 폴더에 있어도 COPY nginx.conf는 그 폴더를 기준으로 찾지 않습니다. 따라서 COPY packages/client/nginx.conf ...로 씁니다. Docker build context
설치 전에 root package.json·lockfile·workspace 설정과 세 package.json을 복사하고, 설치 뒤 소스 파일을 복사합니다. server runner는 독립 실행에 필요한 out/server 전체를 복사하고 개발 도구가 담긴 원래 workspace의 node_modules를 일부만 떼어오지 않습니다. client runner에는 Vite의 정적 dist와 nginx 설정만 필요합니다.
위 server image는 출력 후 종료하는 이 실습의 진입 파일을 실행합니다. 앞 Nest 앱으로 확장할 때에는 start 명령·생성 파일 경로, 운영 dependencies, DB·JWT 설정, 플랫폼별 네이티브 모듈과 readiness를 그 앱에서 다시 확인해야 합니다. 특히 호스트에서 확인한 의존 파일을 다른 OS의 image에 그대로 복사해 호환성을 추정하지 않습니다.
CI에서 pnpm 준비와 캐시 순서를 연결하기
실습 폴더를 학습 저장소의 ci-workspace-check 아래에 보관했다면 아래 파일은 그 저장소 루트의 .github/workflows/workspace-check.yml에 둡니다. pnpm-lock.yaml도 포함돼 있어야 합니다. pnpm 준비 → setup-node의 pnpm 캐시 → frozen install → 세 패키지 build → 실행 폴더 구성 → 확인 순서입니다. setup-node@v4의 pnpm 예시도 pnpm을 먼저 준비합니다. setup-node v4 캐시 안내
name: Workspace build check
on: workflow_dispatch
jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ci-workspace-check
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.9
run_install: false
- uses: actions/setup-node@v4
with:
node-version: '24.21.0'
cache: pnpm
cache-dependency-path: ci-workspace-check/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run prepare:runtime
- run: pnpm test이 워크플로우는 수동 실행용 검사 job 하나이며 Docker Hub push·SSH·운영 배포를 포함하지 않습니다. 그림의 build once·동일 digest 승격은 다음 운영 설계의 목표로 남습니다. 위 로컬 실습이 성공해도 그 그림의 artifact 전달·환경 승인·migration·health/smoke·조건부 rollback이 구현된 것은 아닙니다.
Docker를 실행할 수 없는 환경에서는 literal 파일의 frozen install·build·산출물·독립 Node 실행과 Docker COPY/CI 순서의 정적 검토까지 확인할 수 있습니다. 실제 image build, nginx 응답, 원격 Actions 실행, 서비스 배포는 별도 실행 결과가 있어야 확인한 것으로 기록합니다.
파일 관계를 스스로 확인하기
- context가
.이고 Dockerfile은packages/client/Dockerfile.workspace-check일 때, 설정 파일이packages/client/nginx.conf에만 있다면COPY nginx.conf ...는 왜 맞지 않을까요? Dockerfile 선택 경로와 COPY의 시작점을 각각 적어 보세요. - client가 앞 절처럼 shared를
import type으로만 가져오면 실행할 shared JS가 꼭 필요할까요? 이 실습의formatPublicUserimport와 비교해 보세요. - server의 package.json에서 name이
server인 채 폴더만backend로 바뀌었다고 가정해 보세요. 이름 필터의 선택 대상은 무엇이며, 어떤 파일 경로·workspace 패턴·Docker COPY를 따로 수정해야 할까요?
풀이 확인
-f또는 workflow의file은 Dockerfile을 고르고, context는 COPY가 읽는 파일 집합입니다. 루트 context에는 nginx.conf가 없고 packages/client/nginx.conf가 있으므로 후자의 경로를 적습니다. context를 client로 좁히면 이번 Dockerfile이 필요로 하는 루트 lockfile·workspace·shared 파일을 읽을 수 없으므로 단순히 context만 바꾸는 것으로 해결되지 않습니다.import type은 JS 출력에서 사라지므로 앞 절의 타입 전용 공유에는 실행할 함수가 없습니다. 이번 실습은 formatPublicUser를 실제로 호출하므로 shared의 JS가 먼저 만들어져야 합니다. 타입 검사 성공만으로 런타임 파일 존재를 대신할 수 없습니다.--filter server는 package.json의 이름을 선택합니다. 폴더명과 같은 규칙은 아닙니다.packages/*패턴은 새 packages/backend도 포함하지만 Docker COPY와 경로 확인 목록은 기존 server 경로를 그대로 가리키므로 실제 위치에 맞춰야 합니다.--fail-if-no-match는 이름 오타를 잡고, 경로 오류와 산출물 누락은 COPY·확인 단계가 별도로 잡습니다.
CI/CD 워크플로우 실행 및 모니터링
코드 푸시: main 브랜치에 코드 변경 사항을 푸시합니다.
git add .
git commit -m "feat: setup CI/CD pipeline"
git push origin mainGitHub 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를 통해 개발 프로세스를 자동화하면, 개발자는 기능 구현에 더 집중할 수 있고, 사용자들은 더 빠르고 안정적인 서비스를 경험할 수 있습니다.
다음 절에서는 배포 전략과 운영 관측 기준을 살펴봅니다.