터보팩 Tree shaking 작업 후기

4 min read

전에 기록을 더 많이 남기겠다고 한 적이 있는데 그래서 적는 글이다. 작업은 이것저것 되게 많이 하는데 글로 남길만한 내용이 없더라.

PR은 https://github.com/vercel/turbo/pull/3338 인데, 상당히 큰 작업이었다. 깃허브에 표시되는 최종 커밋은 250개인데 170여개 커밋을 중간에 리베이싱하면서 드랍했다. 중간에 요구사항이 완전히 바뀌어서 해당 코드들이 필요없어졌기 때문이다.

패치의 목적은 좀 특이하다. 일반적인 트리셰이킹보다 한 단계 더 진보된 트리셰이킹을 구현하는 게 목표였다.

import { upper } from "module";
export let foobar = "foo";
export const foo = foobar;
const bar = "bar";
foobar += bar;
let foobarCopy = foobar;
foobar += "foo";
console.log(foobarCopy);
foobarCopy += "Unused";
function internal() {
  return upper(foobar);
}
export function external1() {
  return internal() + foobar;
}
export function external2() {
  foobar += ".";
}

위 코드에서, 일반적인 트리 셰이킹은 foobarCopy += "Unused";를 없애지 못한다. 수정된 값을 아무데서도 사용하지 않지만, foobarCopy 자체는 사용되었기 때문에 저 할당식을 드랍하려면 Control Flow Graph(이하 CFG)가 필요하다. 근데 러스트에서 자바스크립트로 되돌릴 수 있는 CFG를 만드는 건 매우 어려운 작업이다. 러스트에서 AST 구조를 유지하면서 CFG를 만드는 건 내 레벨에서도 힘들다. 참조를 저장할 수 없고 AST 타입들의 메모리 레이아웃을 변경할 수 없어서 AST 노드별로 전용 타입을 만들어야한다. 근데 그러면 매우 비싼 동작이 되어버린다. 그래서 CFG를 안 만들고 CFG의 일부 특성을 구현해야하는 상황이었다.


처음엔 자세한 설계까진 적혀있지 않았고 분석 => 쪼개기 형태였다. 그래서 열심히 작업했다. 근데 하고나서 보니까 문제가 좀 있더라. 난 문서에 적힌 대로 Id 기반으로 쪼개기를 했는데, 이래버리면 IdModuleItem 인덱스로 바꾸는 게 어렵다. 그래서 팀원분들께 얘기했는데, 설계를 완전히 바꾸기로 했다. 그런 뒤 @sokra 님이 아주 자세한 문서를 적어주셨다.

설계가 바뀐 뒤, 난 깃허브 코파일럿으로 아주 빠르게 분석기를 구현했다. 문서가 워낙 자세해서 설명 복사해오니까 코파일럿이 잘 이해하더라. 분석기의 역할은 각 모듈 아이템이 어떤 변수를 건드리는지를 Read/Write를 기준으로 분석하고, 일부 모듈 아이템들을 각각 더 작은 논리적인 가상의 노드로 쪼개는 것이었다. 에를 들어, 위의

import { upper } from "module";

같은 경우 import "module"이라는 노드와 upper 바인딩으로 쪼개진다. 구현한 뒤, 내가 구현한 게 맞는지 검증하기 위해 Graphviz로 시각화한 뒤 @sokra 님께 리뷰를 요청했다. 그런데 깃허브가 Mermaid 렌더링을 지원하니까 마크다운 파일에 Mermaid로 렌더링해서 보여주는 게 더 나을 것 같다고 하셔서 Mermaid 로 바꾼 뒤 다시 리뷰 요청드렸다. 내가 AST 분석할 때 실수한 게 몇개 있었고, 고치고 그래프 확인하고 이런 식으로 몇번 핑퐁하면서 모듈을 실제로 쪼개기 위한 모듈 작업에 들어갔다.

위에서 모듈을 가상 노드로 쪼갠다고 얘기했는데 이것과 별개로 모듈을 실제로 쪼개는 기능도 필요했다. 이 모듈들은 코드의 그룹으로 생각하면 되는데, 각각 모듈을 임포트했기 때문에 실행해야하는 코드 (Module Evaluation)하고 각 export를 위해 필요한 코드들을 담고 있다. 그리고 코드가 중복되는 걸 막기 위해, 코드 그룹 2개 이상에서 참조하는 코드의 경우 별도의 모듈이 되어야했다. 내가 CS 지식이 눈곱만큼 있다보니 그래프 처리할 때 조금 고생했다. 사실 최적화를 포기하면 쉬웠을텐데, 쓸데없이 성능에 집착하다가 시간을 꽤 날렸다. 그리고 결국 쉽고 잘 작동하는 대신 성능은 좀 떨어지는 그런 코드를 쓰게 됐다.

코드 그룹들은 대략 아래와 같은 느낌이다.

// export "foobar"

import { foobar } from "X1";

export { foobar };

이는 foobar라는 export에 대한 그룹이고, 아래는 가상 그룹들이다.

// X1

import { mut foobar } from "X2";
import weak "X3";

foobar += "foo";

export { mut foobar }
// X2

import { mut foobar } from "X3";

const bar = "bar";
foobar += "bar;
let foobarCopy = foobar;
// X3

let foobar = "foo";
const foo = foobar;

export { mut foobar, foo }

이런 식으로 쪼갠 뒤에 나중에 실제 모듈 사용 정보를 가지고 필요한 모듈만 불러오는 식이다. 처음에 문서만 봤을 땐 워낙 괴랄해서 무서웠는데 해보니까 되더라. 결국 변수 분석하고 AST 다루는 것인지라, SWC minifier로 단련된 나한테는 할만한 작업이었다.


이걸 구현한 뒤 남은 일은 이제 터보팩의 시스템에 맞게 트레잇 구현하고 통합하는 것이었다. 이게 사실 제일 어려웠다. 터보팩의 설계가 굉장히 특이하기 때문에 러스트를 능숙하게 다루는 것과는 별개로 어려움이 있었다.

근데 이것도 @sokra 님이 내가 해야하는 작업을 자세히 설명해주셨다. 어떤 타입을 선언해야하고 그 타입이 어떤 트레잇을 구현해야한다 같은 내용들이었는데, 끼워맞추기는 내 전문이다. 일단 해당 트레잇들이 어떻게 쓰이는지 난 모르니까 turbo 레포지토리를 하나 더 클론한 뒤 cargo doc --document-private-items를 했다. 보니까 대략 감이 오더라. 그래서 열심히 트레잇들을 구현했다.

근데 중간에 막혔다. esm_resolver 같은 공통 함수나, turbopack-core을 수정해야만 작동할 것 같은데, PR의 스코프가 어디까지인지를 모르겠어서 고쳐도 되는지 여쭤보기 위해 여러 번 멈췄다. 시차가 있다보니 슬랙 핑퐁이 굉장히 오래걸리더라. 저것들을 수정해도 된다는 얘기를 듣고 나서는 함수 시그니처 수정하고 또 열심히 끼워맞추기를 했다.

그렇게 해서 작동하는 걸 확인한 뒤 코드 리뷰를 요청했다. 말을 쉽게 해서 그렇지 사실 많이 고생했다. 그런데 코드 리뷰의 변경 요청사항이 좀 많았다. 아무래도 API 디자인에 관한 게 제일 많았다. 내 프로젝트가 아니다보니 새 타입의 인자를 추가하는 대신 기존 enum에 variant를 추가한다거나 하는 방식으로 작업했는데, API 추가를 원하셨다. 그리고 코드 스타일 리뷰도 꽤 많았는데 친절하게 Suggestion을 달아주셔서 처리하는 게 어렵지 않았다. 근데 커밋이 수백개다보니 깃허브가 Load more items를 누르기 전까진 리뷰 메시지를 안 보여주는, 피드백을 적용해야하는 입장에선 매우 골 때리는 이슈가 있었다.

그래도 여러 번 핑퐁한 뒤 팀원 두분이 Approve를 해주셨고, next-dev-tests가 내가 구현한 트리 셰이킹 아래에서 잘 작동하는지 확인한 뒤 머지하겠다고 하셨다. 머지를 기다리는 동안 merge conflict가 생겨서 리베이스도 한번 했는데, 결국 오늘 머지됐다. 관련된 작업을 이어서 하려다가, 매니저와 1:1을 한 뒤에 하는 게 나을 것 같아서 오늘은 SWC 이슈들을 보고 있다.


작업 후기? 핑퐁이 매우 답답했고 꽤나 큰 작업인데 잘 마무리돼서 기분이 좋다.

Vercel은

Asynchronous communication에 익숙해져라

라고 하는데, 내가 한국인이라 그런지 아님 내가 성격이 급한건지 시차 떄문에 하루씩 기다려야하는 게 마음에 안 들었다. 회사가 진짜 다 마음에 드는데 딱 하나 마음에 안 드는 게 시차다. 다행히도 내 주 업무는 SWC 관리라서 평상시엔 혼자 작업하고, 시차 이슈도 없다.