vitest 격리를 파일마다 고르기 — 상태를 남기는 스펙만 격리했습니다

정기창·

연재 「CI 테스트 시간 줄이기」의 세 번째 글입니다. 1편에서 전체 그림과 측정 방법을, 2편에서 타입 검사를 다뤘고, 이번에는 단위 테스트 단계에서 백엔드 두 앱의 몫을 줄인 과정을 정리합니다.

대상은 B2B 커머스 운영 시스템으로, NestJS 10 api·worker와 Next.js 15 web이 한 pnpm 모노레포에 있고 단위 테스트는 vitest 4.1.10과 Node 24로 돕니다. 파일이 끝난 뒤에도 상태를 남기는 스펙만 격리에 두고, 나머지는 모듈을 다시 읽지 않는 isolate: false 프로젝트로 옮겼습니다. 어느 쪽인지는 목록이 아니라 설정이 읽힐 때마다 소스를 읽어 정합니다.

8코어 CI에서 단위 테스트 단계는 평균 154.6초에서 136.0초가 됐습니다. 다만 분류는 정적인 문자열 검사라 놓치는 꼴이 있고, 줄어든 이유의 일부는 추론입니다.

시간은 테스트가 아니라 import에서 나갔습니다

vitest는 기본으로 파일마다 워커를 새로 띄우고 모듈을 처음부터 읽습니다. 안전하지만 같은 모듈을 파일 수만큼 다시 읽습니다.

--experimental.importDurations.print로 모듈별 import 시간을 찍었습니다. api 스펙 249개의 import 자기 시간 합 281초 가운데 73%가 배럴 다섯 개, 곧 drizzle-orm, drizzle-orm/mysql-core, @nestjs/common과 워크스페이스의 DB·외부 연동 어댑터 패키지를 파일마다 다시 읽는 몫이었습니다. 파일별 값의 합이라 벽시계보다 크지만, 시간이 준비에서 나간다는 것은 분명했습니다.

다만 CI 단위 테스트 단계는 pnpm -r --no-sort test로 패키지를 병렬로 돌리고, 벽시계는 가장 긴 web이 정합니다. api와 worker를 줄여도 단계는 그대로일 수 있어서, 8코어 CI에서 흔들림(±5%)보다 크게 줄지 않으면 넣지 않기로 먼저 정했습니다.

격리를 끄면 상태가 다음 파일로 넘어갑니다

격리를 끄면 한 워커가 여러 파일을 같은 모듈 인스턴스, 같은 전역으로 이어 돌립니다. 무엇이 남는지는 러너가 파일 사이에 되돌리는 것을 보면 알 수 있습니다.

// vitest 4.1.10 러너가 워커 안에서 파일을 도는 부분 (축약)
for (const file of files) {
  if (config.isolate) {
    moduleRunner.mocker?.reset();
    resetModules(workerState.evaluatedModules, true);
  }
  await startTests([file], testRunner);
  // mocks should not affect different files
  vi.restoreAllMocks();
}

모듈과 가로채기(mocker)는 격리일 때만 비우고 vi.restoreAllMocks()는 매번 부릅니다. 그래서 vi.spyOn으로 건 spy는 되돌려지지만 손으로 대입한 값, 환경 변수, 가짜 시계, 모듈 캐시는 남습니다. vi.mock은 등록이 파일마다 따로여도 가짜에 묶여 읽힌 모듈이 캐시에 남아 다음 파일에서도 가짜를 내고, 앞 파일이 진짜로 읽어 둔 모듈은 뒤 파일의 가로채기를 비껴갑니다. 두 순서로 돌려 확인했습니다.

더 조심해야 했던 것은 실패가 아니라 통과였습니다. 작은 앱에 등록부를 채우는 스펙과, boot()를 부른 뒤 getAdapter('csv')가 있는지 보는 스펙을 두고, boot()에서는 일부러 등록 호출을 뺐습니다. 파일 순서를 시드 여섯 개로 섞어 돌리자, 격리 모드에서는 부트 스펙이 여섯 번 모두 실패했습니다. 격리를 끄고 워커 하나로 돌리면 등록 스펙이 먼저 돈 다섯 번은 통과했습니다. 앞 파일이 채운 등록부 덕분에 공짜로 초록이 된 것입니다. 순서 섞기는 깨지는 순서를 만날 때만 이것을 잡으므로, 이 부류는 실행 결과가 아니라 표지로 막아야 한다고 판단했습니다.

목록 대신 소스를 읽어 가릅니다

안전한 파일 목록을 손으로 두는 것이 가장 쉽지만 목록은 낡습니다. 작업하던 하루에만 api 스펙이 249개에서 253개로 늘었습니다(하나는 이 분류기의 테스트). 목록 밖의 새 파일을 격리로 보내면 이득을 조용히 잃고, 재사용으로 보내면 위험한 파일이 조용히 섞입니다. 있던 파일도 vi.mock 한 줄이나 import 하는 모듈의 변화로 성격이 바뀌지만, 파일 이름 목록은 그것을 모릅니다.

그래서 설정이 읽힐 때마다 분류기가 소스를 읽게 했습니다. 아래 표지가 하나도 없는 스펙만 재사용(reuse) 프로젝트로 가고 나머지는 격리(isolated)에 남습니다. 표지는 스펙 원문과, 스펙이 리터럴 경로로 import 하는 앱 안의 모듈에서 찾습니다. 지금 분류는 api 재사용 209개·격리 44개, worker 65개·30개입니다.

표지예다음 파일에 남는 것
vi.mock 류vi.mock, vi.hoisted가짜에 묶인 모듈 캐시
환경 변수 쓰기vi.stubEnv, process.env.X = …값, 그 값을 캐시한 모듈
가짜 시계vi.useFakeTimers시계 상태
Nest 로거 덮기Logger.overrideLogger, Test.createTestingModule프로세스 전역 로거
전역·내장 객체 고침globalThis.x = …, Date.now = …바뀐 전역
import 한 객체 고침box.v = …, REG.set(…)공유 모듈 객체
프로세스 리스너·종료 코드process.on(…), process.exitCode = 1리스너, 종료 코드
경로를 값으로 받는 importawait import(file)무엇을 실었는지 모름

Test.createTestingModule(…).compile()은 @nestjs/testing 10.4.22가 그 안에서 Logger.overrideLogger()를 부르기 때문에 넣었습니다.

아래는 원리만 남긴 축약판입니다. 결과를 기억해 두는 깊이 우선 순회는 순환 import 안에서 덜 셀 수 있어, 그래프는 스펙마다 넓이 우선으로 새로 걷습니다.

// scripts/classify-isolation.mjs — 축약판 (Node 24)
import { globSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

const MARKERS = {
  'vi.mock': /\bvi\.(mock|doMock|hoisted|importActual|resetModules)\s*\(/,
  '환경 변수': /\bvi\.stubEnv\s*\(|process\.env(\.\w+|\[[^\]]+\])\s*(=(?![=>])|\?\?=)|delete\s+process\.env/,
  '가짜 시계': /\bvi\.(useFakeTimers|setSystemTime)\s*\(/,
  'Nest 로거': /\bLogger\.overrideLogger\s*\(|\bcreateTestingModule\s*\(/,
  '전역 대입': /\b(globalThis|global)(\.[\w$]+)+\s*=(?![=>])/,
  '프로세스': /\bprocess\.(on|once)\s*\(|\bprocess\.exitCode\s*=(?![=>])/,
  '동적 import': /\b(?:import|require)\s*\(\s*(?![\s'"])/,
};
const STATEFUL = /@vitest-isolation\s+stateful\b/;
const IMPORT = /(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)['"](\.{1,2}\/[^'"]+)['"]/g;
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
const markersIn = (code) => Object.keys(MARKERS).filter((id) => MARKERS[id].test(code));

export function classify(root, specDirs) {
  const files = new Set(globSync('**/*.ts', { cwd: root, exclude: ['**/node_modules/**', '**/dist/**'] }));
  const graph = new Map(); // 앱 안의 리터럴 상대 import 만 간선
  for (const f of files) {
    const src = readFileSync(join(root, f), 'utf8');
    const deps = [...src.matchAll(IMPORT)]
      .map((m) => join(dirname(f), m[1]))
      .map((b) => [b, `${b}.ts`, join(b, 'index.ts')].find((c) => files.has(c)))
      .filter(Boolean);
    graph.set(f, { src, deps });
  }
  const reuse = [];
  const isolated = new Map();
  for (const [f, { src, deps }] of graph) {
    if (!f.endsWith('.spec.ts') || !specDirs.some((d) => f.startsWith(`${d}/`))) continue;
    const why = markersIn(src); // 스펙은 주석까지 그대로
    const seen = new Set([f]);
    for (const queue = [...deps]; queue.length > 0; ) {
      const d = queue.shift(); // 스펙마다 넓이 우선
      if (seen.has(d)) continue;
      seen.add(d);
      const { src: mod, deps: next } = graph.get(d);
      why.push(...markersIn(stripComments(mod))); // 닿는 모듈은 주석을 지우고
      if (STATEFUL.test(mod)) why.push(`상태 모듈 ${d}`);
      queue.push(...next);
    }
    if (why.length > 0) isolated.set(f, why);
    else reuse.push(f);
  }
  return { reuse: reuse.sort(), isolated };
}

export function isolationProjects({ root, include, exclude, env = process.env }) {
  const dirs = include.map((g) => g.replace(/\/\*\*\/\*\.spec\.ts$/, '')); // '<dir>/**/*.spec.ts' 꼴만
  const reuse = env.VITEST_ISOLATE_ALL === '1' ? [] : classify(root, dirs).reuse;
  const literal = reuse.map((p) => p.replace(/[*?[\]{}()!+@]/g, '\\$&')); // 글롭 리터럴로
  const isolated = { extends: true, test: { name: 'isolated', include, exclude: [...exclude, ...literal] } };
  if (reuse.length === 0) return [isolated];
  return [{ extends: true, test: { name: 'reuse', include: literal, isolate: false } }, isolated];
}

원본에는 이 밖에 내장 객체·import 한 객체 고침 검사, 다음 절의 패키지 선언, as 별칭 추적, 결과 기억이 있습니다. 이 축약판을 같은 저장소에 돌리면 api 2개, worker 8개 파일이 원본보다 재사용으로 더 가고, 그중 9개가 패키지 선언에 걸리는 파일입니다. 뼈대로만 읽어 주시면 좋겠습니다.

상태는 그 모듈이 스스로 밝힙니다

문자열로는 찾을 수 없는 상태도 있습니다. 모듈 최상위의 싱글턴입니다. 보고서 렌더링 모듈의 대기열은 «동시에 하나»라는 약속이 프로세스 단위라 나눌 수 없고, 대기열과 진행 중 작업이 파일 사이에 남습니다. 이런 모듈에는 @vitest-isolation stateful — 사유 주석을 달아, 직·간접으로 닿는 스펙을 격리로 보냅니다.

패키지는 거의 모든 스펙이 import 하므로 같은 방식이면 재사용 쪽이 사라집니다. 그래서 어댑터 등록부처럼 상태를 «바꾸는» 함수의 이름을 @vitest-isolation mutates-state: registerAdapter — 사유 꼴의 주석으로 밝힙니다. 스펙이나 스펙이 닿는 앱 모듈이 그 이름을 부르면, 네임스페이스 호출이나 as 별칭이어도 격리로 갑니다. getAdapter로 읽기만 하는 파일은 재사용에 남습니다. 재사용 쪽에서 아무도 등록부를 바꾸지 않으니 읽는 쪽은 늘 처음 상태를 봅니다. worker 격리 30개 중 12개가 이 선언에 걸렸고, 그중 8개는 다른 사유가 없습니다.

선언을 분류기 안의 목록으로 두지 않은 이유도 같습니다. 모듈을 옮기면 목록 항목은 조용히 빠지지만 주석은 상태 옆에서 함께 움직입니다. 다만 있던 선언을 지우면 뒤에 나올 «닻» 테스트가 잡지만, 새 싱글턴에 선언을 빠뜨리면 아무것도 잡지 못합니다.

두 프로젝트로 나누고, extends의 배열 합치기를 피합니다

설정은 재사용 목록에 isolate: false를 주고, 격리 쪽은 원래 include에서 그 목록을 exclude로 뺍니다. «include − 재사용»이라 빠지거나 두 번 도는 파일이 없습니다.

// apps/api/vitest.config.ts
import { fileURLToPath } from 'node:url';
import { configDefaults, defineConfig } from 'vitest/config';
import { isolationProjects } from '../../scripts/classify-isolation.mjs';

// test.include 에 다시 두지 않음 — extends: true 가 배열을 이어 붙임
const include = ['src/**/*.spec.ts', 'test/**/*.spec.ts'];

export default defineConfig({
  test: {
    globals: true,
    projects: isolationProjects({
      root: fileURLToPath(new URL('.', import.meta.url)),
      include,
      exclude: configDefaults.exclude,
    }),
    hookTimeout: 60_000, // 훅만 60초 — 테스트 본문 제한(기본 5초)은 그대로
  },
});

첫 함정은 extends: true였습니다. 프로젝트마다 루트 설정을 다시 읽어 합치는데, include나 setupFiles 같은 배열은 덮지 않고 이어 붙입니다. 최상위 test.include를 남기면 재사용 프로젝트에 전 파일이 들어갑니다. 실제 저장소에서 재사용 249개, 격리 51개로 겹쳤고, 작은 앱으로 재현하니 파일 5개가 8번 돌았는데 모두 통과였습니다. Test Files 수 말고는 신호가 없었습니다.

두 번째는 설정이 한 프로세스에서 루트와 프로젝트마다 한 번씩, 세 번 평가된다는 점입니다(설정 파일이 pid를 남기게 해 확인). 원본은 결과를 프로세스 전역에 기억해 두고 입력 파일이 그대로일 때만 다시 씁니다. 분류에는 로컬에서 api 약 0.7초가 들지만 vitest의 Duration에는 설정 로드가 빠집니다. 설정에 1.5초 대기를 넣어도 Duration은 0.1초였습니다. 그래서 효과는 CI 단계의 벽시계로 판단했습니다. 분류기가 의심스러우면 VITEST_ISOLATE_ALL=1로 전부 격리해 원인을 가릅니다.

오분류는 격리 쪽으로 기울게 했습니다

분류가 틀리는 방향은 둘이고 비용이 다릅니다. 상태를 남기는 파일이 재사용으로 가면 다른 파일의 결과가 오염됩니다. 실패하면 엉뚱한 파일이 빨개지고, 통과하면 아무도 모릅니다. 안전한 파일이 격리로 가면 예전처럼 따로 돌 뿐이고, 잃는 것은 속도입니다.

그래서 애매하면 격리로 보냈습니다. 스펙 주석 속 vi.mock도, 템플릿 문자열 import 경로도, Set의 add와 이름이 같은 m.add()도 표지로 칩니다. 예외는 오탐이 넓게 번지는 자리입니다. 따라간 원본 모듈은 주석을 지우고 봅니다. 주석 속 예시 한 줄이 그 모듈을 쓰는 스펙 전부를 격리로 보내지 않게 하려는 것입니다.

기울기에도 값이 있습니다. 처음에는 spy도 표지로 셌는데, vitest가 이미 되돌리는 것을 세자 api 12개, worker 6개 파일이 이유 없이 격리로 갔습니다. 러너 소스를 확인하고 뺐습니다. 모를 때는 격리로 기울되 알 수 있는 것은 소스를 열어 확인하는 것이, 기울기의 비용을 줄이는 길이라는 생각이 들었습니다.

분류기 테스트는 같은 방법으로 두 번 재지 않습니다

분류 오류는 통과로 나타날 수 있어서 분류기에 테스트 49건을 두었습니다. 하나는 합성 워크스페이스로, 임시 디렉토리의 작은 앱에 표지를 하나씩 넣어 격리로 가는지 봅니다. 대조군이 깨지면 나머지가 공허하게 초록이 되므로, 표지 없는 파일이 재사용으로 가는지부터 확인합니다.

다른 하나는 실제 트리입니다. 재사용 쪽 파일에 표지의 «글자 조각»이 없는지를 분류기의 정규식이 아니라 더 느슨한 includes로 셉니다. 같은 정규식으로 다시 재면 같은 맹점을 함께 확인해 줄 뿐입니다. 파일 수도 따로 세어 맞추고, 선언에만 기대 격리되는 스펙을 «닻»으로 골라 사유에 선언이 있는지 봅니다. 분류기를 한 곳씩 고장 내는 주입 22가지마다 실패하는 테스트가 생기는 것도 확인했습니다.

순서 섞기에도 함정이 있었습니다. --sequence.shuffle은 프로젝트까지 섞어, 격리 작업이 끼어들 때마다 재사용 워커가 끊깁니다. 그래서 재사용만 워커 하나로 섞는 조합(--project reuse --maxWorkers=1 --sequence.shuffle.files)을 시드 세 개로 따로 돌렸고, 당시 api 210개 파일 6,014건과 worker 65개 파일 1,436건이 한 프로세스에서 모두 통과했습니다. 전체 섞기의 실패 1건은 바꾸기 전 코드에서도 재현되는, 격리 쪽 파일 안의 순서 의존이었습니다.

리뷰에서 더해진 표지도 있습니다. 재사용 쪽 배선 검사 스펙 두 개가 컨트롤러를 경로 «값»으로 동적 import 하다가 상태 모듈까지 싣고 있었습니다. 깨진 테스트는 없었지만 «재사용 쪽은 상태 모듈에 닿지 않는다»는 보증은 거짓이었습니다. 분류기 테스트가 스스로 제한에 걸린 일도 있었습니다. 실제 트리 분류를 it 본문에서 돌리자 전량 병렬 실행에서 5.3초가 걸려 기본 제한 5초를 넘었습니다. 제한을 한꺼번에 올리면 정말 멈춘 테스트를 늦게 알아채므로, 분류를 훅 제한이 60초인 beforeAll로 옮겼습니다.

8코어 CI에서 18.6초, 줄어든 이유의 일부는 추론입니다

CI 시간은 러너마다 흔들려서, 코드와 무관한 lint와 web 빌드 시간을 속도계로 삼아 같은 등급의 러너끼리만 비교했습니다. 무관한 단계까지 1.4~1.7배 느렸던 한 번은 뺐습니다. 로컬 값은 12코어 머신에서 번갈아 잰 중앙값입니다.

지표전후
CI 단위 테스트 단계(8코어)149·152·156·156·160초(평균 154.6)131·139·138초(평균 136.0)
로컬 pnpm -r --no-sort test(그 안의 web)90.4초(89.8초)81.3초(80.8초)
api 단독 Duration(CPU)19.4초(178.5초)9.5초(81.0초)

단계 평균이 18.6초(12%) 줄어 기준을 넘었습니다. web이 끝을 정하는 단계가 왜 줄었는지에 대한 제 설명은, api와 worker가 CPU를 덜 쓰는 만큼 web이 코어를 더 받았다는 것입니다. web 코드를 고치지 않았는데 같은 실행 안의 web Duration이 9초 준 것이 근거지만, 인과를 떼어 잰 것은 아니어서 추론입니다.

나중에 web 쪽에 단독으로 10초 안팎을 줄이는 개선을 얹었을 때, 둘을 합친 단계는 136.0초에서 133.3초로 약 3초 주는 데 그쳤습니다. 병렬 단계에서는 따로 잰 개선 폭을 더할 수 없다는 것을 숫자로 확인한 셈입니다.

같은 기준으로 넣지 않은 것도 있습니다. 엑셀 파싱·mysql2 계열의 지연 로드(아낄 수 있는 벽시계 약 0.5초)와 보고서 PDF·잔액 계산 스펙의 중복 정리(단계의 3% 안팎)는 흔들림 안이라, 운영 코드나 테스트의 의도를 건드릴 이유가 되지 못했습니다.

맞지 않는 경우와 이번 측정이 증명하지 않는 것

정적 분류에는 맹점이 남습니다. import 한 객체를 함수 인자로 넘겨 그 안에서 고치거나, 상태 변경 함수를 값으로 넘기거나 구조 분해로 이름을 바꿔 부르면 잡지 못합니다. 재사용 쪽에서만 깨지는 파일이 나오면 파일 이름이 아니라 그 상태를 표지로 더해 다음 파일도 잡히게 합니다. 다만 거짓 통과는 순서 섞기로도 잘 드러나지 않아 여전히 남는 위험입니다.

이 방법이 맞지 않는 경우도 있습니다.

  • 스펙 대부분이 vi.mock이나 환경 변수를 쓰면 재사용 쪽이 작아 얻을 것이 적습니다.
  • import가 가볍고 파일이 적으면 분류 비용이 이득을 먹을 수 있습니다.
  • 시간이 테스트 본문에서 나가면 격리를 꺼도 줄지 않습니다.
  • DOM 렌더 테스트는 조심스럽습니다. 같은 저장소 web의 jsdom 스펙 8개로 격리를 꺼 보니 7개가 깨지고 시간은 9초에서 109초로 늘었습니다. 원인은 파지 않았고 web에는 다른 방법을 썼습니다.

이번 측정은 재사용 쪽 파일끼리 서로 오염하지 않음을 증명하지 않습니다. 시드 세 개와 한 워커 순차 실행에서 통과했다는 사실까지입니다. CI 표본은 전 5회, 후 3회로 적고, 러너 등급을 무관한 단계로 가른 것도 경험칙입니다.

돌이켜 보면 격리를 끄는 설정 한 줄보다, 무엇이 다음 파일로 넘어가는지 소스에서 읽어 낼 수 있게 만드는 일이 대부분이었습니다. 상태는 그 상태를 만든 모듈이 가장 잘 압니다. 목록을 한곳에 모으는 대신 상태 옆에 선언을 두고 분류기가 읽게 한 것이 가장 오래 남을 결정이라는 생각이 들었습니다. 다음 글에서는 e2e 단계의 MySQL 하네스 준비 비용을 줄인 과정을 다룹니다.

Vitest테스트 격리CINestJS모노레포성능 측정