Velog
자바스크립트 스코프 호이스팅은 망가졌습니다
번들러의 스코프 호이스팅은 코드 스플리팅과 만나면 모듈 실행 순서·this 바인딩이 깨진다. Parcel v3가 "기본 함수 래퍼" 로 회귀를 검토 중인 이유.
핵심 요약
스코프 호이스팅은 모듈을 함수로 감싸는 대신 단일 스코프에 인라인해 번들 크기·런타임 비용을 줄이는 최적화로 Rollup 이 대중화했다. 한 번들에서는 잘 동작하지만 코드 스플리팅을 쓰면 (1) 공유 모듈 부작용 실행 순서가 원본과 달라지고, (2) import * as foo from './foo'; foo.bar() 의 this 바인딩이 객체 → undefined 로 바뀐다. 해결은 "공유 모듈을 함수로 감싸 호출 순서를 제어"하는 것이며, 실제 앱에서는 결국 거의 모든 모듈이 함수 래퍼를 갖게 되어 호이스팅 이점이 사라진다. webpack 의 ModuleConcatenationPlugin 은 부분 호이스팅으로 절충하지만 자주 bailout 한다. Parcel v3는 "기본 함수 래퍼" 로 정확성을 복원하는 방향을 검토 중.
스코프 호이스팅은 "각 모듈을 함수로 감싸지 말고 한 스코프에 인라인하자"는 최적화다. 번들 1개일 때는 깔끔하지만, 코드 스플리팅으로 번들이 여러 개가 되면 모듈 간 실행 순서·부작용·this 바인딩이 보장되지 않는다. 즉 호이스팅은 "스플리팅과 근본적으로 충돌"하는 최적화이며, 이점은 줄고 비용은 늘었다.
면접에서 "트리 셰이킹은 어떻게 동작하나요?"·"webpack 의 module concatenation 을 아세요?" 같은 질문이 들어왔을 때 "같은 스코프로 합친다" 정도로는 부족하다. 언제 그게 깨지는가 를 코드로 증명할 수 있어야 한다. 이 글은 4가지 번들러(Rollup/ESBuild/Rolldown/Parcel)에서 동일 사례가 어떻게 다르게 깨지는지 보여준다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
스코프 호이스팅의 본질
두 가지 방식 비교:
// 함수 래퍼 방식 (전통적)
let modules = {
'index.js': (require, exports) => {
let { add } = require('math.js');
console.log(add(2, 3));
},
'math.js': (require, exports) => {
exports.add = (a, b) => a + b;
},
};
// 스코프 호이스팅 (Rollup 이후)
function add(a, b) { return a + b; }
console.log(add(2, 3));
호이스팅은 함수 래퍼·require/exports 오버헤드를 없애지만, 대신 모듈 경계를 잃는다.
"호이스팅 = 무조건 좋다"는 도식. 단일 번들 가정에서만 깔끔하다.
코드 스플리팅과 충돌 (1) — 부작용 실행 순서
원래 entry-a 실행 순서가 shared1 → a1 → shared2 → a2 인데, 공유 번들로 추출 + 호이스팅 후엔 shared1 → shared2 → a1 → a2 가 된다. 부작용을 가진 모듈에서 "수입 순서대로 부작용이 실행된다"는 자바스크립트 모듈의 약속이 깨지는 셈.
"순서가 바뀌어도 결과는 같다"고 가정. 폴리필·전역 패치·로깅 등 순서 의존 코드에서 즉시 깨진다.
코드 스플리팅과 충돌 (2) — `this` 바인딩
// 원본
import * as foo from './foo';
foo.bar(); // this === foo (모듈 객체)
// 호이스팅 후
bar(); // this === undefined (strict)
네임스페이스 객체를 거치지 않고 직접 호출되어 this 가 사라진다. re-export 가 섞이면 더 복잡 — this 는 "함수가 선언된 모듈"이 아니라 "가져온 모듈"이어야 한다.
내부 라이브러리에서 this 의존하지 않으니 안전하다고 가정. 외부 의존성에서 깨지는 경우가 더 많다.
해결책 — 함수 래퍼 또는 부분 호이스팅
공유 모듈만 함수로 감싸 호출 순서를 명시화:
// shared.bundle.js
export default {
shared1: () => console.log('shared1'),
shared2: () => console.log('shared2'),
};
// entry-a.bundle.js
import modules from 'shared.bundle.js';
modules.shared1();
console.log('a1');
modules.shared2();
console.log('a2');
이게 본질적으로 Parcel 의 동작 방식이며, webpack 의 ModuleConcatenationPlugin 은 "같은 번들 안에서만" 부분 호이스팅을 시도한다.
수동으로 import 순서를 맞춰 회피하기. 다음 빌드/번들러 버전에서 다시 깨진다.
트리 셰이킹·런타임 비용 재평가
호이스팅은 트리 셰이킹 의 전제조건이라는 통념도 정확하지 않다. 번들러가 자체 정보를 가지면 함수로 감싸진 모듈에서도 트리 셰이킹이 가능. 또한 "함수 래퍼의 런타임 비용" 은 2016년 Nolan Lawson 측정 이후 V8 발전으로 격차가 줄었다 — 게다가 lazy 평가가 "한 번에 평가"보다 빠를 수도 있다.
"호이스팅 없이는 트리 셰이킹 안 된다"는 옛 정설을 그대로 인용.
읽는 순서
- 1이론
ESM 의 정적 import/export, 함수 래퍼 vs 인라인 스코프, 트리 셰이킹의 조건을 그림으로 정리.
- 2구현
글의 예제(
a1/a2/b1/b2/shared1/shared2)를 Rollup·ESBuild·Rolldown·Parcel 에 모두 넣어 출력 차이를 직접 캡처. - 3실무
사내 코드의 "순서 의존 import"(폴리필·CSS-in-JS 초기화·전역 모킹) 을 점검해 어떤 번들러에서 깨질 수 있는지 매트릭스 작성.
- 4설명
"왜 Parcel v3 가 기본 호이스팅을 제거하려 하는가"를 동료에게 5분 안에 설명할 수 있게 정리.
면접 연결 질문
[감점 답변] "같은 거다". [좋은 답변] 다른 최적화다. 트리 셰이킹은 "안 쓰는 export 제거"(빌드 타임 정적 분석), 호이스팅은 "모듈을 같은 스코프로 인라인"(런타임/번들 크기 최적화). 호이스팅이 트리 셰이킹을 더 쉽게 만들어 준 건 사실이지만 필수 조건은 아니다 — 번들러가 정보를 직접 수집하면 함수 래퍼 모듈에서도 가능.
[감점 답변] "순서가 바뀐다" 한 줄. [좋은 답변] (1) 부작용 순서 변경 — shared1 → a1 → shared2 → a2 가 shared1 → shared2 → a1 → a2 가 된다(폴리필·로깅 깨짐). (2) this 바인딩 손실 — import * as foo 후 foo.bar() 가 그냥 bar() 가 되어 this === undefined. 두 사례를 코드 스니펫으로 즉시 보일 수 있어야 한다.
[감점 답변] "webpack 방식이 더 빠르다". [좋은 답변] webpack 은 같은 번들 내 모듈을 부분 호이스팅하다가 부작용·동적 import·side effects 등이 보이면 bailout. Parcel 은 기본 함수 래퍼로 정확성을 우선하고 가능한 곳만 호이스팅. "속도 vs 정확성"의 다른 디폴트라는 관점이 답.
자기 점검
"순서만 좀 바뀌는 것"이라고 사소화. 실제로는 폴리필·전역 패치 같은 부작용 의존 코드가 깨진다.
"무조건 켜면 좋다"고 가정. 실제로는 폴리필 import 같은 모듈을 잘못 빼버려 런타임 에러를 만든다.