로컬에선 그대로인데 CI에서만 25초 빨라졌습니다

2026-09-18 · 19 min · #vitest #testing #ci #performance

DOM을 안 쓰는 테스트 파일 121개가 파일마다 jsdom을 부팅하던 낭비를 Vitest 프로젝트 분리로 걷어냈습니다. 10코어 로컬에서는 wall time이 그대로였는데 4-vCPU CI에서만 실행당 25초가 줄어든 이유를 실측으로 따라갑니다.

로컬에선 그대로인데 CI에서만 25초 빨라졌습니다

이 글을 읽고 나면

  • wall time(경과 시간)과 total compute(총 연산량)가 왜 다른 숫자인지, 어느 쪽을 줄였는지 구분할 수 있습니다
  • Vitest 프로젝트 분리로 jsdom 부팅 낭비를 걷어내고, 쪼갠 뒤 "하나도 안 빠졌다"를 증명하는 방법을 배웁니다
  • 최적화 효과를 어디서 측정해야 하는지, 그리고 그 다음 단계(샤딩)는 언제 꺼내는 도구인지 알게 됩니다

들어가며

같은 diff를 두 군데서 쟀는데 결과가 서로 반대였습니다.

최적화 전최적화 후
로컬 테스트 시간97.5초117.8초

숫자만 보면 오히려 느려졌습니다. 이 표만 보고 있을 때는 이 PR이 실패인 줄 알았습니다. 그런데 머지된 뒤 CI에서는 매 실행 25초씩 꾸준히 줄었습니다. 이런 최적화가 어떻게 가능한지, 그리고 그렇다면 어느 쪽 숫자를 믿어야 하는지가 이 글의 내용입니다.

facebook/astryx(메타가 오픈소스로 운영하는 내부 도구용 디자인 시스템)에 PR 17개를 머지시키며 남긴 기록 중 하나입니다. 근거 PR은 #3814이고, 2026년 7월 11일 머지됐습니다.

경과 시간보다 큰 오버헤드

astryx는 pnpm 모노레포이고 테스트는 Vitest입니다. 전체 테스트를 돌리던 어느 날, 요약 줄의 숫자가 눈에 걸렸습니다.

text
Duration 97.5s (environment 180s, setup 27s, ...)

전체 실행이 97.5초인데 environment가 180초. 처음엔 버그인 줄 알았습니다. 이 숫자는 파일마다 jsdom을 띄운 시간을 전부 더한 값입니다. 워커가 열 개 가까이 돌아서 같은 순간에 jsdom도 그만큼 뜨고 있습니다. 경과 시간에는 그중 한 개분만 잡히고, 합산에는 전부 들어갑니다.

합산값이라 해도 낭비인 건 같습니다. 매 실행마다 jsdom 부팅에만 CPU 시간 180초를 쓰고 있었습니다. jest-dom setup 27초는 덤입니다.

테스트 파일은 약 316개인데, 전부 루트 vitest 설정 하나로 돌고 있었습니다. 루트 설정은 모든 파일에 fresh jsdom 인스턴스를 띄우고, React/StyleX babel 변환을 적용하고, jest-dom 매처를 주입합니다. 컴포넌트 테스트라면 전부 필요한 것들입니다. 문제는 그중 121개 파일이 DOM을 전혀 안 쓴다는 점이었습니다. CLI, 빌드 도구, scripts, 내부 유틸 테스트들이 파일마다 jsdom을 부팅하고 babel 변환까지 통과하고 있었습니다.

항목node 전용 파일 1개당 비용 (파일 하나만 돌렸을 때)
environment (jsdom 부팅)283ms
setup (jest-dom)192ms
transform (StyleX babel)173ms

파일 하나엔 밀리초지만, 121개 × 매 실행이면 이야기가 다릅니다. 순수하게 버려지는 연산입니다.

프로젝트 2개로 나누고, 빠진 것이 없음을 증명합니다

Vitest workspace로 프로젝트를 2개로 나눴습니다.

프로젝트범위environment플러그인
uipackages/core + packages/lab (195파일)jsdomStyleX babel + jest-dom setup
node나머지 전부 (121파일)node없음

머지된 설정의 뼈대만 발췌하면 이렇습니다.

vitest.workspace.ts
export default defineWorkspace([
  {
    extends: './vitest.config.ts',
    test: { name: 'ui', include: ['packages/core/src/**/*.test.{ts,tsx,mjs}', 'packages/lab/src/**/*.test.{ts,tsx,mjs}'] },
  },
  {
    test: {
      name: 'node',
      environment: 'node',
      include: ['packages/**/src/**/*.test.{ts,tsx,mjs}', 'internal/**/*.test.{ts,tsx,mjs}', 'scripts/**/*.test.{ts,tsx,mjs}'],
      exclude: [...configDefaults.exclude, 'packages/core/**', 'packages/lab/**'],
    },
  },
]);

defineWorkspace는 Vitest 3.2에서 deprecated, 4에서 제거되었습니다. 지금 따라 하신다면 루트 설정의 test.projects를 쓰면 됩니다. astryx도 이후 그렇게 마이그레이션했습니다.

node 쪽 include가 명시 목록이 아니라 "루트와 같은 glob에서 core/lab을 뺀 나머지"인 이유는, 새 패키지가 어느 목록에도 안 들어가서 테스트가 CI에서 안 도는 일을 막기 위해서입니다. 새 패키지는 손대지 않으면 node로 들어가고, DOM이 필요하면 거기서 바로 실패합니다.

하나 걸리는 게 있습니다. extends는 배열을 덮어쓰지 않고 병합합니다. 루트 설정에 include를 남겨 두면 ui가 그 목록까지 물려받아 node 파일 121개를 한 번 더 돌립니다. include는 루트에서 들어내고 각 프로젝트에만 둬야 합니다.

이 중복은 분리 전후를 대조하다 드러났습니다. 스위트를 쪼갤 때 성능보다 먼저 확인할 것은 테스트가 빠지지 않았는가입니다. JSON 리포터로 분리 전후의 실행 결과를 덤프해 diff를 떴습니다.

sh
vitest run --reporter=json --outputFile=before.json   # 분리 전 트리
vitest run --reporter=json --outputFile=after.json    # 분리 후

두 파일에서 testResults[].name(파일)과 assertionResults[].fullName(테스트)을 정렬해 비교하면 됩니다. 파일은 양쪽 모두 316개, 테스트도 양쪽 모두 5,893개. 누락 0, 중복 0. 분할 PR에서는 이 대조가 벤치마크보다 먼저라고 생각합니다.

node 프로젝트에 worker threads 풀이나 isolate: false를 쓰면 더 줄어들 것 같아서 둘 다 돌려 봤습니다. threads는 process.chdir()를 쓰는 CLI 테스트 63개가 실패했고(worker thread에서는 chdir를 지원하지 않습니다), isolate: false는 파일 사이에 상태가 남아 2,138개가 실패했습니다. 안 쓰기로 한 이유도 이 숫자와 함께 PR 본문에 적어 뒀습니다.

wall time이 아니라 total compute였습니다

여기까지 하고 벤치마크를 돌렸습니다. 그리고 서두의 그 표가 나왔습니다. 97.5초 → 117.8초. jsdom 부팅 CPU 시간을 걷어냈는데 wall time은 오히려 20초 늘었습니다. 다만 같은 설정으로 여러 번 돌려도 20초 안팎으로 흔들려서, 이 차이는 노이즈 범위 안입니다.

그런데 node 프로젝트만 따로 재면 숫자가 다릅니다.

시나리오이전이후
node 범위만 실행 (121파일 / 1,791테스트)61.6초50.3초 (−18%)
단일 CLI 테스트 파일 (수정 → 재실행 루프)1.69초1.09초 (−35%)
node 실행의 environment 합계약 34초 (121 × 283ms)16ms
node 실행의 transform 합계33초3.5초

부분은 빨라졌는데 전체는 그대로였습니다. 이유는 워커 수입니다.

이 PR이 줄인 것은 wall time이 아니라 total compute, 실행당 worker CPU 시간 약 90~110초입니다. CI 실행의 요약 줄에서 environment와 setup 합산이 그만큼 줄었습니다. 워커 N개가 나눠 돌면 경과 시간에서 빠지는 양은 그 절약을 N으로 나눈 값입니다. 없앤 CPU 시간은 같아도 워커가 많을수록 시계에는 조금밖에 안 보입니다.

제 로컬은 M시리즈 10코어이고 maxWorkers는 기본값이라 워커가 열 개 가까이 뜹니다. 100초를 10으로 나누면 약 10초인데, 여러 번 돌려도 20초 안팎으로 흔들렸습니다. 효과가 노이즈보다 작아서 몇 번 돌려서는 안 보입니다.

2026년 7월 당시 main의 Deploy 워크플로우가 도는 GitHub Actions ubuntu 러너는 4 vCPU입니다(뒤에 나오는 PR CI는 다른 러너입니다). 같은 100초를 서너 개 워커로 나누면 25초에서 35초 사이입니다. 이 PR의 효과는 제 맥북이 아니라 거기서 재야 했습니다.

4-vCPU CI 러너에서 잰 숫자

astryx는 main에 머지될 때마다 Deploy 워크플로우가 돕니다. 그 안의 Run pnpm test 스텝 시간을 봤습니다. 같은 4-vCPU 러너, 같은 fresh checkout이고, 벤치마크가 아니라 실제 배포 실행입니다.

Run pnpm test (main, Deploy 워크플로우)
머지 전 9회 평균295.3초 (범위 290–302)
머지 후 9회 평균269.4초 (범위 214–286)
차이−25.9초 (−8.8%)

출처는 main 브랜치 Deploy 워크플로우의 실행 목록에서 머지(7월 11일) 앞뒤로 성공한 실행 9회씩입니다. 머지 커밋의 실행은 바로 다음 push에 취소돼, 머지 후 첫 실행은 그 다음 커밋입니다. 스텝 시간은 각 실행의 jobs API(started_at·completed_at)에서 뽑았고, 로그인 뒤 test job 로그에서도 볼 수 있습니다. 창을 20회씩이나 7월 915일 전체로 넓혀도 차이는 2226초입니다.

로컬에서 안 보이던 25초가 CI에서는 매 실행마다 나타났습니다. 평균만 준 게 아닙니다. 머지 후 가장 느린 실행(286초)이 머지 전 가장 빠른 실행(290초)보다 빠릅니다. 노이즈로는 이렇게 안 나옵니다.

다음 단계는 샤딩이었고, 채택되지 않았습니다

CI의 경과 시간은 가장 늦게 끝나는 job이 정합니다. 한 달 뒤 astryx의 PR CI에서 그 job은 test였습니다. 재push에서는 Next.js 캐시가 실제로 쓰이기 시작해 build-sandbox가 3분 30초로 내려가는 동안 test는 그대로였고, merge queue는 build-sandbox를 아예 돌리지 않습니다. 사람이 결과를 기다리는 이 두 경우에 test가 마지막에 끝났습니다. 스위트는 499개 파일 / 약 1만 테스트로 자랐고, 컴포넌트가 늘면 같이 늘어나는 유일한 job입니다. 제 기준에서는 여전히 느렸습니다.

앞 절의 나눗셈을 그대로 적용하면, 걷어낼 낭비가 없는 job의 경과 시간을 더 줄이는 방법은 나누는 수를 키우는 것뿐입니다. 그런데 test는 러너 한 대에서 돌고 있었고, PR CI의 러너는 2-core-ubuntu-arm, 코어가 둘입니다. ui 프로젝트를 단독으로 돌리면 이 러너에서 439초가 걸립니다. 러너를 여러 대 띄워 파일을 나눠 맡기는 것이 샤딩입니다. 그래서 test job을 나누는 제안 #4103을 올렸습니다.

올린 diff의 핵심만 발췌하면 이렇습니다. 프로젝트로 먼저 나누고, ui 안에서만 파일 해시로 둘로 나눕니다.

.github/workflows/ci.yml
test-project:
  name: test (${{ matrix.name }})
  runs-on: 2-core-ubuntu-arm
  strategy:
    fail-fast: false
    matrix:
      include:
        - name: ui 1/2
          project: ui
          shard: '1/2'
        - name: ui 2/2
          project: ui
          shard: '2/2'
        - name: node
          project: node
          shard: '1/1'
  steps:
    # checkout · setup · 저장소 동기화 검사(node 잡에서만) …
    - run: pnpm test --project=${{ matrix.project }} --shard=${{ matrix.shard }}

이 셋의 결과를 모으는 test job을 하나 더 두어 예전 체크 이름을 유지했습니다. 브랜치 보호 규칙이 그 이름을 보기 때문입니다.

이 방식으로 정하기까지 같은 러너에서 나누는 방식 세 가지를 콜드 push로 쟀습니다.

방식job별 pnpm testtest 체크러너 초 합계
499개 파일 전체를 해시로 3분할198 / 284 / 215초329초838초
프로젝트로만 분리 (ui / node)439 / 173초497초706초
ui 2분할 + node (이 PR)229 / 247 / 166초296초787초

출처는 순서대로 실행 31775879944, 31776898342, 31777749859입니다.

세 번 돌려 알게 된 것입니다.

--shard는 소요 시간을 모릅니다. 파일 경로의 sha1로 나누기 때문에, 3분할은 파일 수로는 167/166/166으로 고른데 실행 시간은 위 표처럼 벌어집니다. 1번과 3번 샤드는 프로젝트 구성이 거의 같은데도 17초 차이가 났습니다.

uinode는 크기가 다릅니다. 단독으로 돌리면 ui가 439초, node가 173초입니다. 프로젝트로만 나누면 일의 77%인 ui가 2코어에 통째로 남아서, 러너를 가장 적게 쓰고도 셋 중 가장 느립니다.

node는 한 잡으로 둡니다. core 패키지 빌드(globalSetup, 약 42초)는 node 프로젝트에만 붙어 있고 --project=ui는 그 빌드를 건너뜁니다. packages/core/dist를 지운 뒤 --project=ui로 돌리면 다시 생기지 않고, --project=node는 다시 만듭니다. node를 쪼개면 그 빌드를 샤드 수만큼 하게 되고, 쪼개지 않으면 node 잡이 이 방식의 바닥입니다 — 스텝 166초에 checkout·setup과 node 잡에서만 도는 저장소 동기화 검사를 더해 224초입니다(ui 잡의 오버헤드는 42·45초). ui를 셋으로 쪼개도 그 아래로는 못 내려갑니다.

ui를 둘로 쪼개면 합산 시간은 늘어납니다. 샤드 둘의 합은 476초로 단독 439초보다 큽니다. 샤드마다 vitest 기동과 StyleX 변환을 다시 하기 때문입니다. 둘로 나눈 게 고른 것도 지금 파일 집합에서만 그렇습니다. ui 파일 278개에 vitest의 해시를 다시 돌려 보면 측정된 일이 49.9% / 50.1%로 나뉘지만, 파일이 추가되거나 이름이 바뀌면 다시 섞이고, 같은 방법으로 셋으로 나누면 24% / 42% / 34%입니다.

결과는 콜드 첫 push에서 test 296초, build-sandbox 298초로 나란히 끝났습니다. merge queue에서는 build-sandbox가 없으니 test가 여전히 가장 긴 job이고, 다음인 build-storybook(193초)보다 약 100초 앞섭니다.

메인테이너는 진단과 설계가 맞다고 했고 승인까지 했지만, 몇 시간 뒤 PR을 닫았습니다. 닫으며 남긴 코멘트는 이렇습니다. PR을 연 뒤 ci.yml이 12번 바뀌어(a11y 게이트, pr-rtl 범위, 러너 이동) 사실상 다른 파일 위에 다시 얹어야 하고, 얻는 것은 CI 경과 시간인데 지금 그게 부족한 자원이 아니다. 틀린 것은 없고, CI 시간이 실제 문제가 되면 첫 번째로 꺼낼 변경이라고 했습니다.

샤딩은 언제 하면 될까요. 제 답은 이렇습니다.

  • CI 시간이 그 저장소에서 부족한 자원인지부터 봅니다. astryx는 아니었고, 그래서 이 PR은 닫혔습니다.
  • 재push나 merge queue에서 제일 늦게 끝나는 job이 test면 쪼갭니다. 초록불은 제일 늦은 job이 끝나야 켜지고, 이 두 상황이 사람이 그 초록불을 보고 있는 때입니다.
  • test가 제일 늦은 job이 아니게 되면 그만 쪼갭니다. 첫 push에서 test 296초, build-sandbox 298초가 된 뒤로는 test를 더 줄여도 초록불은 298초에 켜집니다. merge queue에서는 여전히 제일 늦지만, node 224초 바닥 때문에 더 쪼개도 얻는 게 적습니다.

마치며

여러분의 스위트에서는 이 여섯 가지를 확인하면 됩니다.

  • environment 항목vitest run 요약 줄에서 environment가 얼마나 큰가. 크면 DOM을 안 쓰는 파일이 jsdom을 띄우고 있다. Vitest 5부터는 비중이 크면 jsdom was created N times 진단 줄로 알려준다
  • 명시 목록은 한쪽만 — DOM이 필요한 프로젝트만 include를 적고, 나머지는 루트와 같은 glob에서 그쪽을 뺀 전부로 잡았나. 새 패키지가 어느 목록에도 안 들어가면 그 테스트는 CI에서 안 돌고 아무도 모른다
  • 루트의 include — 루트 설정에 include가 남아 있나. extends는 배열을 병합하므로, 남아 있으면 물려받는 프로젝트가 그 파일들을 한 번 더 돈다
  • 나누기 전후 diff — JSON 리포터로 덤프한 파일 수와 테스트 수가 양쪽 같은가. 누락 0, 중복 0이 아니면 벤치마크는 아직이다
  • 어디서 쟀나 — CI 러너에서, 머지 전후 여러 번의 평균으로 봤나. 줄인 게 CPU 시간이면 로컬에서는 워커 수로 나뉘어 노이즈 아래일 수 있다
  • 샤딩은 그 다음 — 재push와 merge queue에서 제일 늦게 끝나는 job이 test인가. 아니면 쪼개도 초록불은 그대로다

"로컬에선 효과 없던 최적화가 CI에서 나타난" 경험이 있다면, 어느 환경에서 어떻게 재셨는지 댓글로 들려주세요.