배포 사고라고 하기에는 사소했다.
테스트하려고 함수 중간에 return 하나를 넣어뒀다.
확인하고 지웠어야 했는데 그대로 커밋했고, 그대로 서버에 올라갔다. 에러가 난 것도 아니다. 그 아래 코드가 그냥 실행되지 않았다.
문제는 배포 방식이었다.
코드 수정
↓
사람이 확인
↓
서버 반영
↓
PM2 reload
중간에 막아주는 게 아무것도 없었다.
리뷰에서 놓치면 끝이었다.
그래서 CI/CD를 만들기 시작했다.
목표는 거창하지 않았다.
테스트를 통과하지 못한 코드는 서버에 올라갈 수 없게 만드는 것.
GitLab Runner를 배포 서버에 설치하고 파이프라인을 세 단계로 나눴다.
test
↓
build
↓
deploy
첫 번째 단계는 Vitest다.
unit_test:
stage: test
image: node:20
rules:
- if: '$CI_COMMIT_TAG =~ /^sandbox-/'
before_script:
- corepack enable
- corepack prepare pnpm@8.14.1 --activate
- pnpm install --frozen-lockfile
script:
- pnpm --filter project run test -- --reporter=default
여기서 실패하면 뒤의 build와 deploy는 실행되지 않는다.
CI/CD를 붙인 이유가 사실 이게 전부였다.
return 하나를 찾아주는 특별한 기능이 있는 건 아니다.
대신 테스트에서 그 코드 때문에 기대한 결과가 나오지 않으면 배포 자체가 멈춘다.
사람이 기억해서 막는 것보다 이쪽이 낫다.
CI/CD를 처음 붙이면서 운영 서버까지 한 번에 건드리고 싶지는 않았다.
별도 샌드박스를 하나 만들었다.
project-sandbox
PORT=4400
PM2에도 운영과 별개의 프로세스로 등록했다.
module.exports = {
apps: [
{
name: 'project-sandbox',
cwd: '/home/project/sandbox/.next/standalone',
script: 'server.js',
exec_mode: 'cluster',
instances: 1,
env: {
NODE_ENV: 'production',
PORT: 4400,
HOST: '0.0.0.0',
HOSTNAME: '0.0.0.0',
},
},
],
};
처음부터 여러 인스턴스를 띄우지 않고 하나로 시작했다.
CI에서 배포하고, 로그를 보고, 문제가 없으면 다음 단계로 넘어가는 용도였다.
개발 브랜치에 push할 때마다 서버까지 배포되는 것도 원하지 않았다.
그래서 샌드박스 배포는 태그를 명시적으로 찍었을 때만 실행되게 했다.
git tag sandbox-2026.08.01-1
git push origin sandbox-2026.08.01-1
CI에서는:
rules:
- if: '$CI_COMMIT_TAG =~ /^sandbox-/'
로 잡았다.
이렇게 하면 평소 push와 실제 배포 의도가 분리된다.
일반 push
→ 코드만 반영
sandbox-* 태그
→ test
→ build
→ deploy
처음 CI/CD를 붙이는 단계에서는 이 정도의 수동 장치가 오히려 마음이 편했다.
Next.js는 standalone 빌드를 사용했다.
// next.config.js
module.exports = {
output: 'standalone',
};
서버에 소스 전체를 복사하고 다시 pnpm install, pnpm build를 실행하는 대신 CI에서 빌드를 끝낸다.
build_project_sandbox:
stage: build
image: node:20
needs:
- unit_test
rules:
- if: '$CI_COMMIT_TAG =~ /^sandbox-/'
before_script:
- corepack enable
- corepack prepare pnpm@8.14.1 --activate
- pnpm install --frozen-lockfile --prod=false
script:
- pnpm -w build --filter project
artifacts:
expire_in: 3 days
paths:
- project/.next/standalone
- project/.next/static
- project/public
테스트가 성공하면 standalone 산출물을 만들고 GitLab artifact로 넘긴다.
서버가 받는 건 소스가 아니라 이미 빌드가 끝난 결과물이다.
배포 단계에서는 artifact를 서버로 복사했다.
deploy_project_sandbox:
stage: deploy
image: alpine:3.20
needs:
- build_project_sandbox
dependencies:
- build_project_sandbox
environment:
name: project_sandbox
rules:
- if: '$CI_COMMIT_TAG =~ /^sandbox-/'
여기서 중요한 건 기존 글에 있던:
build:
/^sandbox-/
deploy:
/^project-/
처럼 서로 다른 태그 조건을 쓰지 않는 것이다.
샌드박스 파이프라인이라면 sandbox-*로 통일했다.
실제 복사는 rsync로 했다.
rsync -az --delete \
project/.next/standalone/ \
$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/project/.next/standalone/
rsync -az --delete \
project/.next/static/ \
$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/project/.next/static/
rsync -az --delete \
project/public/ \
$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/project/public/
복사가 끝나면 PM2를 reload한다.
pm2 reload ecosystem.config.js \
--only project-sandbox
없으면 start한다.
pm2 start ecosystem.config.js \
--only project-sandbox
지금 보면 단순한 구조인데, 첫 자동 배포로는 충분했다.
CI 컨테이너에서 서버로 들어가려면 SSH 키가 필요했다.
GitLab Variables에:
SSH_PRIVATE_KEY
DEPLOY_HOST
DEPLOY_USER
DEPLOY_PATH
를 넣었다.
서버에는 공개키를 등록했다.
~/.ssh 700
~/.ssh/authorized_keys 600
그런데 키 자체보다 개행이나 Variable scope 때문에 실패하는 경우가 더 많았다.
키는 멀쩡한데:
Permission denied (publickey)
가 나오면 서버 설정부터 보게 된다.
실제로는 GitLab Variable이 해당 environment에서 안 보이거나, private key 개행이 깨진 경우도 있었다.
이후에는 키 문제를 볼 때:
서버 공개키
GitLab Variable scope
개행
파일 권한
을 같이 본다.
PM2 프로세스를 잘못 중복 실행하면:
EADDRINUSE
가 났다.
이 단계에서 샌드박스는 일부러 instances: 1로 두었다.
새 프로세스를 무작정 start하기보다 기존 프로세스를 reload하는 걸 기본으로 잡았다.
pm2 reload ... || pm2 start ...
배포 자동화를 만들면서 느낀 건 실행 명령 자체보다 같은 명령을 두 번 실행했을 때 어떻게 되는지가 더 중요하다는 것이었다.
CI는 사람이 한 번 누르고 끝나는 스크립트가 아니다.
언젠가는 다시 실행된다.
첫 버전의 CI/CD는 이 정도였다.
sandbox-* tag
│
▼
Vitest
│
│ 실패 → 종료
▼
Next.js standalone build
│
▼
GitLab Artifact
│
▼
rsync / SSH
│
▼
PM2 reload
│
▼
project-sandbox
처음 목적은 달성했다.
테스트가 깨지면 서버까지 갈 수 없게 됐다.
그리고 개발자가 서버에 들어가서 파일을 직접 덮어쓰고 PM2를 재시작하는 일도 없어졌다.
샌드박스에서는 rsync + PM2로 충분했다.
운영까지 확대하려고 보니 조금씩 마음에 안 드는 부분이 생겼다.
서버에 실제로 어떤 버전의 파일이 올라가 있는지 확인하기가 애매했다.
배포 중 파일이 교체되는 방식도 마음에 걸렸다.
롤백하려면 결국 이전 artifact나 파일을 다시 가져와야 했다.
Node, PM2 같은 실행 환경도 서버에 직접 관리해야 했다.
무엇보다 CI에서 만든 결과물을 하나의 불변 단위로 배포하고 싶었다.
그래서 다음 목표를 적어뒀다.
rsync / scp
↓
Docker Image
PM2
↓
Docker Swarm
파일 버전
↓
Commit SHA Image
Teams 알림도 아직 붙이지 않은 상태였다.
이 두 가지가 다음 작업이 됐다.
파일을 배포하지 않고 이미지를 배포하기.
그리고 그 과정에서 ENV, Kaniko, Swarm health check까지 생각보다 많이 걸렸다.
그건 2편에서 이어진다.