1인 개발 CI/CD 파이프라인 삽질기 — 수동 배포에서 완전 자동화까지
📚 모노레포 아키텍처 결정기 시리즈 (4편)
1인 개발 모노레포에서 CI/CD 파이프라인을 구축하며 겪은 실전 트러블슈팅. Docker 빌드 경로 오류, 배포 자동화 실패, Cloud Scheduler 설정 함정까지 — 수동 배포의 고통에서 벗어나는 과정을 정리했다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 수동 배포는 7–8단계 — 하나라도 빠뜨리면 추적 불가·알림 누락 사고가 남
- Docker 모노레포 빌드는 루트 컨텍스트 +
-f경로 지정이 핵심- Cloud Scheduler → Cloud Run POST는
--message-body="{}"+ global prefix 필수- 배포 자동화 실패 시 전체 단계 체크리스트를 수동으로 순회해야 함
- CI에 서버 기동 smoke test 한 단계 추가하면 런타임 장애를 배포 전에 잡을 수 있음
1인 개발에서 CI/CD 파이프라인은 사치가 아니다. 나 하나밖에 없으니까, 자동화 안 하면 모든 삽질을 내가 다 한다.
Docker 빌드, 이미지 푸시, Cloud Run 배포, Git 태그, 팀 알림 — 이걸 매번 수동으로 하면 배포 한 번에 30분이다. NestJS + React 모노레포를 운영하면서 CI/CD 파이프라인을 구축한 과정과, 그 과정에서 터진 사고들을 정리했다.
📌 증상: 수동 배포의 고통
배포 프로세스가 이랬다.
- Docker 빌드
- 이미지 태깅 + 레지스트리 푸시
- Cloud Run 배포
- 헬스체크
- Git 태그 생성
- 버전 범프 커밋
- 팀 알림
7단계. 하나라도 빠뜨리면 문제가 된다.
⚠️ 주의: 수동 배포의 진짜 함정은 “핵심만 하고 부가 작업을 건너뛰는 것”이다. Docker 푸시 + Cloud Run 반영까지 하면 “배포 끝”이라고 착각한다.
실제로 프로젝트 API v1.7.2 배포 때 사고가 터졌다. 배포 자동화 스크립트가 권한 문제로 실패해서 Docker/Registry/Cloud Run 단계만 수동으로 처리했다.
배포 자체는 성공. 근데 Git 태그를 안 만들었고, 팀 알림도 안 보냈다.
나중에 “그 버전 언제 배포됐어요?”라는 질문에 태그가 없으니 추적이 안 됐다 💀
DORA 보고서(2024)에 따르면, 엘리트 팀의 배포 빈도는 하루 여러 번이고, 변경 실패율은 5% 미만이다. 1인 개발에서도 배포 안정성이 떨어지면 기능 개발에 집중할 수 없다. 수동 배포는 실수를 내장하고 있다.
수동 배포에서 빠지는 단계 패턴
- Docker 빌드 → 푸시 → 배포: 여기까지만 하고 “끝”
- Git 태그: 귀찮아서 스킵. 나중에 버전 추적 불가
- 헬스체크: “배포됐으니 되겠지” → 프로덕션에서 500
- 팀 알림: 혼자니까 필요 없다고 생각 → 나중에 내가 모름
태그, 검증, 알림은 부가 작업이 아니라 배포의 일부다.
🕵️ 탐색: 파이프라인 구축 중 터진 3가지 사고
삽질 1: Docker 빌드 컨텍스트 경로 함정
모노레포에서 Docker 빌드할 때 가장 흔한 실수가 빌드 컨텍스트 경로다.
pnpm workspace 모노레포 구조:
monorepo/
├── apps/
│ ├── api/ # NestJS
│ │ └── Dockerfile
│ └── web/ # React
├── packages/
│ └── shared/ # 공유 타입
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
❌ Before — api 폴더에서 빌드
# apps/api/ 에서 빌드 → pnpm-lock.yaml 접근 불가
docker build -t my-api ./apps/api
에러 메시지:
=> ERROR [build 3/5] COPY pnpm-lock.yaml ./
------
ERROR: failed to solve: failed to compute cache key:
"/pnpm-lock.yaml": not found
pnpm-lock.yaml은 모노레포 루트에 있다.
Dockerfile이 api 폴더 안에 있으니 빌드 컨텍스트가 api 폴더로 제한된다.
루트의 lock 파일에 접근할 수 없다.
📌 핵심: 모노레포 Docker 빌드에서 “file not found” 에러가 나면, 십중팔구 빌드 컨텍스트 문제다.
✅ After — 루트에서 빌드 + Dockerfile 경로 지정
# 모노레포 루트에서 빌드 → 전체 파일 접근 가능
docker build -t my-api -f apps/api/Dockerfile .
# apps/api/Dockerfile
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
# 루트의 workspace 설정 파일들을 먼저 복사
# // 캐시 레이어 활용 — 소스 변경 시 의존성 재설치 방지
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
FROM base AS deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM deps AS build
COPY apps/api/ ./apps/api/
COPY packages/shared/ ./packages/shared/
RUN pnpm --filter api build
빌드 컨텍스트를 모노레포 루트로 잡으면 해결된다.
pnpm 공식 Docker 가이드에서도 모노레포 빌드 시 루트 컨텍스트를 권장한다.
대신 .dockerignore에서 불필요한 파일을 제외해야 빌드 컨텍스트가 비대해지지 않는다.
# .dockerignore
node_modules
.git
.gitignore
*.md
dist
.turbo
💡 팁:
--mount=type=cache,id=pnpm,target=/pnpm/store를 쓰면 BuildKit 캐시 마운트로 pnpm store를 재사용한다. 의존성이 변경되지 않았으면 설치 시간이 1초 미만으로 줄어든다.
삽질 2: CI/CD 배포 경로와 서비스 URL 불일치
이건 정말 어이없는 사고였다. 콘텐츠 앱의 CI/CD 배포를 설정했는데, 배포 후에도 변경사항이 반영되지 않았다.
빌드 성공. FTP 업로드 성공. 근데 번들 해시가 안 바뀐다.
Before: index-P1UVYpSs.js
After: index-P1UVYpSs.js ← 동일!
빌드했는데 해시가 그대로라면, 배포 경로가 잘못된 거다.
원인 분석
저장소 이름은 puzzle-bobble이었는데, 실제 서비스 URL은 /contents/puzzle-bubble이었다.
CI/CD 설정에서 배포 대상 경로(FTP_TARGET_DIR)가 저장소 이름 기반이라 다른 디렉토리에 배포하고 있었다.
⚠️ 주의: 더 어이없는 건 “bobble vs bubble — 이거 오타 아니야?”라고 판단해서 서비스 URL 쪽을
bobble로 변경한 것. 프론트엔드가 원래 경로(bubble)를 참조하니 구버전만 보여줬다.
”오타 수정” 전 확인 체크리스트
- CI/CD 설정의 배포 경로 확인
- 실제 서비스가 참조하는 URL 확인
- 저장소명 ≠ 배포 경로일 수 있다 (의도적 설정)
- “왜 이렇게 되어있지?”를 먼저 질문할 것
“당연히 오타겠지”는 위험한 가정이다. 저장소명과 배포 경로가 다른 건 흔한 패턴이다. 배포가 안 되면 경로부터 의심하자.
삽질 3: Cloud Scheduler → Cloud Run POST 요청 실패
배치 작업을 Cloud Scheduler로 스케줄링했다. NestJS의 내부 배치 엔드포인트를 정해진 시간에 호출하는 구조.
테스트 Job 2개를 만들었는데 둘 다 실패했다.
❌ 실패 1: 411 Length Required
gcloud scheduler jobs create http kickoff-batch \
--schedule="0 6 * * *" \
--http-method=POST \
--uri="https://my-api-xyz.run.app/internal/batch/kickoff"
HTTP 411 Length Required
Cloud Run 앞단의 Google 프록시가 body 없는 POST를 거부한다.
HTTP 스펙상 POST에 Content-Length가 없으면 일부 프록시가 411을 반환한다.
Content-Length: 0이라도 보내야 한다.
❌ 실패 2: 404 Not Found
body를 추가했더니 이번엔 404.
NestJS에서 setGlobalPrefix('api/v1')을 설정해둬서, 실제 엔드포인트는 /api/v1/internal/batch/kickoff였다.
컨트롤러에 /internal/batch/kickoff로 데코레이터를 달아놨으니 착각하기 쉽다.
🔍 근본 원인: NestJS global prefix는 컨트롤러 라우팅 데코레이터에 표시되지 않는다.
@Controller('internal/batch')라고 써도 실제 경로는/api/v1/internal/batch다. 외부에서 호출할 때는 항상 전체 경로를 확인해야 한다.
✅ After — 전체 경로 + body + Content-Type
gcloud scheduler jobs create http kickoff-batch \
--schedule="0 6 * * *" \
--http-method=POST \
--uri="https://my-api-xyz.run.app/api/v1/internal/batch/kickoff" \
--headers="Content-Type=application/json" \
--message-body="{}" \
--time-zone="Asia/Seoul"
Job 생성 후에는 반드시 수동 실행으로 검증한다.
# 수동 실행
gcloud scheduler jobs run kickoff-batch
# 결과 확인
gcloud scheduler jobs describe kickoff-batch
# lastAttemptResult: {} ← 빈 객체 = 성공
# lastAttemptResult: { code: 5 } ← NOT_FOUND = URI 오류
🛠️ 해결: 8단계 배포 파이프라인 자동화
수동 배포의 문제를 해결하려면 전체 프로세스를 자동화해야 한다.
파이프라인 전체 구조

| 단계 | 작업 | 설명 |
|---|---|---|
| 1 | 사전 점검 | 브랜치 확인, 미커밋 파일 확인 |
| 2 | 버전 범프 | package.json 버전 업데이트 + 커밋 |
| 3 | Docker 빌드 | multi-stage build |
| 4 | 레지스트리 푸시 | Artifact Registry |
| 5 | Cloud Run 배포 | gcloud run deploy |
| 6 | Git 태그 | git tag -a v{VERSION} |
| 7 | 헬스체크 | /health 엔드포인트 확인 |
| 8 | 팀 알림 | 메신저로 배포 결과 발송 |
이 8단계를 스크립트 하나로 묶었다. 핵심은 원자성이다. 1–5단계만 성공하고 6–8단계를 건너뛰면 “배포는 됐는데 추적이 안 되는” 상태가 된다.
📌 핵심: 자동화 스크립트에서 각 단계 실패 시 에러를 잡고, 어디까지 완료됐는지 로그를 남겨야 한다.
set -e로 즉시 종료하면 어디서 실패했는지 추적하기 어렵다. 각 단계를 함수로 분리하고, 실패 시 “Step 5/8 실패: Cloud Run 배포 에러”처럼 명확하게 로그를 남기자.
Docker Multi-Stage Build 최적화
모노레포에서 Docker 이미지 크기를 줄이는 핵심은 multi-stage build다.
# Stage 1: 의존성만 설치 (캐시 레이어)
FROM node:20-slim AS deps
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --filter api...
# Stage 2: 소스 복사 + 빌드
FROM deps AS build
COPY apps/api/ ./apps/api/
COPY packages/shared/ ./packages/shared/
RUN pnpm --filter api build
# // Prisma Client는 빌드 후 별도 generate 필요
RUN pnpm --filter api exec prisma generate
# Stage 3: 프로덕션 이미지 (최소화)
FROM node:20-slim AS production
WORKDIR /app
COPY --from=build /app/apps/api/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
# // generated 파일은 node_modules 밖에 있을 수 있다
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["node", "dist/main.js"]
⚠️ 주의:
pnpm deploy --legacy를 쓰면 Prisma Client 같은 generated 파일이 자동 복사되지 않는다. 이걸 놓치면 빌드는 되는데 런타임에서PrismaClient is not generated에러가 터진다. Prisma 공식 문서에서도 CI 환경에서의 generate 누락을 흔한 문제로 다루고 있다.
이미지 크기 비교:
| 방식 | 이미지 크기 |
|---|---|
| 단일 스테이지 (전체 복사) | ~1.2GB |
| Multi-stage (프로덕션 deps만) | ~350MB |
| Multi-stage + slim 베이스 | ~250MB |
70% 이상 줄어든다. 이미지가 작을수록 레지스트리 푸시, Cloud Run cold start가 빨라진다.
CI에서 서버 기동 Smoke Test
Docker 빌드가 성공해도 서버가 뜨지 않을 수 있다. 빌드 = TypeScript 컴파일, 기동 = NestJS DI 컨테이너 + DB 연결이기 때문이다.
CI에 기동 테스트를 추가하면 배포 전에 잡을 수 있다.
# .github/workflows/deploy.yml (개념)
jobs:
deploy:
steps:
- name: Build Docker image
run: docker build -t my-api -f apps/api/Dockerfile .
- name: Smoke test — 서버가 뜨는지 확인
run: |
docker run -d --name smoke-test \
-e DATABASE_URL="postgresql://..." \
-p 3000:3000 my-api
sleep 5
curl -f http://localhost:3000/api/v1/health || exit 1
docker stop smoke-test
- name: Push to registry
if: success()
run: docker push my-api
💡 팁: NestJS DI 에러, Prisma Client 미생성, 환경 변수 누락 — 이런 문제가 전부 빌드는 통과하고 기동에서 터진다. 이 smoke test 한 단계가 프로덕션 장애를 예방한다.
배포 자동화가 실패하면?
자동화 스크립트도 실패할 수 있다. 권한 문제, 네트워크 이슈, API 제한 등.
이때 핵심은 스크립트의 전체 단계를 알고 있는 것이다.
v1.7.2 사고는 자동화 스크립트가 권한 문제로 실패해서 3–5단계만 수동으로 처리한 게 원인이었다. 스크립트가 8단계인데 5단계까지만 수동으로 하고 “끝”이라고 판단했다.
- Git 태그:
git tag -a v1.7.2 -m "Release v1.7.2"→ 누락 - 헬스체크: “배포됐으니 되겠지” → 스킵
- 팀 알림: “나만 알면 되지” → 나중에 나도 모름
📊 데이터: 제 경우 수동 배포 시 부가 단계(태그/검증/알림) 누락률이 약 40%였다. 자동화 후에는 0%다. 스크립트는 귀찮음을 모른다.
✅ 검증: 배포 후 확인 프로세스
헬스체크 자동화
# Cloud Run 서비스 URL 확인
SERVICE_URL=$(gcloud run services describe my-api \
--region=asia-northeast3 \
--format="value(status.url)")
# 헬스체크 (5초 타임아웃)
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 5 \
"${SERVICE_URL}/api/v1/health")
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Health check failed: HTTP $HTTP_CODE"
# 롤백 또는 알림 트리거
exit 1
fi
echo "✅ Health check passed"
Cloud Scheduler Job 검증
# 모든 Scheduler Job 상태 확인
gcloud scheduler jobs list \
--format="table(name,state,status.latestExecTime,schedule)"
# 특정 Job 수동 실행 + 결과 확인
gcloud scheduler jobs run kickoff-batch
gcloud scheduler jobs describe kickoff-batch \
--format="value(status.lastAttemptResult)"
💡 팁: Cloud Scheduler Job 생성 후 반드시 한 번은 수동 실행해봐야 한다.
gcloud scheduler jobs run한 줄이면 된다. 실행 결과가{}(빈 객체)이면 성공,code: 5이면 URI 오류다.
🛡️ 예방: 1인 개발 CI/CD 체크리스트
Docker 빌드 체크리스트
- 빌드 컨텍스트가 모노레포 루트인가?
pnpm-lock.yaml,pnpm-workspace.yaml이 복사되는가?- multi-stage build로 이미지 크기를 최소화했는가?
- Prisma Client (generated) 파일이 최종 이미지에 포함되는가?
.dockerignore에node_modules,.git,dist등이 있는가?
배포 파이프라인 체크리스트
- 8단계 전부 자동화되어 있는가?
- 자동화 실패 시 수동 체크리스트가 문서화되어 있는가?
- Git 태그가 배포 버전과 일치하는가?
- 헬스체크가 배포 직후 자동 실행되는가?
- 팀 알림이 배포 결과(버전, 시간, 성공/실패)를 포함하는가?
Cloud Scheduler 체크리스트
- URI에 NestJS global prefix가 포함되어 있는가? (
/api/v1/...) - POST 요청에
--message-body="{}"가 있는가? --headers="Content-Type=application/json"이 있는가?- Job 생성 후
gcloud scheduler jobs run으로 수동 검증했는가? - 타임존(
--time-zone)이 올바르게 설정되어 있는가?
📋 정리
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| Docker 빌드 경로 | api 폴더에서 docker build . | 루트에서 -f apps/api/Dockerfile . |
| 배포 경로 설정 | 저장소명과 서비스 URL이 같다고 가정 | CI/CD 배포 경로와 실제 서비스 URL 대조 |
| Cloud Scheduler POST | body 없이 POST 요청 | --message-body="{}" + Content-Type 필수 |
| NestJS global prefix | 컨트롤러 경로만으로 URI 작성 | /api/v1/ prefix 포함 전체 경로 사용 |
| 배포 자동화 실패 | 핵심 단계만 수동 처리 | 전체 8단계 체크리스트를 수동 순회 |
| Prisma Client | pnpm deploy만 믿음 | generated 파일 별도 복사 확인 |
1인 개발에서 CI/CD 파이프라인은 “나중에 하자”가 아니라 “지금 안 하면 매번 30분씩 날린다”다. 자동화를 만들고, 자동화가 실패했을 때의 매뉴얼도 만들어두자 ✨
📚 모노레포 아키텍처 결정기 시리즈 (4편)
- 1. NestJS + React 모노레포 구성법 — pnpm workspace 실전기
- 2. pnpm workspace 의존성 삽질기 — 모노레포 5가지 함정
- 3. 스펙 변경에 강한 API 설계 — 1인 개발 실전 패턴 5가지
- 4. 1인 개발 CI/CD 파이프라인 삽질기 — 수동 배포에서 완전 자동화까지