Jest 스타일의 JavaScript 테스트 라이브러리 만들기 (7부)

SanE·2026년 5월 5일
post-thumbnail

🤔 들어가며

6부에서는 mock 함수를 풍부하게 만들고 절대 경로의 부담을 라이브러리 안으로 옮겨 사용성을 한 단계 끌어올렸다.

이전 포스트까지가 v0.4.x 버전의 변화였다면, 이번 포스트부터는 v0.5.0 버전의 변경 사항을 다룬다.

6부 마지막에 이런 문제를 예고했다.

우리는 아직도 사용자의 소스 파일을 디스크 위에서 직접 덮어쓰고 있었다. 테스트가 끝나면 원본을 복구하긴 하지만, 만약 그 사이에 프로세스가 비정상 종료되면? 사용자의 코드가 변환된 상태로 남는다. 읽기 전용으로 보호된 파일이 있다면 테스트 자체가 실패한다.

이번 포스트에서는 module.registerHooks()라는 Node.js 모듈 로더 훅 API를 이용해 디스크 쓰기를 완전히 0회로 만든 과정을 다룰 예정이다.

💡 디스크에 덮어쓰는 방식, 무엇이 위험한가

📋 기존 흐름 다시 보기

지금까지의 동작은 이렇게 정리할 수 있다.

[ 기존 흐름 ]

setupFiles()
  → fs.writeFileSync(game.js, 변환된코드)        ← 디스크 오염 ⚠️
  → fs.writeFileSync(random.js, 변환된코드)      ← 디스크 오염 ⚠️

runTests()
  → fs.writeFileSync(basic.test.js, 변환된코드)  ← 디스크 오염 ⚠️
  → await import('./basic.test.js')

finally
  → fs.writeFileSync(game.js, 원본코드)          ← 복구
  → fs.writeFileSync(random.js, 원본코드)        ← 복구
  → fs.writeFileSync(basic.test.js, 원본코드)    ← 복구

핵심은 setupFilesrunTests 단계에서 사용자의 원본 파일에 변환된 코드를 직접 덮어쓰고, finally에서 원본으로 되돌리는 구조다.

finally가 항상 실행될 거라는 가정 위에 세워진 모래성이라고 볼 수 있다.

📋 세 가지 치명적 문제

문제설명
비정상 종료 시 소스 훼손SIGKILL, OOM, 갑작스런 전원 차단 등으로 프로세스가 죽으면 finally가 실행되지 않아 변환된 코드가 사용자 파일에 그대로 남는다
읽기 전용 파일 대응 불가chmod 444로 보호된 파일이 하나라도 있으면 EACCES 오류로 테스트 자체가 시작도 못한다
불필요한 eager 변환setupFiles()가 mock 사용 여부와 무관하게 모든 소스 파일을 일단 디스크에 변환·기록한다

각각이 어떤 시나리오에서 문제가 되는지 잠깐 살펴보자.

(1) 비정상 종료

테스트 실행 중에 무거운 작업이 OOM을 유발하거나, 사용자가 Ctrl+C로 강제 종료할 수 있다. 이 경우 finally가 보장되지 않으므로 사용자의 원본 파일이 변환된 상태로 git에 잡히고, "내 코드가 왜 이렇게 돼있지?" 라는 사고가 발생한다.

(2) 읽기 전용 파일

회사에서 공통 모듈을 읽기 전용으로 보호해두는 케이스가 있다. 그 파일을 import 하는 테스트는 시작도 못 하고 EACCES 로 죽는다. 라이브러리 사용자의 환경 정책을 라이브러리가 강제로 위반하려 드는 셈이다.

(3) eager 변환

mock을 한 곳에서도 안 쓰는 프로젝트라도, setupFiles()는 일단 모든 .js 파일을 변환해서 디스크에 쓴다. 변환 비용이 든다면 모를까, 할 필요 자체가 없는 일을 하고 있다.

📋 개선 목표

세 문제는 결국 한 문장의 목표로 수렴한다.

사용자의 원본 파일을 0바이트도 건드리지 않고, 메모리에서만 변환된 코드를 Node.js에 공급한다.

문제는 "어떻게?"다.

💡 첫 시도 — vm 모듈로 직접 평가해보기

처음 떠오른 아이디어는 단순했다. Node에는 코드를 격리된 컨텍스트 안에서 평가해주는 vm 모듈이 있다. "디스크에서 읽은 소스를 Babel로 변환한 다음, vm 으로 직접 돌리면 끝 아닌가?" 라는 그림이었다.

흐름을 도식화하면 이렇다.

[ 처음 그렸던 그림 ]

readFileSync(game.js)
  → babel.transform(...)
       → vm.???(transformedCode)   ← 메모리에서 평가
            → 테스트 실행

디스크에는 read만 발생하고, write는 한 번도 일어나지 않는다. 목표 그대로다. 문제는 "vm.???" 자리에 들어갈 게 뭐냐였다.

📋 vm.SourceTextModule — 실험 플래그가 발목을 잡는다

ESM을 평가하는 표준 도구는 vm.SourceTextModule이다.

const m = new vm.SourceTextModule(transformedCode);
await m.link(linker);
await m.evaluate();

깔끔해 보인다. 그런데 이 API는 --experimental-vm-modules 플래그 없이는 동작하지 않는다.

node --experimental-vm-modules bin/cli.js

라이브러리 입장에서 이건 큰 문제다.

사용자가 우리 라이브러리를 쓰려면 자기 package.json의 test 스크립트를 통째로 고쳐서 플래그를 넣어야 한다.

다양한 환경을 그대로 받아주는 게 이 라이브러리가 노리는 자리인데, 진입 시점부터 사용자에게 환경 변경을 요구하는 셈이다.

📋 커스텀 linker — 의존성 그래프를 직접 묶어야 한다

experimental 플래그 문제를 잠깐 미뤄두고 본다 해도, SourceTextModulelink() 단계에서 사용자가 직접 linker 함수를 구현해 의존 모듈들을 묶어줘야 한다.

await m.link(async (specifier, referencingModule) => {
  // './game.js' 같은 specifier를 받아
  // 절대경로로 풀고
  // 디스크에서 읽고
  // Babel로 변환하고
  // 또 다른 SourceTextModule을 만들고
  // 그 모듈도 재귀적으로 link 한 뒤
  // 반환
});

Node가 평소에 알아서 해주던 일을 우리가 통째로 다시 짜야 한다. ESM resolver, CJS interop, node_modules 탐색, conditional exports, 순환 의존 처리, 그 모든 게 우리 책임이 된다. 이걸 직접 짜면 그 자체로 새 미니 Node를 만드는 셈이다.

📋 vm.Script 로 격리 평가만? — 재귀 의존이 빠져나간다

그렇다면 무거운 ESM API는 피하고, 더 단순한 vm.Script / vm.runInThisContext 로 진입점 파일만 평가하면 어떨까.

const script = new vm.Script(transformedCode);
script.runInThisContext();

여기서 결정적인 함정이 보인다. 진입점 코드(예: basic.test.js) 안에는 다른 모듈을 부르는 import './game.js' 가 들어 있다. vm.Script 는 그 진입점 코드 한 덩어리만 평가할 뿐이고, 그 안의 import 가 트리거되면 결국 Node의 기본 로더가 디스크에서 원본을 직접 읽어 들인다.

vm.Script(basic.test.js의 변환된 코드)
  └─ import './game.js'        ← 여기서 Node 기본 로더로 빠져나감
       └─ 디스크에서 game.js 원본 그대로 읽음 (변환 누락 ⚠️)

진입점 한 파일만 메모리에서 변환되고, 그 아래로 줄줄이 따라오는 의존 모듈들은 모두 변환되지 않은 원본으로 들어온다. 모킹의 핵심인 wrapper 변환이 적용되지 않으니 라이브러리 자체가 동작을 멈춘다.

📋 하이브리드도 결국 같은 짐

마지막으로 시도해본 그림은 "vm 으로 격리 평가는 하되, ESM 의존 해석은 Node에 맡기는 하이브리드". 그런데 이걸 풀려면 결국 vm 컨텍스트 안에서 발생하는 모든 의존성 요청을 다시 가로채는 또 다른 훅이 필요하다. 그 훅에서 또 변환을 끼워 넣어야 한다.

여기서 깨달았다.

vm 모듈은 "내가 가진 코드 한 덩어리를 어떻게 평가할 것인가" 의 도구지, "Node 가 모듈을 어떻게 불러오는가" 의 도구가 아니다.

우리에게 필요한 건 후자였다. 사용자의 진입점뿐 아니라 그 아래 모든 import/require를 일관되게 가로채는 자리. 그렇다면 vm 으로 우회할 게 아니라, Node의 모듈 로더 그 자체에 끼어드는 게 정답이라는 결론이 났다.

방향을 틀어 Node의 모듈 로딩 파이프라인을 다시 들여다봤다.

💡 Node.js 모듈 로딩 파이프라인을 가로채기

📋 resolve → load → 실행

Node.js에서 import './game.js' 한 줄이 실행될 때 내부적으로는 다음 단계를 거친다.

resolve(specifier)  →  load(url)  →  실행
  • resolve: import './game.js' 같은 specifier를 절대 URL(file:///abs/path/game.js)로 변환
  • load: 그 URL에 해당하는 파일을 디스크에서 읽어 소스 코드를 반환
  • 실행: 반환된 소스를 컴파일·평가

여기서 우리가 노려야 할 자리가 보인다. load 단계에서 디스크가 돌려준 소스를 가로채, 우리가 변환한 소스로 바꿔치기 하면, 디스크는 read만 하고 write는 일어나지 않는다.

이걸 가능하게 해주는 API가 module.registerHooks() 다.

📋 module.registerHooks() vs module.register()

Node.js에는 비슷해 보이는 두 API가 있다. 처음에는 둘 사이에서 잠깐 헤맸다.

항목registerHooks()register()
실행 스레드메인 스레드별도 worker 스레드
동기/비동기동기비동기
Closure 접근가능불가 (구조 복제만)
커버 범위ESM + CJS 단일 훅ESM만
해제deregister() 반환불가

우리에게 결정적이었던 두 가지는 다음과 같다.

(A) closure로 mockedPaths를 그대로 캡처

훅 안에서 "이 파일이 mock 대상인가?"를 판단하려면 미리 수집해둔 mockedPaths Set이 필요하다. register() 였다면 worker 스레드로 데이터를 넘기기 위해 MessageChannel 같은 부가 장치를 만들어야 했다.

registerHooks()는 메인 스레드에서 동기 실행되므로 그냥 클로저에 잡으면 끝이다.

(B) ESM과 CJS를 한 훅으로 커버

사용자 환경은 다양하다. import/require가 한 프로젝트 안에 섞여 있을 수도 있다. 이 단일 훅 하나가 ESM import, 동적 import(), CJS require()전부 가로챈다. CJS용으로 Module.prototype._compile을 몽키 패치하던 우회가 필요 없어진다.

module.registerHooks()는 Node.js v22.15+ 에서 도입된 API이며 현재 Release Candidate 단계다. 그만큼 따끈한 기능이라 자료가 많지 않지만, 우리가 풀려는 문제에는 거의 맞춤이었다.

💡 installLoaderHook 구현

📋 전체 코드

src/cli/loaderHook.js 파일을 새로 만들었다.

import {registerHooks} from 'node:module';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
import {transformSource} from './utils/transformSource.js';
import {PATH} from '../constants/index.js';

const NODE_MODULES_SEGMENT = `${path.sep}${PATH.NODE_MODULES}${path.sep}`;
const FILE_PROTOCOL = 'file://';

const shouldTransform = (url, mockedPaths) => {
  if (mockedPaths.size === 0) return false;
  if (!url.startsWith(FILE_PROTOCOL)) return false;

  const filename = fileURLToPath(url);
  if (filename.includes(NODE_MODULES_SEGMENT)) return false;

  return filename.endsWith('.js') || filename.endsWith('.mjs');
};

export const installLoaderHook = (mockedPaths) => {
  return registerHooks({
    load(url, context, nextLoad) {
      const result = nextLoad(url, context);

      if (!shouldTransform(url, mockedPaths)) return result;

      const originalSource = typeof result.source === 'string'
        ? result.source
        : Buffer.from(result.source).toString('utf-8');

      const filename = fileURLToPath(url);
      const transformed = transformSource(originalSource, filename, mockedPaths);

      return {
        ...result,
        source: transformed,
        shortCircuit: true,
      };
    },
  });
};

이 짧은 함수가 흐름을 통째로 바꾼다. 분해해서 보자.

📋 load 훅의 동작

load(url, context, nextLoad) {
  const result = nextLoad(url, context);
  // ...
}

nextLoad(url, context)Node.js의 기본 로직(디스크 read)을 그대로 실행해 원본 소스를 돌려준다. 우리가 직접 fs.readFileSync를 호출할 필요가 없다는 점이 중요하다 — 어떤 파일이든 Node가 알아서 읽어주고, 우리는 그 결과만 들여다본다.

return {
  ...result,
  source: transformed,
  shortCircuit: true,
};

source 필드만 변환된 코드로 바꿔서 돌려준다. shortCircuit: true는 "이 훅에서 모든 처리가 끝났으니 후속 훅 체인을 건너뛰어라"는 신호다.

여기서 한 번이라도 디스크에 write 하는 코드가 없다는 점을 다시 확인하자. 모든 변환은 메모리 위에서만 일어난다.

📋 shouldTransform — 변환할지 말지 가르기

load 훅은 Node가 로드하는 모든 모듈에 대해 호출된다. node:fs, node_modules 안의 npm 패키지, data: URL까지 전부 다. 이 모두를 변환할 이유는 없다.

const shouldTransform = (url, mockedPaths) => {
  if (mockedPaths.size === 0) return false;
  if (!url.startsWith(FILE_PROTOCOL)) return false;

  const filename = fileURLToPath(url);
  if (filename.includes(NODE_MODULES_SEGMENT)) return false;

  return filename.endsWith('.js') || filename.endsWith('.mjs');
};

세 가지 가드를 차례로 통과시킨다.

  1. mockedPaths가 비어있으면 변환할 이유 자체가 없다 — mock을 안 쓰는 프로젝트는 변환 비용 0
  2. file:// 프로토콜이 아닌 URL은 skip (node:fs, data:... 등)
  3. node_modules 안의 파일은 skip, .js/.mjs만 통과

라이브러리가 사용자 환경에 가장 적게 개입하는 방향이다. 우리가 손댈 이유가 명확한 파일에만 정확히 적용한다.

📋 closure로 mockedPaths를 캡처

export const installLoaderHook = (mockedPaths) => {
  return registerHooks({
    load(url, context, nextLoad) {
      // 여기서 mockedPaths가 closure로 잡힘
      if (!shouldTransform(url, mockedPaths)) return result;
      // ...
    },
  });
};

installLoaderHook(mockedPaths)로 호출하면 그 인자가 그대로 훅 함수의 closure에 잡힌다. 별도 직렬화·복제 없이 참조 그대로 사용할 수 있다는 점이 메인 스레드 동기 실행 모델의 가장 큰 장점이다.

💡 변환 함수 분리 — transformSource

load 훅 안에서 직접 Babel을 호출해도 되지만, 코드를 분리해서 단위 테스트와 캐시를 따로 관리할 수 있도록 별도 파일로 뺐다.

📋 전체 코드

import {transformSync} from '@babel/core';
import {babelTransform} from '../../babelPlugins/babelTransform.js';

const cache = new Map();

const hashCode = (str) => {
  let h = 0;
  for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0;
  return h;
};

export const transformSource = (code, filename, mockedPaths) => {
  if (mockedPaths.size === 0) return code;

  const cacheKey = `${filename}:${code.length}:${hashCode(code)}`;
  const cached = cache.get(cacheKey);
  if (cached) return cached;

  const {code: transformed} = transformSync(code, {
    filename,
    plugins: [babelTransform(mockedPaths)],
    parserOpts: {
      sourceType: 'unambiguous',
      plugins: ['dynamicImport'],
    },
  });

  cache.set(cacheKey, transformed);
  return transformed;
};

핵심은 Babel transformSync 호출 + 6부에서 보강한 babelTransform 플러그인 그대로 재사용이다. 변환 규칙 자체는 새로 짤 필요가 없다.

📋 캐시

같은 파일이 여러 테스트에서 반복 import되면 매번 Babel 변환을 돌리는 건 낭비다. 그래서 변환 결과를 캐싱한다.

const cacheKey = `${filename}:${code.length}:${hashCode(code)}`;

캐시 키 설계가 미묘하다.

  • 파일명만 키로 쓰면: 같은 경로의 파일이 테스트 도중 바뀐 케이스를 놓친다
  • 내용 전체를 키로 쓰면: 큰 파일이 많아지면 메모리 낭비
  • 파일명 + 길이 + 짧은 해시: 충돌 가능성은 거의 없으면서 키 자체는 짧음

이 정도면 우리가 다루는 규모에선 충분하다.

📋 mock 없으면 0비용

if (mockedPaths.size === 0) return code;

shouldTransform에서 한 번 걸러지긴 하지만, 이 함수도 자체 가드를 갖는다. 단위 테스트로 직접 호출되는 경우까지 안전하게 지키기 위함이다.

mock을 한 번도 쓰지 않는 프로젝트는 Babel 호출 자체가 발생하지 않는다. 일반 사용자에게 변환 비용을 전가하지 않는다.

💡 CLI 흐름이 어떻게 단순해졌는가

새 훅 하나 도입한 결과로 CLI 쪽은 얼마나 정리됐을까.

📋 bin/cli.jsfinally 통째로 제거

변경 전

const main = async () => {
  try {
    const jsTe = await setupEnvironment();
    const {mockedPaths, testFiles} = setupFiles();

    // ... 테스트 실행 ...
  } catch (error) {
    // ...
    return NUM.ONE;
  } finally {
    restoreFiles();   // ← 디스크 복구 필수
  }
};

변경 후

const main = async () => {
  try {
    const jsTe = await setupEnvironment();
    const {mockedPaths, testFiles} = setupFiles();

    installLoaderHook(mockedPaths);   // ← 신규

    // ... 테스트 실행 ...
  } catch (error) {
    // ...
    return NUM.ONE;
  }
  // finally 블록 통째로 제거 ✓
};

디스크를 건드린 적이 없으니 복구할 게 없다. finally 블록과 restoreFiles import 한 줄이 통째로 사라졌다.

📋 setupFiles.js — eager 변환 루프 제거

변경 전

import {findAllSourceFiles, findTestFiles} from "./utils/findFiles.js";
import {collectMockedPaths} from "./utils/collectMocks.js";
import {transformFiles} from "./utils/transformFiles.js";

export const setupFiles = () => {
  const testFiles = findTestFiles(process.cwd());
  const sourceFiles = findAllSourceFiles(process.cwd());
  const mockedPaths = collectMockedPaths(testFiles);

  for (const file of sourceFiles) {
    transformFiles(file, mockedPaths);   // ← 모든 소스 파일을 디스크에 변환
  }
  return {mockedPaths, testFiles};
};

변경 후

import {findTestFiles} from "./utils/findFiles.js";
import {collectMockedPaths} from "./utils/collectMocks.js";

export const setupFiles = () => {
  const testFiles = findTestFiles(process.cwd());
  const mockedPaths = collectMockedPaths(testFiles);

  return {mockedPaths, testFiles};
};

이전엔 모든 소스 파일을 미리 한 번씩 디스크에서 변환했지만, 이제는 mock 경로만 수집해두고 실제 변환은 로더 훅에 맡긴다. findAllSourceFiles도 호출처가 사라져 함께 제거됐다.

📋 runTests.jstransformFiles() 호출 제거 + pathToFileURL

변경 전

for (const file of testFiles) {
  console.log(getFilePath(file));
  transformFiles(file, mockedPaths);   // ← 테스트 파일 디스크 변환

  await import(path.resolve(file));
  // ...
}

변경 후

for (const file of testFiles) {
  console.log(getFilePath(file));

  await import(pathToFileURL(path.resolve(file)).href);
  // ...
}

transformFiles 호출 한 줄이 사라졌다. 그리고 import 인자를 pathToFileURL로 감쌌는데, 로더 훅 안에서 url.startsWith('file://')로 판별하기 때문에 절대 경로 문자열이 아닌 정식 file:// URL로 넘기는 게 안전하다.

await import(...) 한 줄이 발동시키는 연쇄는 다음과 같다.

import(basic.test.js)
  └─ load 훅 발동 → babel 변환 (메모리에서)
       └─ import('./game.js') → load 훅 발동 → babel 변환
            └─ import('./random.js') → load 훅 발동 → babel 변환

모든 변환이 메모리에서, 자동으로, 재귀적으로 발생한다. 우리가 미리 모든 파일을 훑고 다닐 필요가 없다.

📋 transformFiles.js 통째로 삭제

기존 39줄짜리 파일이 통째로 사라졌다.

// 사라진 것들
- transformFiles()       // 디스크 변환
- restoreFiles()         // 디스크 복구
- originalFiles  Map     // 원본 보관용

정상적인 리팩터링은 코드가 줄어드는 방향으로 간다. 새로 추가된 두 파일(loaderHook.js 47줄, transformSource.js 38줄)을 합쳐도 순증감은 미미하지만, 위험 표면적이 비교할 수 없을 만큼 줄었다.

💡 진짜로 0바이트도 안 건드렸나? — 검증

코드만 봐서는 "정말로 디스크 쓰기가 사라졌는가?"가 직관적으로 와닿지 않는다. 그래서 객관적으로 증명해봤다.

📋 회귀 테스트

먼저 기존 테스트 결과가 그대로 유지되는지 확인.

node bin/cli.js

test/basic.test.js   15/15 pass
test/matcher.test.js  7/7  pass
test/error.test.js    0/2  pass  (의도된 실패 케이스)
---
Total: 22 passed, 2 failed (기존과 동일)

기능 회귀 없음.

📋 파일 불변성 검증

진짜 검증은 이쪽이다. 실행 전후로 모든 소스 파일의 SHA-256을 떠서 비교한다.

# 실행 전 해시 기록
find src bin test test-helper -name '*.js' | xargs shasum -a 256 > before.txt

# 테스트 실행
node bin/cli.js

# 실행 후 해시 기록
find src bin test test-helper -name '*.js' | xargs shasum -a 256 > after.txt

# 차이 비교
diff before.txt after.txt
# → 차이 없음: ALL FILES UNCHANGED ✓

디스크 쓰기 0회가 객관적으로 증명됐다. 비정상 종료가 일어나도 사용자 파일은 안전하고, 읽기 전용 파일이 섞여 있어도 테스트가 정상 실행된다.

😅 후기

이번 작업의 가장 큰 수확은 사실 결과물보다 module.registerHooks() 라는 훅이 존재한다는 사실 자체를 알게 된 것이었다.

Node 가 모듈을 어떻게 불러오는지에 그동안 한 번도 손을 뻗어본 적이 없었는데, 이번에 그 파이프라인의 한 단계를 직접 갈아끼우는 경험을 했다.

또 하나는 라이브러리가 사용자의 환경에 가능한 한 영향을 끼치지 않게 만든다는 게 생각보다 고려할 게 많다는 것이었다.

처음엔 "그냥 디스크에 안 쓰면 되는 거 아닌가" 싶었지만, 실제로 풀어보니 평가 모델 선택, 의존성 그래프 처리, ESM/CJS 통합, closure 공유 같은 결정이 줄줄이 따라왔다. "사용자가 우리 라이브러리를 쓰기 위해 자기 환경을 바꿔야 하는가?" 라는 한 줄짜리 질문이 설계 단계에서 얼마나 많은 선택을 좌우하는지 체감한 작업이었다.

물론 트레이드오프도 있다. module.registerHooks()는 Node v22.15+ 에서만 동작하므로 구버전 사용자는 라이브러리를 사용할 수 없다. 그래도 "사용자의 원본 파일을 직접 수정한다" 는 훨씬 더 크리티컬한 문제를 먼저 풀어내는 게 우선이라고 판단했다. Node 버전은 시간이 지나면 따라오지만, 한 번 훼손된 사용자 파일은 되돌릴 수 없다.


여기까지가 라이브러리의 핵심 동작이 자리 잡은 시점이다. CLI도, 모킹도, 변환 메커니즘도 모두 안정화됐다.

이제 시선을 다른 곳으로 돌릴 차례다. 테스트 라이브러리답게 매처(matcher) 자체의 품질을 손볼 차례다.

다음 포스트에서는 JSON.stringify 비교의 한계, .not 체이닝 설계, toThrow 인자 다형화 같은 변화를 다룰 예정이다.

📎 프로젝트 링크

profile
JavaScript를 사용하는 모두를 위해

0개의 댓글