GitLab CI/CD 구축

임기호·2025년 8월 15일

DevOps

목록 보기
1/2

CI/CD 환경 구축 — 테스트가 통과해야 배포한다

배포 사고라고 하기에는 사소했다.

테스트하려고 함수 중간에 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를 붙이는 단계에서는 이 정도의 수동 장치가 오히려 마음이 편했다.


빌드는 서버가 아니라 CI에서 끝냈다

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로 넘긴다.

서버가 받는 건 소스가 아니라 이미 빌드가 끝난 결과물이다.


첫 배포 방식은 그냥 rsync였다

배포 단계에서는 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

지금 보면 단순한 구조인데, 첫 자동 배포로는 충분했다.


SSH 키에서 생각보다 많이 막혔다

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편에서 이어진다.

0개의 댓글