Node.js REPL을 이용한 iteration


개요

Node 환경에서 각종 변수들을 그대로 유지하면서 실행 코드만 변경하면서 실행하는 방법을 알아본다.

문제

IPython(jupyter)의 개발 방식이 빠른 프로토타이핑에는 정말 유리하다고 생각하는데, Node.js에 적용하기 어려운 면이 좀 있다.

  • 원본 소스코드는 Typescript로 작성하기 때문에 컴파일 과정이 필요하다.
  • REPL은 IDE에 비해 자동완성도 부실하고 Copilot 같은게 될리도 없기 때문에 생산성이 떨어진다.

디자인

실행은 Node.js의 REPL을 이용한다.

REPL 아니면 jupyter의 node.js 커널을 이용해야 하겠지만, 앞서 말했듯 IDE를 제대로 쓰려니 그냥 REPL을 쓰도록 한다.

모든 라이브러리는 한 파일에 몰아넣고, delete require.cache를 이용해서 모듈을 갱신한다.

Node REPL의 문제는 한번 로드한 라이브러리를 캐시해서, 코드를 수정해도 바로 적용이 안된다는 점이다. 여기서는 require.cache를 제거해서 수정된 코드를 다시 읽어들이도록 한다.

컴파일은 그냥 tsc -w

변수는 모두 한 객체 안에 몰아넣는다.

변수를 여럿 만들면 REPL을 닫았다 다시 열었을 때 그 모든 변수들을 다시 만들어야 한다는 문제가 있다. 그러니 한 객체안에 모든 변수를 몰아넣고, 필요에 따라 필드를 추가해서 변수로 사용한다.

구현

// lib.ts

export function reload() {
  Object.keys(require.cache).forEach((key) => {
    delete require.cache[key];
  });
  return require("./lib");
}

interface MyContext {
  page: Page;
}

async function init(): Promise<MyContext> {
  // initialize context and return it
}

export async function run(ctx?: MyContext): Promise<MyContext> {
  if (!ctx) {
    ctx = await init();
  }
  try {
    // do something here
  } finally {
    return ctx;
  }
}

이런 식으로 코드를 작성하고, REPL은 다음과 같이 쓴다.

$ node --experimental-repl-await
> var lib = require("./dist/lib").reload(); var ctx = await lib.run(ctx);
...
> var lib = require("./dist/lib").reload(); var ctx = await lib.run(ctx);
...

이렇게 하면, 코드를 수정하고 저장하면 자동으로 컴파일이 되고, REPL에서는 lib.reload()를 호출해서 캐시를 지우고, 새 코드를 가지고 다시한번 run을 호출한다. 첫번째 run 호출때는 undefined가 전달돼서 init이 호출되고, 그 다음부터는 이전에 리턴된 ctx가 전달되어서 환경을 유지하면서 반복적으로 코드 변경을 수행할 수 있게 된다.

결론

Playwright 등을 이용해서 브라우저 automation을 하다보면 자주 동작을 변경하면서 테스트를 해야 하는 경우가 많은데, 이럴때 쓰기 유용하다.