- Published on
GitHub Actions로 Nx 모놀리포 CI/CD 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 앱/라이브러리가 한 저장소에 공존하는 모놀리포는 개발 생산성을 크게 끌어올리지만, CI/CD는 반대로 쉽게 느려집니다. 모든 PR마다 전체 테스트/빌드를 돌리면 비용과 대기 시간이 폭증하고, 캐시가 조금만 어긋나도 “왜 이렇게 느리지?”라는 질문이 반복됩니다.
Nx는 모놀리포를 전제로 변경 영향 범위(affected), 태스크 그래프, 로컬/원격 캐시, 병렬 실행을 제공하므로 GitHub Actions와 결합했을 때 최적화 여지가 큽니다. 이 글에서는 Nx 프로젝트(예: apps/web, apps/api, libs/*)를 기준으로, PR 검증부터 main 배포까지 빠르고 예측 가능한 CI/CD를 만드는 패턴을 정리합니다.
> Docker 기반 빌드/푸시를 함께 운영한다면 캐시 키 설계나 루프 이슈도 같이 터지기 쉽습니다. 관련해서는 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기도 함께 참고하면 좋습니다.
목표: “모든 걸”이 아니라 “필요한 것만”
Nx 최적화의 핵심은 단순합니다.
- affected만 실행: 변경된 프로젝트와 그 의존성만 lint/test/build
- 캐시를 일관되게:
node_modules(패키지 매니저 캐시) + Nx task cache(가능하면 원격) - 병렬화: Nx
--parallel+ GitHub Actions matrix(필요 시) - 배포 단위 분리: 앱별 아티팩트/이미지/배포를 독립적으로
이 4가지를 PR과 main 워크플로에 녹이면, “PR은 빠르게 검증, main은 안전하게 배포” 구조를 만들 수 있습니다.
전제: Nx에서 affected가 정확히 동작하도록
Nx의 affected는 기본적으로 Git diff를 기반으로 합니다. 따라서 CI에서 다음이 중요합니다.
actions/checkout시 fetch-depth: 0로 전체 히스토리를 가져오기(또는 base ref가 있는 만큼 fetch)- PR에서는
base와headSHA를 명확히 지정
또한 Nx Cloud(원격 캐시)를 쓰지 않더라도, 최소한 로컬 캐시(.nx/cache)를 Actions cache로 보존하면 효과가 큽니다.
PR 워크플로: lint/test/build를 affected로 최소화
아래 예시는 pnpm + Nx 기준입니다( npm/yarn도 동일한 구조로 변환 가능 ). 핵심은 다음입니다.
nx affected -t lint test build로 필요한 타깃만 실행NX_BASE,NX_HEAD를 PR 이벤트에서 정확히 계산pnpm store+.nx/cache캐시
# .github/workflows/ci-pr.yml
name: ci-pr
on:
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
affected:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store path
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Cache Nx
uses: actions/cache@v4
with:
path: |
.nx/cache
key: ${{ runner.os }}-nx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-nx-
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Compute base/head
id: shas
run: |
echo "BASE=${{ github.event.pull_request.base.sha }}" >> $GITHUB_OUTPUT
echo "HEAD=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
- name: Nx affected (lint/test/build)
env:
NX_BASE: ${{ steps.shas.outputs.BASE }}
NX_HEAD: ${{ steps.shas.outputs.HEAD }}
run: |
pnpm nx affected -t lint test build --parallel=3 --configuration=ci
포인트 1) --configuration=ci로 CI 전용 옵션 분리
로컬 개발과 CI는 요구사항이 다릅니다. 예를 들어:
- 테스트는
--runInBand가 더 안정적인 경우가 있음 - 빌드는 sourcemap을 끄거나, 캐시 친화적으로 설정
- lint는 포맷 검사만 하고 자동 수정은 금지
Nx project.json/workspace.json에서 configurations.ci를 만들어두면 커맨드가 깔끔해지고 재현성도 좋아집니다.
포인트 2) 캐시 키를 “너무 세게” 묶지 않기
위 예시에서 Nx 캐시는 restore-keys로 폭넓게 복구합니다. Nx task cache는 입력 해시 기반이라, 오래된 캐시가 복구되어도 잘못된 결과를 만들 가능성이 낮고(태스크가 재실행되거나 캐시 히트), 대신 복구율이 높아집니다.
반대로 Docker 레이어 캐시나 산출물 캐시를 과하게 공유하면 무한 재빌드/루프 같은 문제가 생길 수 있으니, Docker를 섞는 경우 캐시 키/태그 전략을 더 엄격히 분리해야 합니다. 이 이슈는 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기에서 다룬 패턴과 맞닿아 있습니다.
main 워크플로: “변경된 앱만” 배포로 연결
PR에서 검증이 끝났다면, main에서는 보통 다음을 원합니다.
- affected build로 아티팩트 생성
- 앱별 Docker 이미지 빌드/푸시(해당 앱만)
- 배포(예: Helm/Kustomize/Argo CD 트리거 등)
여기서도 핵심은 affected로 배포 대상을 좁히는 것입니다.
1) affected 앱 목록을 JSON으로 뽑아 matrix로 배포
Nx는 print-affected를 제공합니다. 이를 이용해 “이번 커밋에서 영향받은 앱만” matrix로 돌릴 수 있습니다.
# .github/workflows/cd-main.yml
name: cd-main
on:
push:
branches: [ main ]
permissions:
contents: read
id-token: write
jobs:
detect:
runs-on: ubuntu-latest
outputs:
apps: ${{ steps.set-matrix.outputs.apps }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- id: set-matrix
run: |
# base를 직전 커밋으로 잡는 단순 전략(팀 정책에 맞게 조정)
BASE=$(git rev-parse HEAD~1)
HEAD=$(git rev-parse HEAD)
# type=app인 프로젝트만 추출
APPS=$(pnpm nx print-affected --base=$BASE --head=$HEAD --type=app --select=projects)
# 빈 문자열이면 []로
if [ -z "$APPS" ]; then
echo 'apps=[]' >> $GITHUB_OUTPUT
else
# "a b c" -> ["a","b","c"]
JSON=$(node -e "console.log(JSON.stringify(process.argv.slice(1)))" $APPS)
echo "apps=$JSON" >> $GITHUB_OUTPUT
fi
deploy:
needs: detect
if: ${{ needs.detect.outputs.apps != '[]' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: ${{ fromJson(needs.detect.outputs.apps) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Build only this app
run: pnpm nx build ${{ matrix.app }} --configuration=production
- name: Docker build & push (example)
run: |
echo "Build/push image for ${{ matrix.app }}"
# docker build -f apps/${{ matrix.app }}/Dockerfile ...
# docker push ...
- name: Deploy (example)
run: |
echo "Deploy ${{ matrix.app }}"
# helm upgrade --install ...
이 접근의 장점은 명확합니다.
- main에 여러 앱 변경이 섞여도 앱별로 독립 배포
- 실패가 특정 앱에 국한되어 원인 파악이 쉬움
- 병렬 배포로 전체 리드타임 감소
Nx 캐시를 “제대로” 쓰는 방법
로컬 캐시(.nx/cache)만으로도 효과가 있지만 한계가 있다
GitHub Actions 캐시는 “같은 저장소/브랜치/키 패턴”에서만 잘 재사용됩니다. PR이 많아지고 브랜치가 다양해지면 히트율이 떨어집니다.
이때 선택지는 두 가지입니다.
- Nx Cloud(원격 캐시) 도입: 팀 단위로 캐시 공유 → 가장 큰 체감
- 자체 원격 캐시: S3/Redis 등으로 구성(운영 난이도 증가)
Nx Cloud를 쓰면 동일한 입력 해시를 가진 작업은 CI 환경이 달라도 바로 캐시 히트가 나므로, 특히 e2e/빌드가 무거운 프론트 앱에서 효과가 큽니다.
캐시를 망치는 대표 원인: 비결정성 입력
캐시 히트가 안 나는 이유는 보통 “매번 입력이 달라짐”입니다.
- 빌드 시각/커밋 SHA를 번들에 주입
postinstall에서 파일 생성- 테스트가 랜덤/외부 API에 의존
- 환경변수에 따라 결과가 달라짐
해결은 간단합니다.
- 빌드 산출물에 시간/랜덤 값을 넣지 않기
- 외부 의존은 mock/stub로 고정
- Nx
inputs를 조정해 “정말 필요한 파일만” 해시 입력으로 포함
병렬화 전략: Nx parallel vs Actions matrix
- Nx
--parallel: 한 러너 안에서 CPU 코어를 활용해 태스크를 병렬 처리 - Actions matrix: 여러 러너로 쪼개서 병렬 처리(비용 증가, 속도 증가)
권장 접근은 단계적으로 입니다.
- 먼저
nx affected ... --parallel=N으로 단일 러너 최적화 - 그래도 느리면 “앱 단위”로 matrix 분리(위 deploy 예시)
- e2e가 무겁다면 e2e만 별도 job/matrix로 격리
CI 안정성: 리소스 한계(OOM)와 타임아웃
모놀리포 CI가 커지면 속도뿐 아니라 메모리/CPU 병목이 자주 발생합니다. 특히 프론트 빌드(webpack/vite/next)나 대규모 테스트가 겹치면 Node 프로세스가 OOM으로 죽기도 합니다.
--parallel값을 무작정 올리면 OOM 확률이 증가- 한 job에 너무 많은 타깃을 몰아넣으면 피크 메모리가 커짐
따라서 다음을 권합니다.
--parallel은 2~4부터 시작해 점진 조정- e2e는 별도 job으로 분리
- Node 힙 사이즈가 필요하면
NODE_OPTIONS=--max-old-space-size=4096등으로 조정
Kubernetes/EKS로 배포하는 경우, 빌드/배포 이후 런타임에서도 메모리 이슈가 이어질 수 있습니다. 런타임 OOM 진단은 Kubernetes OOMKilled 진단과 메모리 누수 추적 실전이 실전 관점에서 도움이 됩니다.
실전 팁: PR과 main의 “base” 기준을 팀 규칙으로 고정
affected의 base를 무엇으로 잡느냐는 팀마다 다릅니다.
- PR:
pull_request.base.shavsorigin/main - main:
HEAD~1vs “마지막 릴리즈 태그” vs “마지막 성공 배포 커밋”
권장 패턴:
- PR은 이벤트 payload의 base/head SHA 사용(가장 정확)
- main은 “직전 커밋”이 아니라, 마지막 배포 기준점을 태그로 두고
--base=last-release-tag로 계산하면 배포 누락을 줄일 수 있습니다.
예:
# 마지막 릴리즈 태그부터 현재까지 영향 앱만
pnpm nx print-affected --base=release-2026-02-01 --head=HEAD --type=app --select=projects
체크리스트: Nx 모놀리포 CI/CD 최적화 요약
-
actions/checkout fetch-depth: 0설정 - PR은
NX_BASE/NX_HEAD를 PR payload에서 가져오기 -
nx affected -t lint test build로 범위 최소화 - pnpm/yarn/npm 캐시 +
.nx/cache캐시 적용 -
--parallel은 보수적으로 시작(2~4) - main 배포는
print-affected로 앱 목록을 뽑아 matrix 배포 - 비결정성 입력 제거(시간/랜덤/외부 의존)
- 필요하면 Nx Cloud로 원격 캐시 공유
마무리
Nx 모놀리포에서 GitHub Actions CI/CD 최적화는 “더 빠른 러너”보다 실행 범위를 줄이고(affected), 캐시를 공유하고, 배포 단위를 쪼개는 것이 훨씬 큰 효과를 냅니다. 위의 PR 워크플로 + main matrix 배포 패턴을 기본 골격으로 잡고, 팀의 배포 기준점(base)과 캐시 정책만 일관되게 유지하면 PR 대기 시간과 배포 리드타임을 안정적으로 줄일 수 있습니다.