모든 PDF 로드 시 동일한 ReferenceError가 발생하며 크래시가 났다. 스택 트레이스는 전혀 도움이 되지 않는 곳을 가리켰고, 코드를 가이드하던 명세서는 종이 위에서는 완벽하게 합리적으로 보였다. 명세서는 피처 플래그를 확인하고, 각 영역을 pageWidth의 절반과 비교하라고 되어 있었다. 무엇이 일어나야 하는지는 설명되어 있었다. 하지만 pageWidth가 어디서 와야 하는지는 설명하지 않았고, 그 단 하나의 누락이 전체 파이프라인을 무너뜨리기에 충분했다.

아키텍처 문서는 동작을 설명하는 데는 능숙하다. 하지만 경계(boundaries)를 설명하는 데는 종종 형편없다. "함수가 x를 pageWidth와 비교한다"라는 문장은 기술적 계약이 아니다. 그것은 평이한 영어 속에 의존성을 숨겨둔 서사(narrative)일 뿐이다. 개발자가 그 문장을 읽고 pageWidth를 이름으로 참조하는 모듈 레벨 함수를 작성하면, 코드는 명세의 설명을 충족하므로 올바르게 보인다. 그러다 런타임이 해당 이름을 해결하려 시도하지만, 스코프 내에서 아무것도 찾지 못해 에러를 던진다.

단순했어야 할 리팩터링

페이지 조립 리팩터링 과정에서 정확히 이와 같은 패턴을 목격했다. 명세서에는 겉보기에 깔끔한 두 가지 요구 사항이 나열되어 있었다:

  • FEATURE_LAYOUT 확인.
  • 각 영역을 pageWidth / 2와 비교.

개발자는 지침을 충실히 따랐다. 그들은 모듈 스코프에 유틸리티 함수를 추출했고, pageWidth를 매개변수로 지정하지 않은 채 함수 본문에 직접 넣었다. 명세서에는 pageWidth가 인자 목록을 통해 전달되어야 한다고 명시되어 있지 않았다. 또한 함수가 pageWidth를 더 이상 볼 수 없는 모듈 스코프에 존재한다는 사실도 명시되지 않았다. 그저 구현자가 실행 컨텍스트를 암묵적으로 이해하고 있다고 가정했을 뿐이다.

결과는 모든 PDF 로드 시 발생하는 ReferenceError였다. 변수가 모듈 스코프에 없었기 때문에 함수는 즉시 에러를 던졌다. 만약 명세서가 함수의 경계와 입력값을 명시적으로 지정했다면, 개발자는 pageWidth를 인자로 전달했을 것이고, 버그는 구조적으로 발생 불가능했을 것이다. 대신, 그 지침은 구현자가 존재하지 않는 상위 스코프에 손을 뻗도록 유도하는 함정처럼 작용했다.

"사용한다(Uses)"의 네 가지 의미

더 깊은 문제는 산문(prose)에는 타입 시스템이 없다는 점이다. 명세서에서 "함수가 X를 사용한다"라고 말할 때, 현대적인 JavaScript 코드베이스에서 이 문장은 적어도 네 가지 구체적인 방식으로 모호해진다:

  • 함수가 X를 형식 매개변수(formal parameter)로 받는다.
  • 함수가 동일한 파일에 선언된 모듈 레벨 변수에서 X를 읽는다.
  • 함수가 중첩된 상위 스코프로부터 X를 클로저(closure)로 캡처한다.
  • 함수가 전달받은 더 큰 객체에서 X를 추출한다.

이 모든 옵션은 명세의 문구를 충족한다. 이 모든 옵션은 정적 분석을 통과한다. 하지만 주어진 경계에 대해 올바른 것은 단 하나뿐이며, 잘못된 선택은 컴파일 시에는 조용히 넘어가는 방식으로 그 경계를 넘어 가정을 유출시킨다.

개발자들은 보통 코드를 작성하는 순간 저항이 가장 적은 길을 선택한다. 만약 pageWidth가 우연히 외부 스코프에 있다면, 함수 시그니처를 변경하기보다는 거기서 읽어오는 방식을 택할 것이다. 클로저는 의존성을 숨긴다. 코드는 처음 실행될 때 잘 작동하고, 테스트 스위트를 통과하며, 배포된다. 몇 주 후, 누군가 재사용을 위해 또는 가독성을 높이기 위해 해당 함수를 다른 파일로 옮긴다. 상위 스코프가 사라진다. 코드는 깨지고, 근본 원인이 원래의 숨겨진 의존성이었음에도 불구하고 마치 새로운 회귀(regression) 버그처럼 보인다.

Web Worker는 증거를 지워버린다

이 문제는 아키텍처에 Web Worker가 도입되는 순간 진정으로 악랄해진다. 워커 내부에서 에러가 발생하면, 브라우저는 당신에게 가장 필요한 정보를 제거해 버린다.

실제로 일어나는 일은 다음과 같다. 워커 내부에서 처리되지 않은 예외(uncaught exception)가 발생하면 ErrorEvent가 발생한다. 만약 워커가 해당 에러를 메인 스레드로 전달한다면, 일반적인 패턴은 message 문자열을 가져와 경계를 넘어 전달하는 것이다. 메인 스레드는 그 문자열을 받아 그것으로부터 새로운 Error 객체를 생성하고, 이를 로그로 남기거나 다시 던진다(re-throw). DevTools에 나타나는 것은 메인 스레드의 메시지 핸들러 내부에서 재구성된 에러다. 원래의 파일명, 줄 번호, 스택 트레이스는 폐기된다. 실패의 실제 위치는 보이지 않게 된다.

따라서 누락된 pageWidth가 워커 내부에서 ReferenceError를 트리거했을 때, 메인 스레드는 메시지가 처리되는 지점에서 "pageWidth is not defined"라는 텍스트만 보고했다. 실제 모듈 레벨 함수는