수동 배포를 하다 보면 실수가 생긴다. 빌드를 까먹거나, 테스트를 안 돌리거나, 잘못된 브랜치를 배포하거나. GitLab CI/CD를 쓰면 코드를 push할 때 자동으로 테스트 → 빌드 → 배포까지 돌릴 수 있다. .gitlab-ci.yml 파일 하나면 된다.

기본 구조

프로젝트 루트에 .gitlab-ci.yml을 만든다:

stages:
  - test
  - build
  - deploy

variables:
  NODE_VERSION: "20"

test:
  stage: test
  image: node:${NODE_VERSION}-alpine
  script:
    - npm ci
    - npm run lint
    - npm test
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/

build:
  stage: build
  image: node:${NODE_VERSION}-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour
  only:
    - main
    - develop

deploy:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache rsync openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
    - rsync -avz --delete dist/ $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/
  only:
    - main
  when: manual

stages에서 순서를 정의하고, 각 job이 어떤 stage에 속하는지 지정한다. 같은 stage의 job은 병렬로 실행된다.

환경변수 설정

SSH 키, 서버 주소 같은 민감한 정보는 절대 yml 파일에 넣으면 안 된다. GitLab에서:

Settings → CI/CD → Variables에서 추가:

  • SSH_PRIVATE_KEY: 배포용 SSH 개인 키 (Type: File 또는 Variable, Protected + Masked)
  • DEPLOY_HOST: 서버 주소 (예: 192.168.1.100)
  • DEPLOY_USER: SSH 사용자명
  • DEPLOY_PATH: 배포 경로

Protected를 체크하면 protected 브랜치에서만 사용 가능하고, Masked를 체크하면 로그에 값이 노출되지 않는다.

Docker 이미지 빌드 + 배포

컨테이너 기반으로 배포한다면:

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

deploy-docker:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
    - ssh $DEPLOY_USER@$DEPLOY_HOST "
        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY &&
        docker pull $CI_REGISTRY_IMAGE:latest &&
        cd /srv/app && docker compose up -d --no-deps app
      "
  only:
    - main
  when: manual

GitLab의 Container Registry를 활용하면 이미지 저장소를 따로 만들 필요가 없다. $CI_REGISTRY 같은 변수는 GitLab이 자동으로 제공한다.

GitLab Runner 설치

self-hosted GitLab을 쓰고 있다면 Runner를 직접 설치해야 한다:

# Docker 기반 Runner 설치
docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest

Runner 등록:

docker exec -it gitlab-runner gitlab-runner register

URL과 토큰을 물어보는데, GitLab 프로젝트 Settings → CI/CD → Runners에서 확인할 수 있다. executor는 docker를 선택하고, 기본 이미지는 alpine:latest를 넣으면 된다.

캐시와 아티팩트

빌드 시간을 줄이려면 캐시를 활용한다. node_modules를 캐싱하면 npm ci 시간이 확 줄어든다:

cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/

package-lock.json이 바뀔 때만 캐시가 갱신된다. 아티팩트는 stage 간에 파일을 전달할 때 쓴다. build에서 만든 dist/를 deploy에서 사용하는 식이다.

브랜치별 전략

개발 흐름에 맞게 파이프라인을 분기할 수 있다:

deploy-staging:
  stage: deploy
  script:
    - echo "Deploying to staging..."
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop

deploy-production:
  stage: deploy
  script:
    - echo "Deploying to production..."
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual

when: manual은 파이프라인에서 수동으로 클릭해야 실행된다. 프로덕션 배포는 실수 방지를 위해 수동으로 해두는 게 안전하다. environment를 설정하면 GitLab UI에서 배포 현황을 볼 수 있다.

CI/CD를 한번 세팅하면 배포 스트레스가 사라진다. push만 하면 나머지는 파이프라인이 알아서 하니까.