CI/CD 파이프라인 삽질 11가지
pnpm 모노레포 Docker 빌드, Jest CI, EC2 배포까지 GitHub Actions 파이프라인 구성 중 겪은 트러블슈팅 전 과정을 기록합니다.
이전 글에서는 GitHub Actions CI/CD 파이프라인의 개념과 전체 설계를 다뤘습니다.
실제로 파이프라인을 구성하는 과정에서 크고 작은 문제들이 발생했습니다.
이번 글에서는 그 트러블슈팅 과정을 기록합니다.
트러블슈팅
파이프라인을 만들면서 겪은 삽질들을 기록합니다.
에러가 발생한 순서대로, 원인과 해결책을 남깁니다.
Docker 빌드 삽질들
삽질 1 — workspace symlink가 COPY에 파괴되다
에러
Cannot find module '@repo/schemas'
Object is possibly 'undefined' ← DTO 프로퍼티 32건

원인
pnpm workspace는 apps/server/node_modules/@repo/schemas를 ../../../../packages/schemas로 향하는 심볼릭 링크로 만듭니다.
일반적인 레이어 캐시 최적화 패턴에서는 package.json만 먼저 복사하고 install 후에 소스를 복사합니다.
COPY apps/server/package.json ./apps/server/
RUN pnpm install # symlink 생성됨
COPY apps/server ./apps/server # ← 디렉터리를 통째로 덮어씀, symlink 사라짐
install이 만든 node_modules 안의 symlink가 소스 COPY로 사라집니다.
해결
소스 전체를 install 이전에 복사합니다.
COPY packages/schemas ./packages/schemas
COPY apps/server ./apps/server
RUN pnpm install --frozen-lockfile # symlink가 보존됨
pnpm 모노레포 Docker 빌드에서 install 이후에 패키지 디렉터리를 통째로 COPY하면 안 됩니다.
삽질 2 — tsbuildinfo, TypeScript의 조용한 거짓 성공
에러
tsc: exit 0 (성공)
dist/index.js: 없음
Cannot find module '@repo/schemas'
원인
.dockerignore에 dist는 있는데 *.tsbuildinfo가 빠져 있었습니다.
TypeScript incremental 빌드는 tsconfig.build.tsbuildinfo에 이전 빌드 상태를 기록합니다.
컨테이너에 dist는 없고 tsbuildinfo만 들어오면, tsc가 “이미 최신 빌드”로 판단하고 emit 없이 종료합니다.
로컬 파일시스템:
packages/schemas/dist/ → .dockerignore에 의해 제외됨
packages/schemas/tsconfig.build.tsbuildinfo → .dockerignore에 없어 컨테이너에 포함됨
컨테이너 안에서:
dist/ 없음 + tsbuildinfo 있음
→ tsc: "이미 최신, emit 생략" → exit 0
→ dist/index.js 미생성
→ @repo/schemas exports 해석 실패
디버그 Dockerfile을 만들어 ls packages/schemas/dist/를 확인했을 때 아래와 같은 상태였습니다.
$ ls packages/schemas/dist/
index.d.ts.map index.js.map todo.dto.d.ts.map todo.dto.js.map ...
# index.js 없음 — .map 파일만 10개
rm -rf dist tsconfig.build.tsbuildinfo 후 tsc를 재실행하자 정상 빌드됐습니다.
해결
.dockerignore에 두 줄을 추가합니다.
*.tsbuildinfo
**/*.tsbuildinfo
dist와 tsbuildinfo는 반드시 쌍으로 제외해야 합니다. 하나만 없으면 incremental 빌드가 잘못된 상태에서 시작합니다.
삽질 3 — pnpm deploy와 dist 누락
에러
Error: Cannot find module '/app/dist/main.js'
원인
pnpm deploy --filter=server --prod /prod/server는 prod 의존성과 소스 파일을 단일 디렉터리로 추출합니다.
그런데 nest build가 생성한 dist/는 자동으로 포함되지 않습니다.
pnpm deploy는 패키지 파일과 node_modules를 패키징하는 도구입니다.
컴파일된 산출물은 별도로 복사해야 합니다.
추가로 pnpm v10부터 workspace 패키지 deploy 시 ERR_PNPM_DEPLOY_NONINJECTED_WORKSPACE 에러가 발생합니다.
해결
--legacy 플래그 추가와 dist 명시 복사입니다.
RUN pnpm deploy --filter=server --prod --legacy /prod/server \
&& cp -r apps/server/dist /prod/server/dist
Jest CI 삽질들
삽질 4 — Jest가 @경로를 모른다
에러
Cannot find module '@/common/decorators/response-message.decorator'
Cannot find module '@repo/schemas'

원인
Jest는 TypeScript 컴파일러가 아닙니다.
tsconfig.json의 paths를 읽지 않으므로 @/로 시작하는 import를 독자적으로 해석해야 합니다.
@repo/schemas는 "type": "module" ESM 패키지입니다.
Jest에서 ESM을 그대로 물면 변환 설정이 복잡해집니다.
해결
jest.config.ts에 moduleNameMapper를 추가합니다.
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
// @repo/schemas를 dist가 아닌 소스 TS로 직접 매핑 → ESM 충돌 회피
"^@repo/schemas$": "<rootDir>/../../packages/schemas/src/index.ts",
// .js 확장자 import 처리
"^(\\.{1,2}/.*)\\.js$": "$1",
},
삽질 5 — DI Mock 없이 컴파일이 안 된다
에러
Nest can't resolve dependencies of TodosService (Repository?, PinoLogger?)
원인
유닛 테스트에서 TodosModule을 그대로 compile()하면 NestJS가 Repository, PinoLogger, JwtService 등 모든 의존성을 주입하려 합니다.
실제 DB 연결 없이는 불가능합니다.
해결
공통 Mock 헬퍼를 만들어 각 spec에서 재사용합니다.
// test/helpers/test-providers.ts
export const mockRepositoryProvider = <T>(entity: new () => T) => ({
provide: getRepositoryToken(entity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
},
});
export const mockPinoLoggerProvider = {
provide: PinoLogger,
useValue: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
};
spec마다 같은 mock을 복붙하지 않고 헬퍼로 DRY하게 관리합니다.
전체 9개 suite가 통과했습니다.
Nest Jest 실패는 보통 3단계 연쇄입니다: (1) path alias 해석 실패 → (2) workspace 패키지 ESM 충돌 → (3) DI mock 부재. 하나를 고치면 다음이 터집니다.
EC2 배포 삽질들
삽질 6 — docker: command not found
에러
bash: line 2: docker: command not found
exit status 127

원인 추적 과정
처음에는 비대화형 SSH가 .bashrc를 로드하지 않아 PATH가 짧아진 탓이라고 생각했습니다.
워크플로 스크립트에 PATH 보강 코드를 추가했지만 해결되지 않았습니다.
EC2에 직접 SSH 접속해서 docker --version을 치니 동일하게 command not found였습니다.
Docker Engine 자체가 미설치된 상태였습니다.
해결
# EC2에서 실행
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker ubuntu
# 재로그인 (권한 적용)
배포 파이프라인이 실패하면 “워크플로 설정 문제”를 먼저 의심합니다. 하지만 런타임 서버 자체가 준비되지 않은 경우도 많습니다. Actions 로그보다 EC2 직접 접속 확인이 먼저입니다.
삽질 7 — exec format error, ARM64와 amd64의 충돌
에러
exec /usr/local/bin/docker-entrypoint.sh: exec format error
컨테이너가 기동됐다가 즉시 재시작을 반복합니다.
원인
EC2에서 uname -m을 실행하니 aarch64가 출력됐습니다.
AWS Graviton 인스턴스, 즉 ARM64 아키텍처입니다.
GitHub Actions 기본 runner인 ubuntu-latest는 amd64(x86_64) 아키텍처입니다.
amd64로 빌드된 Docker 이미지를 arm64 EC2에서 실행하면 CPU 명령어 집합이 달라 바이너리를 실행할 수 없습니다.
처음 시도는 QEMU 에뮬레이터를 세팅하고 platforms: linux/arm64를 추가하는 것이었습니다.
- uses: docker/setup-qemu-action@v3
- uses: docker/build-push-action@v6
with:
platforms: linux/arm64
삽질 8 — QEMU의 함정, SIGILL과 15분 멈춤
에러
qemu: uncaught target signal 4 (Illegal instruction) - core dumped
빌드가 15분 넘게 진행이 없다가 크래시가 납니다.

원인
bcrypt는 설치 시 C++ 네이티브 바이너리를 컴파일합니다.
amd64 runner 위에서 QEMU로 arm64 명령어를 에뮬레이션하며 이 컴파일을 수행하면, CPU 명령어 변환 병목이 극단적으로 터집니다.
Illegal instruction은 에뮬레이터가 처리하지 못한 명령어를 만났다는 신호입니다.
해결
amd64에서 QEMU로 에뮬레이션하는 대신, 처음부터 ARM runner에서 네이티브 빌드합니다.
deploy:
runs-on: ubuntu-24.04-arm # ARM 네이티브 runner — QEMU 불필요

GitHub Actions는 ubuntu-24.04-arm runner를 지원합니다.
EC2 Graviton과 동일한 aarch64 아키텍처이므로 QEMU 없이 네이티브 빌드가 가능합니다.
QEMU 스텝을 전부 제거하고 runner만 바꿨더니 빌드가 정상 속도로 완료됐습니다.
Graviton EC2를 타겟으로 한다면 QEMU 크로스 빌드보다 ARM native runner가 정답입니다. 네이티브 모듈이 있으면 QEMU는 버팁니다.
삽질 9 — workflow 수정이 트리거되지 않는다
증상
gradle.yml(워크플로 파일)을 수정하고 push했는데 워크플로가 실행되지 않았습니다.
EC2에는 구 amd64 이미지가 그대로 돌고 있었습니다.
원인
on.push.paths 필터가 있었습니다.
on:
push:
paths:
- "apps/server/**"
- "packages/schemas/**"
- "pnpm-lock.yaml"
# .github/workflows/** ← 빠져 있었음
paths 필터가 있으면 해당 경로의 파일이 변경될 때만 워크플로가 트리거됩니다.
워크플로 YAML 파일 자체를 수정해도 .github/workflows/**가 paths에 없으면 실행되지 않습니다.
해결
paths:
- "apps/server/**"
- "packages/schemas/**"
- "pnpm-lock.yaml"
- ".github/workflows/**" ← 추가
모노레포에서 path 필터를 쓴다면 워크플로 파일 경로도 반드시 포함해야 합니다. CI 설정을 수정했는데 반영이 안 된다면 이 필터를 먼저 확인합니다.
삽질 10 — pino-pretty가 프로덕션을 죽이다
에러
Error: unable to determine transport target for "pino-pretty"
arm64 이미지로 컨테이너가 뜨는 것 같았는데, docker logs를 보니 2~3초마다 재시작되고 있었습니다.

원인
세 가지 조건이 맞물렸습니다.
LoggerModule:NODE_ENV !== 'production'일 때 pino-pretty transport 활성화- 서버
/home/ubuntu/apps/.env:NODE_ENV=development로 설정되어 있었음 pnpm deploy --prod:pino-pretty는 devDependency이므로 프로덕션 이미지에 미포함
프로덕션 이미지에서 존재하지 않는 transport를 찾으려다 매번 크래시가 납니다.
해결
docker run에 -e NODE_ENV=production을 명시해 .env 파일의 값을 덮어씁니다.
docker run -d \
--env-file /home/ubuntu/apps/.env \
-e NODE_ENV=production \ # --env-file보다 우선 적용
...
--env-file로 주입된 값보다 docker run -e 인수가 우선 적용됩니다.
프로덕션 이미지 + .env의 NODE_ENV=development 조합은 런타임에만 터지는 조용한 버그입니다. 배포 스크립트에서 NODE_ENV=production을 강제 주입하는 것이 안전합니다.
삽질 11 — DB_HOST=postgresql인데 ENOTFOUND
에러
Error: getaddrinfo ENOTFOUND postgresql
Nest 서버가 기동됐지만 DB 연결에서 반복 실패합니다.

원인
EC2에서 PostgreSQL은 docker compose up으로, API 서버는 docker run으로 각각 띄웠습니다.
Compose가 자동 생성한 네트워크:
└── postgresql 컨테이너
'postgresql' DNS는 이 네트워크 안에서만 유효
기본 브리지 네트워크:
└── todo-server 컨테이너
'postgresql'을 찾을 수 없음 → ENOTFOUND
Docker Compose가 자동 생성하는 네트워크 이름은 [디렉터리명]_default 형태입니다.
docker run으로 띄운 컨테이너는 이 네트워크에 자동으로 합류되지 않습니다.
해결
EC2용 docker-compose.yml에 명시적 네트워크를 정의하고 이름을 고정합니다.
# deploy/ec2/docker-compose.yml
services:
postgresql:
networks:
- todo-network
networks:
todo-network:
name: todo-network # 이름을 고정해야 외부 컨테이너가 --network로 참조 가능
API 서버 배포 시 --network todo-network를 지정합니다.
docker run -d \
--network todo-network \ # Compose 네트워크에 합류
...
DB_HOST문자열이 맞는지보다, 두 컨테이너가 같은 Docker 네트워크 안에 있는지를 먼저 확인해야 합니다.
마치며
파이프라인을 처음 만들 때 “워크플로 파일 한 번 쓰면 되는 거 아닌가”라고 생각했습니다.
실제로는 Docker 빌드 내부, Jest 환경, EC2 런타임까지 각기 다른 층에서 문제가 터졌습니다.
이번 시리즈 네 편에 걸쳐 정리한 것들입니다.
- 1편: AWS EC2 인스턴스 생성, SSH 접속, 보안 그룹, RDS 대신 Docker를 선택한 이유
- 2편: Docker 기초 개념, pnpm 모노레포 Dockerfile 설계, EC2에 컨테이너 배포
- 3편: GitHub Actions CI/CD 파이프라인 개념과 전체 설계
- 4편: 파이프라인 구성 중 겪은 11가지 삽질
가장 선명하게 남은 교훈은 두 가지입니다.
.dockerignore에서dist만 빼고*.tsbuildinfo를 남기면, TypeScript가 아무 말 없이 거짓 성공을 한다. 빌드 산출물과 그 캐시는 항상 쌍으로 제외해야 한다.- GitHub Actions가 녹색이어도 EC2에서
docker ps와docker logs를 직접 확인해야 한다. 이미지가 Docker Hub에 올라간 것과 서비스가 살아있는 것은 완전히 다른 이야기다.
나중에 NestJS와 PostgreSQL로 프로젝트를 다시 배포할 때, 이 네 편이 실질적인 참고 자료가 되길 바랍니다.