노드와 Gatsby.js에서 크롬으로 자식 프로세스를 디버깅하는 방법


원문 : https://indepth.dev/how-to-debug-a-child-process-in-node-and-gatsby-js-with-chrome/

이 글에서는 Gatsby.js에서 사용하는 jest-worker 패키지를 수정하고 크롬 개발자 도구를 사용해 사용 가능한 자식 프로세스(child process)를 디버깅하는 방법을 소개할 것이다.

나는 지난 몇 주 동안 Gatsby 빌드 프로세스의 내부 구조를 파악하는 데 시간을 보냈다. 그 과정에서 서버측에서 Redux 스토어를 사용해 빌드 프로세스의 다른 액터들과 통신하는 것과 같은 흥미로운 접근법들을 발견했다. 조만간 이 내용에 대해 깊게 다룬 시리즈를 연재할 예정이다.

디버깅을 할 때 문제가 되었던 것 중 하나는, 노드의 자식 프로세스에 의존하는 Gatsby 기능이었다. 이 기능은 각 정적 페이지를 위해 HTML 빌드를 병렬로 실행하는 데 사용되었다. 자바 또는 C++와 같은 언어에서 병렬화는 보통 스레드를 사용하여 처리하지만, 자바스크립트는 스레드가 없기 때문에 여러 자식 프로세스를 생성하여 병렬로 노드 작업을 처리한다. 더 자세한 내용은 여기에서 확인할 수 있다.

Node.js는 자식 프로세스를 생성하기 위해 두 개의 주요 모듈을 가진다. child_process와 새 모듈인 worker_threads는 부모 프로세스와 자식 프로세스 사이에서 메모리를 공유하여 다른 언어의 스레드들을 에뮬레이트한다.

내부를 살펴보면, Gatsby는 병렬로 빌드를 실행하기 위해 jest-workers 패키지를 사용한다. 이는 WorkerPool을 구현하여 쉽게 확인할 수 있다.

const Worker = require(`jest-worker`).default;
const { cpuCoreCount } = require(`gatsby-core-utils`);

const create = () =>
  new Worker(require.resolve(`./child`), {
    numWorkers: cpuCoreCount(),
    forkOptions: {
      silent: false
    }
  });

module.exports = {
  create
};

이 패키지는 기본적으로 노드의 childprocess 모듈을 사용하지만, 워커의 인스턴스를 생성할 때 enableWorkerThreads: true로 설정하면 `workerthreads` 모듈로 변경할 수 있다.

이 글은 프론트엔드와 백엔드 개발자 모두에게 유용할 것이다. Gatsby.js에 특별히 관심이 없다면, 다음 장은 건너뛰고 "노드에서 자식 프로세스를 디버깅하기 위해 jest-workers를 수정하는 방법"으로 넘어간다.

Gatsby.js에서 자식 프로세스를 디버깅하는 방법 찾기

나는 수년간 프론트엔드 프레임워크와 라이브러리의 소스를 탐색하면서, 소스에서 디버깅과 리버스 엔지니어링(reverse-engineering)에 대해 많은 것을 배웠다고 생각한다. 디버깅과 리버스 엔지니어링에 대한 정보는 inDepth.dev에 게시된 "당신의 리버스 엔지니어링 스킬 올리기" 글에 공유되어 있다.

Gatsby 디버깅을 시작하기 위해 Gatsby CLI인 gatsby new gatsby-site를 사용해 프로젝트를 생성한다. 노드의 디버깅 인스펙터(debug inspector)를 활성화하여 애플리케이션을 실행하기 위해 다음 명령어를 사용한다.

$ node --inspect-brk node_modules/gatsby/dist/bin/gatsby.js build

그런 다음 chrome://inspect에서 프로세스를 찾아서 inspect를 클릭한다.

첫 번째 줄에서 디버거가 일시 정지된 상태에서 크롬 개발자 도구를 열고 컨트롤러를 사용해 계속 실행한다.

여기서 생성된 자식 프로세스를 디버깅할 수 없었다. 처음에 언급했듯이, Gatsby는 이 기술을 사용해 렌더링 부분을 빌드 단계에서 실행한다.

탐험의 일환으로, 다음과 같이 Header 컴포넌트에 debugger 구문을 추가했다.

디버깅 모드에서 프로세스를 실행하고, 첫 번째 줄에서 디버거를 일시 정지하여 크롬 개발자 도구를 연 다음 "Resume" (F8) 버튼을 눌렀다. 놀랍게도 컴포넌트 안에 추가한 중단점(breakpoint)에서 멈추지 않고 빌드가 끝났다. 중단점을 통과한게 처음은 아니라서 그냥 이 코드가 실행되지 않는다고 추측했다.

Header 컴포넌트 내 코드는 별도의 자식 프로세스에서 실행되고 크롬 디버거는 이 자식 프로세스에 연결되지 않는다는 것을 이해하기까지 시간이 꽤 걸렸다.

디버깅이 되지 않는 것은 좋지 않았다. 스크립트를 디버깅할 수 없다면 세부 내용도 이해할 수 없기 때문이다. 검색을 했더니 크롬이 자식 프로세스에 연결되지 않는다는 몇 가지 이슈를 발견했다.

첫 번째 해결 방법은 생성된 자식 프로세스에서 Gatsby가 실행한 renderHTML 함수를 사용해 render-html.js 파일 전체를 간단하게 복사하는 것이었다.

그리고 필요한 파라미터를 전달하는 노드 디버거를 사용해 이 함수를 수동으로 트리거 한다. 그러나 이 방법은 굉장히 불편하고 생산적이지 않았다. 에러를 피하기 위해 이 프로세스에 전달할 환경 변수를 매번 알아내야 했기 때문이다.

이 시점에서 내가 쓴 Webpack 빌드 디버깅 글을 보면, node_modules에서 노드 디버거와 함께 사용할 자바스크립트 파일을 찾는 방법을 설명했었다. 그러면서 노드 디버거로 필요한 파일 위치를 알아내는 방법으로 ndb를 노드 디버거로 사용하는 것이 합리적이라는 답변을 받았다. 이렇게 긴 명령어를 쓰는 대신

$ node --inspect-brk node_modules/webpack/bin/webpack.js

이렇게 간단하게 쓰면 된다.

$ ndb webpack

이 도구를 탐구하다가 몇 가지 흥미로운 기능을 발견했다.

이것은 정확하게 필요로 했던 기능이었다. Gatsby로 디버거를 실행하기 위해 다음과 같이 테스트 했다.

$ ndb gatsby build

ndb는 크롬의 다크모드처럼 보이는 디버깅 환경을 시작했다.

이번에는 컴포넌트 내 중단점이 실행을 멈추게 했다.

그랬더니 생성된 모든 프로세스를 아주 편리하게 보여주었다.

그러나 ndb는 버그가 조금 있었고, 다음과 같은 문제가 발생했었다.

약간의 문제는 있었지만, ndb는 여전히 유용한 도구였다. 하지만 더 나은 도구가 필요했다.

노드에서 자식 프로세스를 디버깅하기 위해 jest-workers 수정하는 방법

이 문제를 Victor와 논의했다. 그는 자식 프로세스를 포크(fork)한 코드로 수정하고, 자식 프로세스에 inspect-brk 옵션을 넘기도록 제안했다.

jest-workers를 사용한 아주 기본적인 애플리케이션에서 가능성을 먼저 찾기로 했다. 나는 보통 기술을 분리하고 각각 분리된 단위로 사용하려고 노력한다. 이렇게 하면 나중에 각 기술들이 어떻게 결합되고 시스템의 어떤 부분이 문제를 일으키지는지 이해하는 데 도움이 되기 때문이다.

jest-worker문서를 찾아서 문서에서 설명하는 첫 번째 예제를 사용해보았다. 예제에서 ECMAScript 모듈을 CommonJS로 변경해야 했다.

const Worker = require("jest-worker").default;

async function main() {
  const worker = new Worker(require.resolve("./worker"), { numWorkers: 1 });
  const result = await worker.hello("Alice"); // "Hello, Alice"
  console.log(result);
}

main();

parent.js

exports.hello = function hello(param) {
  debugger;
  return "Hello, " + param;
};

worker.js

worker.js 파일에서 내보낸 hello 함수 안에 debugger 구문을 추가하고 디버그 모드로 스크립트를 실행시켰다.

$ node --inspect-brk index.js

예상했던 것처럼 hello 함수가 자식 프로세스에서 실행되기 때문에 디버거는 중단점에서 멈추지 않았다.

정확하게 코드를 수정할 방법을 찾아야 했다. 내 생각에는 디버깅 모드에서 현재 프로세스를 실행하면 노드에 --inspect-brk 옵션을 넘기는 것이었다. 그래서 모든 자식 프로세스에서 동일한 옵션을 넘겨야된다고 가정했다.

--inspect-brk을 실행하면 인스펙터 에이전트가 기본 호스트인 127.0.0.1에 붙고 기본 포트 9229로 수신할 수 있다. 여러 개의 디버거는 같은 포트 번호에서 실행될 수 없으므로, 각 자식 프로세스에 다른 포트 번호를 표시할 수 있어야 했다. 그것이 바로 내가 한 일이었다.

node_modules\jest-worker\build\workers\ChildProcessWorker.js를 찾아서 다음 코드를 추가했다.

직접 해보고 싶다면 이 코드를 복사하면 된다.

const execArgv = process.execArgv.filter(
  value => !value.includes("inspect-brk")
);
const randromNumber = Math.floor(Math.random() * 9 + 1);
execArgv.push("--inspect-brk=:700" + randromNumber);

위 이미지에서 보여지는 것처럼 fork 메서드에서 업데이트된 execArgv를 넘기는 것을 잊으면 안된다.

또한 기존 구현 코드 내 jest가 모든 inspectdebug 플래그를 제거하는 것도 여기에서 확인할 수 있다.

수정한 작은 부분은 inspect-brk 옵션과 argV를 통해서 자식 프로세스를 임의로 생성된 마지막 번호의 포트를 간단하게 넘기도록 추가한 것이다. inspect-brk를 넘겼기 때문에 노드가 첫 번째 줄에서 자식 프로세스를 일시 중지시킬 것이라고 가정했다. 자식 프로세스는 디버거를 연결할 때까지 기다릴 것이고 어떤 실행 로직도 놓지지 않을 것이다.

부모 프로세스를 위해서 기본 포트 번호 대신 7000 포트 번호를 사용하기로 했다. 코드를 수정한 후 다음 명령어를 실행했다.

$ node - inspect-brk=:7000 index.js

크롬 설정

이 작업이 끝나면, 옵션으로 넘긴 포트 번호 7001~7009로 연결하도록 크롬에서 설정을 해야한다. 방법은 다음과 같다. chrome://inspect에서 Configure 버튼을 클릭한다.

그리고 포트 번호를 추가한다.

새로운 포트를 추가하려면 추가할 때마다 다이얼로그를 다시 열어야 한다. 대안책으로, 단순하게 수정한 코드에서 포트 번호를 기록할 수 있다. 그런 다음 크롬 디버거 설정에서 하나의 포트만 추가하면 된다.

포트 번호 7000을 추가하면 부모 프로세스를 볼 수 있다.

디버거를 연결하고 실행을 계속하면, 예상대로 새로운 자식 프로세스가 목록에 나타난다.

크롬 개발자 도구에서 가져온 inspect를 클릭하고 child_process 모듈 안에서 코드를 정지시킨다.

실행을 재개하면, hello 함수 안에서 중단된다.

예상하고 있었던 일이었고, 내 머릿속에 도파민이 흐르는 것을 느꼈다. 나는 항상 재미있는 것을 찾을 때 행복하다고 느낀다.

Gatsby.js 내에서 jest-worker 수정하기

스탠드얼론 버전의 애플리케이션을 성공적으로 수정한 다음, Gatsby 애플리케이션에서 jest-worker를 수정하기 위해 같은 기술을 쉽게 적용할 수 있었다.

Gatsby에서 자식 프로세스를 디버깅하려면 Gatsby 프로젝트 안에서 node_modules\jest-worker\build\workers\ChildProcessWorker.js를 찾아 위에서 본 것과 같은 코드를 추가한다.

기본적으로 jest-workers는 기기에서 감지한 CPU 코어 수 만큼 자식 프로세스를 생성한다. 이것은 os 모듈을 사용해 확인할 수 있다.

const os = require("os");
console.log(os.cpus().length);

내 경우에 CPU 코어 수는 8이었다.

그래서 프로세스로 chrome://inspect를 열었을 때 8개의 자식 프로세스가 생성되어 있을 것이라고 예상했었지만, 4개만 생성되어 있었다.

포트 번호가 [1–10]까지 생성되어 있었기 때문에 충돌이 나면서 한 인스턴스가 다른 인스턴스를 대체했기 때문이라고 생각했다. 모든 인스턴스를 확인하고 싶다면, 충돌 가능성을 줄이기 위해 포트 번호 범위를 늘려야 한다. 하지만 크롬에 모든 포트 번호를 추가하는게 불가능한 경우가 있을 수 있다. 쉬운 해결 방법은 간단하게 포트 번호를 기록하고 노드 인스펙터가 사용하는 포트 번호만 추가하는 것이다.

그러나 Gatsby에서 자식 프로세스를 디버깅할 때, 여러 IP 주소에 연결하도록 크롬을 설정하는 번거로움을 피하기 위해 1개의 자식 프로세스만 생성하는 것을 추천한다. 그렇게 하기 위해서 node_modules\gatsby\dist\utils\worker\pool.js에 아래 코드를 추가한다.

const create = () => new Worker(require.resolve(`./child`), {
  // numWorkers: cpuCoreCount(true),
  numWorkers: 1,   <---------- specify the number of child workers equal to 1
  forkOptions: {
    silent: false
  }
});

또 다른 흥미로운 세부 사항은 컴포넌트가 포함된 페이지를 빌드하기 위해 생성된 프로세스를 클릭해야 한다는 것이다. 그렇지 않으면 자식 프로세스가 생성되더라도 중단점에서 멈추지 않는다. 중단점에서 멈추려면 맞는 것을 찾기 전에 프로세스 중에 아무거나 클릭해야 할 것이다. 컴포넌트로 페이지를 렌더링 자식 프로세스를 찾기 전에 처음 두 개를 클릭했다. 마침내 중단점에서 멈췄다.

작은 힌트로, node_modules\gatsby\dist\utils\worker\render-html.js 파일에서 renderHTML 함수 내 paths 변수를 확인하면 렌더링 된 페이지들을 확인할 수 있다.

모든 파일에서 일시 정지하려면 중단점을 추가한다. 특정 페이지에서만 정지하고 싶다면 다음과 같은 조건문을 추가하면 된다.

또는 조건부 중단점을 추가한다.

이 코드를 포크해서 즐겁게 디버깅을 해보길 바란다! indepth.community에 질문이나 각 주제에 대한 당신의 생각을 공유해보라.