노트북에서는 시각 회귀 테스트가 전부 통과합니다. 그런데 push하고 파이프라인이 돌면, 같은 테스트가 브라우저를 조용히 죽여 버리거나 실제로는 안티앨리어싱 차이일 뿐인 “변경” 50건을 잡아냅니다. 코드는 하나도 바뀌지 않았는데 CI만 다르게 말합니다.
“로컬에서는 된다”와 “CI에서도 된다” 사이의 이 간극은 거의 언제나 테스트 문제가 아니라 인프라 문제입니다. CI 파이프라인에서 시각 회귀 테스트를 안정적으로 셀프 호스팅하려면 네 가지를 제대로 연결해야 합니다. Docker executor를 쓰는 CI 러너, headless Chromium이 공유 메모리를 다 쓰지 않도록 설정한 컨테이너, CI가 읽을 수 있는 리포트를 내보내도록 구성한 비교 도구, 그리고 headless 브라우저가 실제로 소비하는 메모리에 맞춰 사양을 잡은 VPS입니다.
이 가이드는 그 스택을 처음부터 끝까지 만들어 갑니다. 다 읽고 나면 동작하는 .gitlab-ci.yml(또는 Gitea 버전), Chromium이 죽지 않는 Docker 환경, 파이프라인이 읽을 수 있는 JUnit 리포트를 만들어 내는 BackstopJS, 그리고 이 모든 것을 돌릴 적정 사양의 VPS를 갖추게 됩니다.
요약
- 로컬과 CI에서 동일한 Docker 이미지로 브라우저를 실행하십시오. 대부분의 거짓 양성 diff는 환경 간 폰트와 렌더링 차이에서 나옵니다. 이미지를 고정하면 사라집니다.
- 64MB로 잡힌 /dev/shm 기본값을 고치십시오. Docker의 기본 공유 메모리 크기 는 Chromium 렌더러를 굶겨서 CI에서 아무 말 없이 죽게 만듭니다. docker-compose.yml에 shm_size: '2gb'를 설정하거나 Chromium에 --disable-dev-shm-usage를 전달하십시오.
- 셀프 호스팅 도구로는 BackstopJS를 우선 쓰십시오. MIT 라이선스(v6.3.25)이고, --docker 플래그로 Docker를 우선 지원하며, JUnit 리포트를 내보냅니다. 이미 Playwright를 쓰고 있다면 내장된 toHaveScreenshot()도 설치 없이 시작하기 좋은 출발점입니다.
- 기준 사양은 4GB RAM으로 잡으십시오. 메모리는 넉넉하게 잡으십시오. 실제 CI 실행에서 Chromium 스크린샷 작업은 전체 페이지를 캡처하는 동안 유휴 상태에서 필요해 보이는 양보다 훨씬 많은 메모리를 씁니다. 순차 실행이나 가벼운 병렬 실행으로 작업 1~2개를 돌린다면 4GB RAM이 더 안전한 기준입니다.
이 가이드에서 다루지 않는 내용
이 글은 이미 구축하기로 마음먹은 인프라를 위한 구축 가이드입니다. 몇 가지는 의도적으로 범위 밖에 두었습니다.
- 전체 도구 비교가 아닙니다. 이 가이드는 셀프 호스팅 CI 환경에서 실제로 운영할 수 있는 스택, 즉 BackstopJS와 Docker, 그리고 직접 운영하는 러너에 집중합니다.
- Argos 셀프 호스팅은 다루지 않습니다. Argos는 오픈소스이지만, 공개된 제품 흐름과 문서가 간단한 프로덕션 셀프 호스팅 경로보다는 호스팅형 Argos 앱과 CI 연동에 초점을 맞추고 있습니다. 이 가이드의 목적에는 BackstopJS가 더 안전한 예제입니다.
- Lost Pixel은 권하지 않습니다. 이 프로젝트는 2026년 4월에 아카이브되었습니다. 새로 구축한다면 BackstopJS를 쓰십시오.
- 셀프 호스팅과 관리형 SaaS 중 무엇이 나은지는 다시 따지지 않습니다. 여기까지 왔다면 Percy나 Chromatic은 이미 후보에서 뺐다는 뜻이니까요.
필수 조건
첫 명령을 실행하기 전에 몇 가지가 준비되어 있어야 합니다.
- 셀프 호스팅 CI 러너, 또는 이를 배포할 계획(아래에서 다룹니다).
- 러너 호스트에 설치된 Docker.
- 테스트 대상이 이미 있는 Node.js 프로젝트(실행 중인 앱 URL 또는 캡처할 컴포넌트 라우트 목록).
- 러너가 올라가 있는 VPS 또는 호스트에 대한 셸 접근 권한.
시각 테스트가 로컬에서는 통과하고 CI에서는 깨지는 이유
macOS에서 기준 이미지를 렌더링한 브라우저와 Linux CI 컨테이너에서 비교 이미지를 렌더링하는 브라우저는 같은 브라우저가 아닙니다. Playwright 공식 문서도 이 점을 직설적으로 말합니다. 브라우저 렌더링은 호스트 OS, 버전, 설정, 하드웨어, 전원 상태, headless 모드 등 여러 요인에 따라 달라질 수 있습니다. 권고 역시 똑같이 단순합니다. 일관된 스크린샷을 원한다면 기준 스크린샷을 만든 환경과 동일한 환경에서 테스트를 실행하십시오.
이 권고 한 줄이 나머지 설정이 존재하는 이유입니다. 거짓 실패의 거의 전부는 세 가지 불일치에서 나옵니다.
- 폰트와 안티앨리어싱 차이. 개발 머신과 CI 컨테이너는 서로 다른 폰트 패키지와 서브픽셀 렌더링을 씁니다. 텍스트가 많은 페이지는 아무것도 바뀌지 않아도 매번 diff가 납니다.
- headless와 headed 렌더링의 차이. headed 브라우저와 headless 브라우저는 같은 페이지를 조금씩 다르게 배치할 수 있습니다.
- 공유 메모리 고갈. 이건 조용히 터지는 쪽입니다. Docker의 기본 /dev/shm은 64MB인데, headless Chromium은 렌더러 프로세스에 공유 메모리를 씁니다. 이게 바닥나면 Chromium은 쓸 만한 에러 메시지도 없이 죽습니다. 작업은 그냥 종료되거나 멈춰 버립니다.
앞의 둘은 기준 이미지 생성과 비교 모두에 하나의 Docker 이미지를 고정해서 해결합니다. 세 번째는 아래 Docker 설정 절에서 해결합니다. 셋 다 잡고 나면 “CI에서 불안정하던” 테스트가 결정론적으로 바뀝니다.

도구 선택: BackstopJS냐 Playwright 내장 비교냐
이미 엔드투엔드 테스트에 Playwright를 쓰고 있다면 가장 빠른 길은 내장 toHaveScreenshot() 어서션입니다. 추가 의존성이 없고, 픽셀 단위 비교를 하며, 플랫폼 접미사가 붙은 기준 이미지를 기본으로 제공합니다. 작은 테스트 스위트라면 여기서 멈춰도 충분하고, 공유 컴포넌트 라이브러리 없이 화면이 대략 50개 미만인 팀이라면 평생 이것만으로도 될 수 있습니다.
한계에 부딪히는 지점은 리뷰 워크플로입니다. Playwright의 비교는 픽셀 단위뿐이고 PR 리뷰 UI가 없어서, 정상적인 시각 변경 묶음을 승인하려면 스냅샷을 손으로 다시 만들어야 합니다. 기준 이미지 개수가 늘어나면 이 수동 승인 루프가 고통스러워집니다.
BackstopJS 는 바로 그 루프를 위해 만들어진 전용 도구입니다. MIT 라이선스(현재 v6.3.25)이고, --docker 플래그로 Docker를 우선 지원하며, CI 러너가 그대로 읽는 JUnit XML을 내보냅니다. 기준 이미지 워크플로(reference 생성, 그에 대한 test, 받아들인 diff를 approve해 새 기준으로 확정)는 셀프 호스팅 VRT 구성이 결국 원하게 되는 운영 모델입니다. 이 가이드의 나머지 부분에서는 BackstopJS를 예제로 씁니다.
다른 글에서 추천받았을 법한 두 도구는 이 안내에서 제외했습니다. Argos는 공개된 제품 흐름이 호스팅 앱과 CI 연동 중심이라 다루지 않고, Lost Pixel은 아카이브되었습니다. 실용적인 셀프 호스팅 구성에는 BackstopJS가 더 안전한 예제입니다.
| 축 | BackstopJS | Playwright toHaveScreenshot() |
|---|---|---|
| PR 리뷰 워크플로 | 내장 리포트와 approve 명령 | 수동 스냅샷 재생성 |
| 기준 이미지 관리 | reference / test / approve 명령 | 테스트별 스냅샷 파일 |
| Docker로 표준화된 렌더링 | --docker 플래그 | 자체 이미지 가져오기 |
| 설치 부담 | 별도 의존성 | Playwright를 쓴다면 이미 설치되어 있음 |
더 많은 도구와 기준을 놓고 비교한 내용이 필요하다면 BackstopJS, Argos, Lost Pixel 비교 에서 선택 과정을 전부 다룹니다.
VPS에 셀프 호스팅 CI 러너 배포하기
렌더링 일관성을 실제로 만들어 주는 것은 Docker executor입니다. 러너가 각 작업을 여러분이 정의한 컨테이너 안에서 실행하므로 브라우저 환경이 매번 동일합니다. 러너 플랫폼은 그 외에 어디까지 맡기고 싶은지에 따라 고르십시오.
셀프 호스팅 GitLab CE는 무거운 쪽입니다. 저장소, CI/CD, 레지스트리, 이슈, 머지 리퀘스트를 한 인스턴스에 담습니다. 이 가이드에서 중요한 부분은 GitLab Runner의 Docker executor입니다. 덕분에 모든 시각 회귀 작업이 매번 동일하게 고정된 컨테이너 이미지 안에서 실행됩니다. GitLab, Gitea, Jenkins, Forgejo, Portainer, Docker는 모두 Cloudzy 마켓플레이스에서 원클릭 배포로 제공되므로 설정 시간이 크게 줄어듭니다.
act-runner를 사용하는 Gitea 조합은 가벼운 대안입니다. Gitea는 Go로 만든 Git 서비스이며 act-runner를 통해 GitHub Actions와 호환되는 워크플로 엔진을 제공합니다. 팀이 Actions 문법에 익숙하다면 작고 빠르게 띄울 수 있는 러너입니다.
어느 쪽을 고르든 설정 목표는 같습니다. 작업을 받아 Docker executor로 실행하는 러너를 만드는 것입니다. 이후에 나오는 모든 내용(컨테이너 설정, BackstopJS, 샘플 파이프라인)은 그 executor가 준비되어 있다고 전제합니다.
Chromium이 죽지 않도록 Docker 설정하기
사람들이 시간을 가장 많이 뺏기는 실패가 바로 이것입니다. Docker는 기본적으로 /dev/shm을 64MB로 마운트합니다. headless Chromium은 렌더러 프로세스에 이 공유 메모리 파티션을 쓰는데, 전체 페이지 스크린샷을 찍을 때는 64MB보다 훨씬 많이 필요합니다. 바닥나면 렌더러가 죽고, 대개 공유 메모리를 가리키는 에러 메시지조차 남지 않습니다. 작업은 멈추거나 타임아웃이 나거나 그냥 브라우저 크래시라고만 보고됩니다.
해결책은 두 가지이고, 둘 중 아무거나 써도 됩니다.
해결책 A: Compose에서 공유 메모리 크기를 올린다. shm_size 키는 컨테이너의 /dev/shm 파티션 크기를 설정합니다. Docker Compose 파일 레퍼런스 는 shm_size가 서비스 컨테이너에 허용되는 공유 메모리 크기를 설정한다고 명시합니다. 서비스 블록에 다음과 같이 지정하십시오:
# docker-compose.yml
services:
vrt:
image: backstopjs/backstopjs:6.3.25
shm_size: '2gb' # override the 64MB default
volumes:
- ./:/src
working_dir: /src
해결책 B: Chromium이 아예 /dev/shm을 쓰지 않게 한다. --disable-dev-shm-usage 플래그를 주면 Chromium이 공유 메모리 파티션 대신 /tmp에 공유 메모리 파일을 씁니다. 브라우저 실행 인자에 넣으십시오. backstop.json 설정에서는 engineOptions, Playwright에서는 launchOptions입니다:
// backstop.json (fragment)
{
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Compose 파일을 직접 관리한다면 해결책 A가 깔끔하고, 브라우저 실행 플래그만 건드릴 수 있다면 해결책 B가 이식성이 좋습니다. 둘 다 적용해도 문제없습니다.
팁: 로컬과 CI에서 완전히 동일한 Docker 이미지를 쓰십시오. BackstopJS는 --docker 플래그 하나로 이걸 공짜로 해 줍니다. 고정된 레퍼런스 이미지 안에서 캡처가 이뤄지므로 개발 머신과 러너의 렌더링 결과가 같아집니다. 폰트와 안티앨리어싱 때문에 생기는 거짓 양성이 사라지는 이유가 이것입니다. 이미지를 한 번 고정해 두면 유령 diff를 쫓아다닐 일이 없어집니다.

CI용 BackstopJS 설정하기
BackstopJS는 CI 러너가 읽어 빌드의 성공/실패를 판정할 수 있는 JUnit XML 리포트를 만듭니다. 손으로 돌리지 않고 파이프라인에 연결하는 이유가 바로 이것입니다. 설치하고 설정을 초기화하십시오:
npm install --save-dev backstopjs
npx backstop init
그다음 backstop.json이 캡처할 페이지를 가리키도록 하고 CI 리포트를 켜십시오:
// backstop.json (fragment)
{
"id": "my_app_vrt",
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 }
],
"scenarios": [
{
"label": "Homepage",
"url": "http://app:3000/",
"selectors": ["document"]
}
],
"report": ["CI"],
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
JUnit XML을 내보내는 것은 CI 리포트 설정입니다. 호스트 머신에서 로컬로 이 명령들을 실행할 때는 --docker 플래그를 쓰십시오. 이미 backstopjs/backstopjs 이미지를 쓰는 CI 작업 안에서는 backstop test를 바로 실행하면 됩니다. 기준 이미지 워크플로는 명령 세 개입니다:
npx backstop reference --docker # capture the approved baseline
npx backstop test --docker # run the comparison, emit the report
npx backstop approve # accept the current diffs as the new baseline
기준 이미지 관리에 관해 한 가지 짚어 둘 점이 있습니다. 정상적인 UI 변경이 있을 때마다 approve를 실행해 새 스크린샷을 확정해야 합니다. 시나리오가 100개쯤을 넘어가면 이 승인 단계는 실제 운영 비용이 됩니다. 누군가는 diff를 눈으로 확인하고 어느 것이 의도된 변경인지 판단해야 하며, 그 부담은 도입한 팀 규모에 비례해 커집니다. 이 유지보수가 대규모 시각 회귀 테스트의 진짜 가격이므로, 큰 스위트를 만들기 전에 미리 감안해 두는 편이 좋습니다.
동작하는 CI 파이프라인 설정
파이프라인은 논리적으로 두 부분으로 나뉩니다. 기준 이미지를 한 번(또는 필요할 때) 만들고, 변경이 생길 때마다 그것과 비교합니다. 아래 예제는 Docker executor를 쓰는 GitLab CI 설정이며, 공유 메모리 문제는 backstop.json의 실행 플래그로 이미 해결해 두었습니다.
# .gitlab-ci.yml
stages:
- visual-regression
visual_regression:
stage: visual-regression
image:
name: backstopjs/backstopjs:6.3.25
entrypoint: [""]
script:
- backstop test
artifacts:
when: always
reports:
junit: backstop_data/ci_report/xunit.xml
paths:
- backstop_data/bitmaps_test
- backstop_data/html_report
expire_in: 1 week
junit 줄은 리포트를 GitLab에 넘겨 실패가 머지 리퀘스트 UI에 표시되게 하고, paths 줄은 diff 비트맵을 남겨 실제로 무엇이 바뀌었는지 볼 수 있게 합니다. act-runner를 쓰는 Gitea에서는 같은 단계가 Actions 방식 워크플로로 그대로 옮겨 갑니다:
# .gitea/workflows/visual-regression.yaml
name: Visual Regression
on: [push, pull_request]
jobs:
vrt:
runs-on: ubuntu-latest
container:
image: backstopjs/backstopjs:6.3.25
options: --shm-size=2gb # raise /dev/shm for the job container
steps:
- uses: actions/checkout@v4
- run: backstop test
Gitea 워크플로의 --shm-size=2gb 컨테이너 옵션을 눈여겨보십시오. 해결책 A와 동일한 공유 메모리 조치를, 수정할 Compose 파일이 따로 없는 작업 컨테이너 수준에서 적용한 것입니다.
headless Chromium에 맞는 VPS 사양 정하기
여기서 병목은 CPU가 아니라 RAM입니다. headless Chromium의 CPU 사용은 순간적으로 튀는 형태라(캡처할 때 치솟고 그 사이에는 놉니다) 코어 수는 적당해도 충분히 따라갑니다. 동시에 몇 개의 작업을 돌릴 수 있는지를 결정하는 상한은 메모리입니다. headless Chromium 프로세스는 유휴 상태에서 대략 300–500MB를 쓰고, 전체 페이지 스크린샷을 캡처하는 동안 1–2GB까지 올라갑니다. 따라서 사양을 결정하는 기준은 동시 작업당 RAM입니다.
| RAM | 적합한 용도 |
|---|---|
| 2GB | 빠듯한 최소 사양: 순차 작업 1개 |
| 4GB | 권장 기준: 동시 작업 1–2개 |
| 8GB | 동시 작업 3–4개 |
| 16GB | 대규모 스위트와 병렬 파이프라인 |
이 수치는 벤더 벤치마크가 아니라 실제 CI 실행에서 얻은 현장 경험치입니다. 대략적인 출발점으로 삼고, 직접 최대 메모리 사용량을 확인하십시오.
이 절의 핵심: 믿을 만한 기준은 4GB RAM이며, 동시 실행하는 시각 회귀 작업이 하나 늘 때마다 대략 2GB의 여유를 더하십시오.
셀프 호스팅 VRT의 함정은 메모리가 부족한 러너가 요란하게 실패해 주지 않는다는 점입니다. 대신 Chromium이 조용히 죽어버리는데, 이 구성 전체가 막으려던 바로 그 증상입니다. 메모리 여유를 넉넉히 잡아 두는 것이 가장 값싼 보험입니다. 러너 호스트를 처음부터 직접 구축하고 싶지 않다면 Cloudzy 마켓플레이스의 원클릭 배포를 이용할 수 있습니다. 셀프 호스팅 GitLab 와 셀프 호스팅 Gitea 을 원클릭으로 배포할 수 있습니다. 몇 분이면 CI 인스턴스가 돌아가고, VPS 사양은 여러분의 스위트가 필요로 하는 headless Chromium 여유 메모리에 정확히 맞춰 고를 수 있습니다.

자주 묻는 질문
CI/CD 파이프라인에 시각 회귀 테스트를 어떻게 구성하나요?
BackstopJS를 설치하고, backstop.json이 대상 페이지를 가리키게 한 뒤, JUnit XML을 내보내도록 CI 리포트 옵션을 켜십시오. 로컬 머신에서 실행할 때는 backstop reference --docker와 backstop test --docker를 써서 고정된 Docker 이미지 안에서 스크린샷이 렌더링되게 하십시오. CI에서는 backstopjs/backstopjs 이미지 안에서 작업을 실행하고 backstop test를 바로 호출한 다음, backstop_data/ci_report/xunit.xml의 JUnit 리포트를 게시하십시오. 러너는 Docker executor를 쓰는 셀프 호스팅 러너로 하고, headless Chromium을 위해 최소 4GB RAM으로 잡으십시오.
CI 컨테이너에서 headless Chromium은 RAM이 얼마나 필요한가요?
headless Chromium 프로세스는 유휴 상태에서 대략 300–500MB를 쓰고, 전체 페이지 스크린샷을 캡처하는 동안 1–2GB까지 치솟습니다. CI를 안정적으로 돌리려면 동시 작업 1~2개당 약 4GB RAM을 잡고, 병렬 작업이 하나 늘 때마다 대략 2GB를 더하십시오. CPU는 순간적으로 튈 뿐 병목이 아니며, 동시에 몇 개의 작업을 돌릴 수 있는지는 RAM이 결정합니다.
시각 회귀 테스트가 왜 로컬에서는 통과하고 CI에서는 실패하나요?
브라우저 렌더링은 환경마다 다릅니다. 폰트, 안티앨리어싱, headless와 headed 모드, OS가 모두 결과물을 바꿉니다. 해결책은 기준 이미지 생성과 비교를 동일한 Docker 이미지에서 실행해 양쪽이 똑같이 렌더링되게 하는 것입니다. BackstopJS의 --docker 플래그는 고정된 레퍼런스 이미지 안에서 캡처를 실행해 이를 해결합니다.
Chromium Docker CI에서 /dev/shm 오류는 어떻게 고치나요?
Docker의 기본 /dev/shm은 64MB로, headless Chromium 렌더러에는 너무 작아 조용한 크래시를 일으킵니다. docker-compose.yml에서 shm_size: '2gb'로 올리거나(작업 컨테이너에는 --shm-size=2gb), Chromium 실행 플래그에 --disable-dev-shm-usage를 넣어 공유 메모리 파일을 /tmp에 쓰게 하십시오.
Playwright toHaveScreenshot을 써야 하나요, 전용 시각 회귀 도구를 써야 하나요?
Playwright에 내장된 toHaveScreenshot()은 작은 스위트에는 충분합니다. 화면이 대략 50개 미만이고, 공유 컴포넌트 라이브러리가 없으며, PR 리뷰 UI가 필요 없는 경우입니다. 제대로 된 기준 이미지 승인 워크플로, 리뷰 리포트, Docker로 표준화된 렌더링이 필요해지면 BackstopJS 같은 전용 도구로 옮기십시오. 기준 이미지 개수가 늘어날수록 이 부분이 중요해집니다.
Lost Pixel은 아직 유지보수되나요?
아닙니다. Lost Pixel은 2026년 4월에 아카이브되었고 새로 도입할 후보가 아닙니다. 셀프 호스팅 시각 회귀 테스트 구성에는 BackstopJS를 쓰십시오.