자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
예상 읽기 시간: 18분
오늘의 질문: “콜백 안에서 읽는 값은 함수가 만들어진 곳을 볼까, 호출된 곳을 볼까?”
이 글은 자바스크립트 퀴즈북의 스코프·호이스팅·클로저 앞부분을 프론트엔드 코드 리뷰용으로 다시 묶은 글입니다. 단어를 외우는 순서는 별로 중요하지 않습니다. 실제 리뷰에서는 아래 질문을 계속 따라가야 합니다.
var, let, const 중 어떤 바인딩을 닫아두고 있나?undefined 흐름인지, TDZ 에러인지?const라는 말이 “재할당 금지”인지, “객체 내부 불변”인지?const label = "global";
function makeLogger() {
const label = "local";
return function log() {
console.log(label);
};
}
const log = makeLogger();
function run(callback) {
const label = "runner";
callback();
}
run(log);
출력은 local입니다. log가 run 안에서 호출되었으니 runner를 볼 것 같지만, 자바스크립트 함수는 호출 위치의 주변 변수를 새로 고르지 않습니다. 함수가 만들어진 위치의 렉시컬 환경을 기억하고, 실행될 때 그 환경 체인을 따라 이름을 찾습니다.
이 한 문장이 Day 7의 stale closure, Day 8의 async 실행 순서, Day 12의 이벤트 핸들러까지 이어집니다.
초보 때는 “함수가 실행되는 곳의 주변 값을 보겠지”라고 생각하기 쉽습니다. 하지만 함수는 호출될 때 아무 스코프나 다시 선택하지 않습니다. 함수 객체가 만들어질 때 바깥 렉시컬 환경에 대한 참조가 함께 붙고, 실행 때는 그 참조를 따라갑니다.
그래서 프론트엔드 버그는 이런 모양으로 나옵니다.
function createSubmitHandler(user) {
return function handleSubmit() {
sendAnalytics(user.id);
};
}
handleSubmit은 실행 시점의 최신 user를 자동으로 찾아오지 않습니다. 만들어질 때 인자로 받은 user 바인딩을 닫아둡니다. React 컴포넌트에서 핸들러가 어느 렌더에서 만들어졌는지 묻는 이유가 여기에 있습니다.
스코프 체인은 어려운 내부 구조라기보다 이름을 찾는 순서로 보면 됩니다.
const taxRate = 0.1;
function createPriceFormatter(discount) {
return function format(price) {
const discounted = price - discount;
return discounted + discounted * taxRate;
};
}
const format = createPriceFormatter(5);
console.log(format(100));
format 안에서 price는 자기 실행 환경에서 찾습니다. discount는 바깥 createPriceFormatter 환경에서 찾습니다. taxRate는 더 바깥 전역 환경에서 찾습니다. 이름을 찾지 못하면 그때 ReferenceError가 납니다.
리뷰할 때는 “이 변수 값이 어디서 왔지?”를 아래 순서로 좁히면 됩니다.
마지막 질문이 중요합니다. 클로저는 보통 값을 복사해 두는 스냅샷이 아니라 바인딩을 따라갑니다. 바인딩이 바뀌면 나중 실행에서 보이는 값도 달라질 수 있습니다.
스코프 체인을 설명할 때 가장 위험한 말은 “함수가 값을 기억한다”입니다. 편하게 말하면 맞는 것 같지만, 실제 버그를 읽을 때는 조금 더 정확해야 합니다. 함수는 보통 값 복사본이 아니라 바인딩에 닿는 길을 가지고 있습니다.
function makeCounter() {
let count = 0;
return {
inc() {
count += 1;
},
read() {
return count;
},
};
}
const counter = makeCounter();
counter.inc();
console.log(counter.read()); // 1
inc와 read가 각각 0이라는 값을 복사해 둔 게 아닙니다. 두 함수가 같은 count 바인딩을 보고 있습니다. 그래서 한쪽이 바꾸면 다른 쪽이 나중에 읽는 값도 바뀝니다. 이 감각은 Day 7의 stale closure와도 연결됩니다. “값이 살아 있다”는 말은 항상 최신 앱 상태를 본다는 뜻이 아니라, 그 렌더나 함수 호출이 만든 바인딩이 살아 있다는 뜻입니다.
React 코드에서 이 차이가 자주 헷갈립니다.
function makeHandler(user) {
return () => user.id;
}
const handler = makeHandler({ id: "a" });
여기서 handler는 user 바인딩을 기억합니다. 하지만 그 바인딩 자체가 나중에 다른 객체로 재할당되지 않는다면 화면의 최신 user를 자동으로 따라가지 않습니다. 반대로 바인딩이 mutable이라면 나중 실행에서 바뀐 값을 볼 수 있습니다. 리뷰에서는 “클로저가 값을 기억했다”로 뭉개지 말고, 닫힌 것이 immutable 값인지, mutable object인지, 재할당 가능한 바인딩인지까지 봐야 합니다.
브라우저에서 파일 하나가 전역처럼 보여도, ESM 모듈 안의 top-level 선언은 옛날 script 전역과 다르게 동작합니다.
// analytics.js
const queue = [];
export function track(event) {
queue.push(event);
}
queue는 모듈 내부에서 오래 살아남는 바인딩입니다. 컴포넌트가 unmount되어도 모듈이 로드된 동안 유지됩니다. 이게 의도한 이벤트 큐라면 괜찮지만, 테스트 사이에 상태가 새거나 사용자 전환 후 이전 데이터가 남는다면 모듈 스코프 mutable state가 원인일 수 있습니다.
반면 오래된 script 코드에서 var top-level 선언은 window 프로퍼티처럼 보이는 경우가 있습니다. ESM으로 옮길 때 “파일 위에 있으니 전역”이라고 단순하게 생각하면 디버깅이 꼬입니다. Day 13의 module 글에서 더 다루지만, Day 6 단계에서도 최소한 이 질문은 남겨야 합니다.
이 바인딩은 함수 호출마다 새로 생기나, 모듈이 로드된 뒤 계속 살아 있나?
프론트엔드 테스트에서 flaky한 상태 공유를 잡을 때 이 질문이 꽤 자주 먹힙니다.
var와 let/const의 차이var, let, const 모두 선언 처리 단계와 관련이 있습니다. 차이는 “어느 스코프에 바인딩이 생기고, 초기화 전 읽을 수 있는가”입니다.
| 선언 | 선언 전 읽기 | 스코프 | 리뷰 포인트 |
|---|---|---|---|
var | undefined | 함수 | 루프·콜백에서 같은 바인딩을 공유하기 쉽다 |
let | TDZ 에러 | 블록 | 선언 순서가 의도를 더 잘 드러낸다 |
const | TDZ 에러 | 블록 | 재할당 금지이지 객체 불변은 아니다 |
console.log(a); // undefined
var a = 1;
console.log(b); // ReferenceError
let b = 1;
var가 안전해서 에러가 안 나는 것이 아닙니다. 버그가 더 멀리 흘러갈 뿐입니다. 선언 전에 읽은 undefined가 fallback 로직을 타거나, API payload에 섞이거나, 렌더링 조건을 바꾸면 원인을 찾기 어려워집니다.
TDZ는 귀찮은 제약이 아니라 조기 경고에 가깝습니다.
function renderTheme() {
console.log(theme);
const theme = getTheme();
}
이 코드는 바로 실패합니다. 반대로 var였다면 아래처럼 조용히 undefined를 흘릴 수 있습니다.
function renderTheme() {
console.log(theme); // undefined
var theme = getTheme();
}
리뷰에서는 “에러가 안 났다”보다 “선언 순서와 초기화 시점이 독자에게 보이는가”를 봐야 합니다.
함수 선언과 class 선언도 같은 단어로 “호이스팅된다”고만 말하면 오해가 생깁니다.
sayHi(); // 가능
function sayHi() {
console.log("hi");
}
new User(); // ReferenceError
class User {}
함수 선언은 선언 전에 호출 가능한 형태로 준비되는 반면, class 선언은 이름이 스코프에 잡혀도 초기화 전 접근은 TDZ에 걸립니다. AI가 “선언은 위로 끌어올려진다”는 말 하나로 function/class/var/let/const를 같은 규칙처럼 설명하면 리뷰에서 바로 멈춰야 합니다. 실제 확인해야 할 것은 세 가지입니다.
| 선언 형태 | 선언 전 접근 감각 | 리뷰에서 볼 것 |
|---|---|---|
| function 선언 | 호출 가능 | 의도적으로 아래에 둔 API인지, 순환 의존인지 |
| class 선언 | TDZ 에러 | 생성 시점이 선언보다 앞서지 않는지 |
| var | undefined로 읽힐 수 있음 | 조용한 fallback/분기 오염이 없는지 |
| let/const | TDZ 에러 | 선언 순서가 데이터 흐름을 설명하는지 |
프론트엔드 코드에서는 function 선언이 나쁜 게 아닙니다. 컴포넌트 파일 안에서 작은 helper를 아래에 두고 위에서 읽는 스타일은 흔합니다. 다만 class나 const 함수식을 같은 식으로 다루면 깨집니다.
render(); // ReferenceError
const render = () => {
console.log("render");
};
이런 차이를 알고 있으면 “왜 이 함수만 위로 올려야 하지?” 같은 리뷰 코멘트가 더 정확해집니다.
스코프와 호이스팅이 실제 버그로 튀어나오는 대표 예시는 루프 안 콜백입니다.
function createHandlers() {
const handlers = [];
for (var index = 0; index < 3; index += 1) {
handlers.push(() => index);
}
return handlers;
}
console.log(createHandlers().map((handler) => handler())); // [3, 3, 3]
여기서 문제는 “화살표 함수라서”가 아닙니다. 세 콜백이 같은 index 바인딩을 닫아두었기 때문입니다. var는 함수 스코프 바인딩 하나를 만들고, 루프가 그 바인딩을 계속 바꿉니다. 루프가 끝난 뒤 index는 3입니다.
let은 반복마다 새 바인딩을 만듭니다.
function createHandlers() {
const handlers = [];
for (let index = 0; index < 3; index += 1) {
handlers.push(() => index);
}
return handlers;
}
console.log(createHandlers().map((handler) => handler())); // [0, 1, 2]
이 차이를 “var는 나쁘고 let은 좋다”로만 외우면 부족합니다. 리뷰 질문은 더 구체적이어야 합니다.
이 콜백들은 같은 바인딩을 공유해야 하는가, 반복마다 다른 바인딩을 닫아야 하는가?
대부분의 UI 핸들러는 반복마다 다른 항목 ID나 index를 닫아야 합니다. 하지만 일부 코드는 의도적으로 shared mutable binding을 사용합니다. 그러면 그 의도를 이름과 주석, 더 작은 함수로 드러내야 합니다.
const는 객체 불변이 아니다Day 5의 참조와 바로 이어집니다. const는 바인딩 재할당을 막습니다. 객체 내부 변경을 막지는 않습니다.
const config = { theme: "dark" };
config.theme = "light"; // 가능
config = {}; // TypeError
코드 리뷰에서 “const니까 안전하다”는 말이 나오면, 무엇이 안전한지 다시 물어야 합니다. 바인딩이 바뀌지 않는 것인지, 객체 내부가 바뀌지 않는 것인지 다릅니다. 내부 불변이 필요하면 업데이트 방식을 바꾸거나, Object.freeze, immutable data pattern, 타입 레벨 readonly 같은 별도 설계가 필요합니다.
const options = { retry: 3 };
function updateOptions() {
options.retry += 1;
}
이 코드는 const지만 전역 mutable object입니다. 테스트 사이에 값이 새거나, 이벤트 핸들러가 예전 가정을 들고 실행될 수 있습니다. “재할당하지 않는다”와 “상태가 변하지 않는다”를 구분해야 합니다.
AI는 var를 let/const로 기계적으로 바꾸는 리팩터링을 잘합니다. 많은 경우 이 변화는 맞습니다. 하지만 루프 콜백, 함수 스코프 의존 코드, hoisting에 기대던 오래된 코드에서는 의미가 바뀔 수 있습니다.
for (var i = 0; i < nodes.length; i += 1) {
nodes[i].onclick = function () {
console.log(i);
};
}
이 코드를 let으로 바꾸면 많은 UI 버그가 고쳐집니다. 각 핸들러가 자기 반복의 i를 닫기 때문입니다. 다만 기존 코드가 일부러 최종 index를 읽고 있었다면 동작이 바뀝니다. 좋은 리뷰는 “modern syntax로 바꿨다”에서 끝나지 않고, 각 콜백이 어떤 바인딩을 읽어야 하는지 확인합니다.
AI 코드에서 또 하나 자주 보이는 패턴은 선언을 위로 끌어올리며 의미를 흐리는 것입니다.
let selectedUser;
if (response.ok) {
selectedUser = response.user;
}
renderProfile(selectedUser);
이 코드는 타입이나 린트가 잡아주지 않으면 undefined 흐름을 만들기 쉽습니다. 차라리 분기 안에서 return하거나, 성공/실패 상태를 명시적으로 모델링하는 편이 낫습니다. 여기서 Effective TypeScript Day 6의 “유효한 상태만 표현하기”와 연결됩니다.
var 바인딩을 공유하고 있지 않은가?let/const 선언 전 접근이 TDZ 에러로 드러나는가, 아니면 var의 undefined가 조용히 흐르는가?const 객체를 내부에서 바꾸면서 불변처럼 말하고 있지 않은가?스코프 체인은 문법 지식보다 “이 함수가 어떤 바인딩을 기억하고 실행되는지”를 추적하는 도구에 가깝습니다. Day 7에서는 이 모델이 오래된 값(stale closure) 문제로 어떻게 이어지는지 봅니다.
const 객체 내부 변경과 얕은 복사 문제를 이어서 봐야 합니다.Post Q&A
자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.
프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.
this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.
콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.
클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.
스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.
객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.
객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.
객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.
==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.
자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.