(번역) 자바스크립트 스코프 호이스팅은 망가졌습니다

sehyun hwang·2025년 8월 4일
19

FE 번역글

목록 보기
42/44
post-thumbnail

원문 : https://devongovett.me/blog/scope-hoisting.html

오늘날 대부분의 자바스크립트 번들러는 "스코프 호이스팅(scope hoisting)"이라고 하는 최적화 기법이 구현되어 있습니다. 이 기법은 컴파일러가 각 번들 모듈을 함수로 감싸는 대신, 모듈을 단일 스코프로 연결하는 것입니다. 아래와 같은 프로그램이 있다고 가정해 봅시다.

// index.js
import {add} from './math';

console.log(add(2, 3));
// math.js
export function add(a, b) {
  return a + b;
}

스코프 호이스팅이 적용된다면 번들 결과물은 다음과 같습니다.

function add(a, b) {
  return a + b;
}

console.log(add(2, 3));

컴파일러는 두 모듈 간 충돌할 가능성이 있는 최상위 레벨 변수의 이름을 변경하고, import 문 순서에 따라 연결합니다. 이 아이디어는 Rollup에 의해 대중화되었고, 현재는 Parcel, ESBuild 등 다른 많은 번들러에도 구현되어 있습니다.

이는 이론상으로 좋은 아이디어입니다. 스코프 호이스팅 이전에 흔히 사용되었던 대체안은 각 모듈을 별도의 함수로 감싸는 것입니다.

let modules = {
  'index.js': (require, exports) => {
    let {add} = require('math.js');
    console.log(add(2, 3));
  },
  'math.js': (require, exports) => {
    exports.add = function add(a, b) {
      return a + b;
    }
  }
}

이렇게 하면 모듈 스코프가 분리되지만, 스코프 호이스팅 방식에 비해 번들 사이즈가 더 크고, 런타임에 requireexports를 통한 추가 동작이 있습니다.

코드 스플리팅

스코프 호이스팅의 문제점은 근본적으로 코드 스플리팅과 상충한다는 점입니다. 의존성을 단일 번들에 모두 이어 붙일 때는 잘 동작합니다. 이 경우, import 된 코드가 선형적으로 실행되며 import 문을 가져온 코드로 대체하기만 하면 올바르게 작동합니다.

코드 스플리팅은 이 가정을 깨뜨립니다. 대부분의 실제 애플리케이션은 여러 페이지, 동적 import() 등 하나 이상의 엔트리 포인트를 갖고 있습니다. 이러한 엔트리 포인트들은 React와 같은 프레임워크, lodash와 같은 라이브러리 등 서로 같은 공통 의존성을 가집니다.

번들러는 각 엔트리 포인트부터 시작된 모든 의존성을 인라이닝(inlining) 하는 대신, 엔트리 간의 공통 모듈을 별도의 번들로 추출하는 똑똑한 알고리즘을 구현했습니다. 이를 통해 페이지 간의 중복 코드를 방지하고, 브라우저의 HTTP 캐시를 더 잘 활용할 수 있게 되었습니다.

예제와 함께 설명해 드리겠습니다.

// entry-a.js
import React from 'react';
import _ from 'lodash';

export function EntryA() {
  return <div>Entry A</div>;
}
// entry-b.js
import React from 'react';
import _ from 'lodash';

export function EntryB() {
    return <div>Entry B</div>;
}

간단히 구현된 번들러라면 다음과 같이 두 개의 번들을 생성합니다.

  • entry-a.js + react.js + lodash.js
  • entry-b.js + react.js + lodash.js

그러나 이 경우 React와 lodash가 각 엔트리에 중복됩니다. 따라서 대부분의 번들러는 공통 의존성인 React와 lodash를 별도의 번들로 분리하여 각 엔트리 간에 공유할 수 있도록 합니다.

  • entry-a.js
  • entry-b.js
  • react.js + lodash.js

부작용

스코프 호이스팅으로 다시 돌아오겠습니다. 코드 스플리팅을 사용하면 번들러는 더 이상 import 문을 간단히 인라이닝 할 수 없습니다. 이제 몇몇 모듈은 다른 번들에 위치하게 됩니다. 따라서 공유 번들을 가져오기 위해 다음과 같은 import 문을 생성합니다.

// entry-a.bundle.js
import {React, _} from 'shared.bundle.js';

export function EntryA() {
  return <div>Entry A</div>;
}
// entry-b.bundle.js
import {React, _} from 'shared.bundle.js';

export function EntryB() {
  return <div>Entry B</div>;
}
// shared.bundle.js

// ...
export {React, _};

여기까진 아무 문제 없습니다.

그러나 자바스크립트 모듈은 export 외에 함수 호출, 변수 할당 등의 임의의 문을 포함할 수 있습니다. 이는 실행 환경에서 부작용을 불러올 수 있습니다. 그렇기 때문에 자바스크립트 모듈은 실행 순서에 매우 민감합니다. 만약 순서가 뒤바뀐다면 프로그램의 동작도 달라집니다.

이번엔 조금 더 복잡한 예제를 살펴보겠습니다.

// entry-a.js
import './a1';
import './a2';

// entry-b.js
import './b1';
import './b2';
// a1.js
import './shared1';
console.log('a1');

// a2.js
import './shared2';
console.log('a2');
// b1.js
import './shared1';
console.log('b1');

// b2.js
import './shared2';
console.log('b2');
// shared1.js
console.log('shared1');

// shared2.js
console.log('shared2');

번들링 없이 entry-a.js를 실행한다면, 이 코드는 다음과 같은 로그를 출력합니다.

  • shared1
  • a1
  • shared2
  • a2

번들링을 하게 되면, 코드 스플리팅 알고리즘은 shared1.jsshared2.jsentry-a.js, entry-b.js 사이의 공통 의존성이라는 것을 식별하여, 별도의 번들로 따로 분리합니다. 그다음 스코프 호이스팅이 실행되어 a1, a2, b1 그리고 b2 모듈을 인라이닝 합니다.

// entry-a.bundle.js
import 'shared.bundle.js';

console.log('a1');
console.log('a2');

// entry-b.bundle.js
import 'shared.bundle.js';

console.log('b1');
console.log('b2');
// shared.bundle.js
console.log('shared1');
console.log('shared2');

이제 entry-a.bundle.js를 실행해 보면 방금 전과는 다른 순서로 로그가 출력됩니다!

  • shared1
  • shared2
  • a1
  • a2

이 예제는 Rollup, ESBuild, 그리고 Rolldown에서 깨집니다. (모듈을 함수로 감싸는 실험적 옵션이 있긴 합니다.)

해결책

위 문제에 대한 해결책은 이 글의 도입부에서 얘기한 대로 각 공유 모듈을 함수로 감싸는 것입니다. 이를 통해 모듈의 실행 순서를 제어할 수 있습니다.

// entry-a.bundle.js
import modules from 'shared.bundle.js';

modules['shared1']();
console.log('a1');
modules['shared2']();
console.log('a2');
// entry-b.bundle.js
import modules from 'shared.bundle.js';

modules['shared1']();
console.log('b1');
modules['shared2']();
console.log('b2');
// shared.bundle.js
export default {
  'shared1': () => console.log('shared1'),
  'shared2': () => console.log('shared2'),
}

이는 기본적으로 Parcel의 동작 방식입니다. 선언된 번들 외부에서 접근이 이뤄지는 각 모듈은 함수로 감싸지고, 이들을 필요로 하는 모듈에 의해 호출됩니다. 만약 어떤 모듈이 감싸진다면, 이 모듈의 의존성 역시 모두 감싸져야 합니다. 실제 애플리케이션에서는 대부분의 모듈이 결국엔 함수로 감싸져야 한다는 뜻이며, 이는 스코프 호이스팅의 이점을 무효화합니다.

webpack은 이와 유사하게 구현되어 있습니다. 또한 webpack은 같은 번들 내에서만 접근되는 모듈 그룹을 위해 부분적인 스코프 호이스팅을 수행하는 module concatenation을 지원합니다. 이는 가장 최적의 해결책인 동시에 올바른 방법일 가능성이 높습니다. Parcel에도 향후 이와 같은 기능이 추가될 수 있습니다.

다른 문제점

위에서 언급한 부작용은 스코프 호이스팅의 여러 문제점 중 한 가지이고 다른 문제점들도 있습니다. 그중 한 가지는 export 된 함수 내에서 this 값이 깨진다는 것입니다.

// entry.js
import * as foo from './foo';

foo.bar();
// foo.js
export function bar() {
  console.log(this);
}

번들링을 하지 않으면 위 예제는 foo.js 모듈(즉, bar 함수를 포함한 객체)을 로깅 합니다.

스코프 호이스팅으로 번들링한 이후에는 다음과 같습니다.

// bundle.js
function bar() {
  console.log(this);
}

bar();

이제 bar()함수가 객체 프로퍼티를 거치지 않고 직접적으로 호출됩니다. 따라서, (엄격 모드에서는) this 값이 undefined가 됩니다. 이는 Rollup, ESBuild, Parcel, Rolldown 그리고 webpack에서 깨집니다.

만약 re-export가 포함될 경우 상황은 더욱 복잡해집니다. 왜냐하면 this 값은 함수가 선언된 모듈이 아닌 re-export 된 모듈이어야 하기 때문입니다.

스코프 호이스팅은 가치가 있나요?

이는 근래 제가 가장 궁금했던 질문입니다. 스코프 호이스팅은 최적화 가능성이 제한적인 것에 비해 너무 복잡한 것 같습니다.

Rollup이 개발될 당시에, 코드 스플리팅은 전혀 지원되지 않았습니다. 이 경우 스코프 호이스팅은 대부분 잘 동작합니다. 하지만 이는 라이브러리 번들링이나 작은 애플리케이션에서와 같이 매우 제한적인 사용 사례입니다. 라이브러리 번들링은 별로 좋지 않은 아이디어지만, 이는 다른 글에서 더 자세히 다뤄보겠습니다.

코드 스플리팅이 도입된 이후로, 스코프 호이스팅의 이점은 매우 축소되었습니다. 제가 실제 앱에서 Parcel로 테스트해 본 결과, 최종적으로 5% 미만의 모듈이 함수로 감싸지지 않았습니다. 공유 번들이나 동적 가져오기를 통해 접근하는 경우엔 반드시 함수로 감싸지기 때문에 기본적으로 엔트리 번들 내의 모듈만 스코프 호이스팅이 적용됩니다. webpack의 Module concatenation이 도움이 될 수 있지만, 이마저도 최적화에 실패하는 경우가 많습니다.

스코프 호이스팅은 트리 셰이킹에도 도움이 될 거라고 예상되었습니다. 모듈 간의 참조를 함수를 통하는 대신 정적 변수 접근을 이용함으로써 minifier는 모듈을 가로지르며 "살펴보고" 사용되지 않는 코드를 제거할 수 있기 때문에 더 잘 동작할 수 있습니다. 하지만 이는 트리 셰이킹을 구현하기 위해 범용 목적의 minifier를 이용할 때 국한됩니다. 번들러는 또한 이러한 정보를 스스로 수집하고, 모듈이 감싸져 있거나 심지어 서로 다른 번들끼리 참조하고 있을 때도 트리 셰이킹을 수행할 수 있습니다.

스코프 호이스팅의 또 다른 잠재적 이점은 런타임 성능입니다. 2016년에 Nolan Lawson은 작은 모듈의 비용에 관한 글을 작성했습니다. 그는 모듈을 감싸는 함수 래퍼의 런타임 비용과 번들 사이즈 측정을 시도했습니다. 하지만 그 당시에 트리 셰이킹은 Rollup에만 구현되어 있었고, 번들 사이즈 비용은 현실보다 크게 측정되었습니다. 런타임 비용(기본적으로 객체 프로퍼티 접근 비용 vs 정적 변수 참조 비용)은 흥미롭습니다. 오늘날의 하드웨어와 JS 엔진을 사용해서 이 문제를 다시 한번 테스트해 보고 싶습니다. 한편으로 모듈이 필요할 때만 게으르게 평가하는 방식이 모든 것을 한 번에 평가하는 것보다 성능상의 이점을 제공할 수도 있습니다.

그래서 요약하자면, 저는 더 이상 스코프 호이스팅이 가치가 있는지 잘 모르겠습니다. Parcel v3에서는 제거하는 것을 검토 중입니다. 물론, 트리 셰이킹, 죽은 코드 제거, 상수 폴딩, 그리고 다른 최적화 기법은 유효합니다. 정확성을 높이고 복잡성은 낮추기 위해 모듈을 기본적으로 함수로 감싸는 설계를 고려하고 있습니다. 추후 진행 상황을 다시 알려드리도록 하겠습니다.

2개의 댓글

comment-user-thumbnail
2025년 8월 14일

만약 re-export가 포함될 경우 상황은 더욱 복잡해집니다. 왜냐하면 this 값은 함수가 선언된 모듈이 아닌 re-export 된 모듈이어야 하기 때문입니다. MyMilestoneCard Login

답글 달기
comment-user-thumbnail
2025년 8월 18일

와, 움짤 설명 최고예요! 저도 단축키 좋아하는데 덕분에 많이 배워가요. 최근에 프로필 사진용으로 free ai cartoon generator​ 써봤는데, AI가 그림 그려주는 것도 신기하더라고요. 좋은 글 감사합니다!

답글 달기