자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
오늘의 질문: “클로저는 값을 복사해 둔 스냅샷일까, 변수 바인딩을 계속 따라가는 참조일까?”
원천 앵커: JavaScript Quizbook Chapter 03의 스코프/클로저 문제군 중
var루프 콜백, 렉시컬 환경, 함수 생성 시점, 콜백 지연 실행을 프론트엔드 리뷰 기준으로 다시 묶었습니다. Day 6이 “이름을 어디서 찾는가”였다면, Day 7은 “나중에 실행되는 함수가 어떤 바인딩을 계속 보고 있는가”입니다.
function makeCounters() {
const result = [];
for (var i = 0; i < 3; i += 1) {
result.push(function read() {
return i;
});
}
return result;
}
const [a, b, c] = makeCounters();
console.log(a(), b(), c());
출력은 3 3 3입니다. 세 함수가 각각 0, 1, 2를 복사해 둔 게 아니라, var i라는 같은 함수 스코프 바인딩을 닫아두었기 때문입니다. 루프가 끝난 뒤 그 바인딩의 값은 3입니다.
let으로 바꾸면 결과가 달라집니다.
for (let i = 0; i < 3; i += 1) {
result.push(() => i);
}
// 0, 1, 2
let은 반복마다 새 블록 바인딩을 만들기 때문에 각 콜백이 닫아두는 대상이 달라집니다.
클로저를 설명할 때 “함수가 바깥 값을 기억한다”라고만 말하면 절반만 맞습니다. 실제 리뷰에서는 무엇을 기억하는지 더 정확히 봐야 합니다.
function makeReader() {
let value = 1;
const read = () => value;
value = 2;
return read;
}
console.log(makeReader()()); // 2
read가 만들어질 때 value의 숫자 1을 복사했다면 출력은 1이어야 합니다. 하지만 함수는 렉시컬 환경의 value 바인딩을 따라가므로, 나중에 바뀐 2를 읽습니다. 반대로 원시값을 별도 상수에 담아 넘기면 그 지역 값은 더 이상 원본 객체 경로를 따라가지 않습니다.
function makeReader(user) {
const name = user.name;
return {
readSnapshot: () => name,
readLivePath: () => user.name,
};
}
이 차이가 코드 리뷰에서 중요합니다. readSnapshot은 함수 생성 시점의 지역 상수 name을 읽고, readLivePath는 같은 user 객체를 통해 현재 name을 다시 읽습니다. 클로저 버그는 “값이 복사됐나?”보다 “어떤 경로를 닫아두었나?”로 질문해야 빨리 보입니다.
“함수가 만들어질 때 값이 복사된다”라고 생각하면 클로저 버그를 잘못 고칩니다. 클로저가 기억하는 것은 보통 값 자체가 아니라 렉시컬 환경에 있는 바인딩입니다. 그래서 같은 바인딩을 여러 함수가 공유하면 나중 값이 같이 보이고, 렌더·이벤트·타이머 사이에 생성 시점이 어긋나면 오래된 경로를 읽습니다.
React에서 자주 보일 뿐, 원인은 자바스크립트 함수 생성 위치입니다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
}
의존성 배열이 비어 있으면 interval 콜백은 처음 렌더의 count 바인딩을 닫아둡니다. 화면에서는 값이 바뀌어도 이 콜백은 계속 첫 렌더의 경로를 볼 수 있습니다.
코드 리뷰에서는 “왜 count가 최신이 아니지?”보다 먼저 “이 함수가 어느 렌더에서 만들어졌지?”를 묻는 편이 낫습니다.
| 상황 | 의심할 부분 | 흔한 해결 방향 |
|---|---|---|
| 루프 안 콜백이 모두 같은 값 | 같은 var 바인딩 공유 | let, 별도 함수, 값 인자 전달 |
| 타이머가 오래된 상태 출력 | 콜백 생성 시점과 상태 변경 시점 분리 | 의존성 배열 보정, ref, updater 함수 |
| 이벤트 핸들러가 과거 props 사용 | 핸들러가 오래된 렌더에서 생성 | 최신 값을 인자로 넘기거나 핸들러 갱신 |
| async 후 상태 업데이트 꼬임 | 완료 시점이 생성 시점보다 늦음 | 취소 플래그, AbortController, functional update |
핵심은 “클로저를 없앤다”가 아닙니다. 클로저는 유용합니다. 다만 어떤 바인딩을 닫아두는지 코드가 말해주지 않으면 나중 실행에서 버그가 됩니다.
useEffect, useCallback, 이벤트 리스너의 의존성이 실제로 읽는 값과 맞는가?클로저를 값 저장소로 외우면 “왜 예전 값이지?”에서 멈춥니다. 바인딩 참조로 보면 생성 위치, 공유 여부, 실행 지연을 순서대로 추적할 수 있습니다.
React에서 stale closure를 고칠 때 의존성 배열만 늘리면 끝난다고 생각하기 쉽습니다. 하지만 실제로는 세 가지 선택지가 있습니다.
첫째, 콜백을 다시 만들고 다시 등록해야 하는 경우입니다. effect 안에서 읽는 값이 바뀌면 effect도 다시 실행되어야 합니다.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
둘째, 최신 값은 필요하지만 effect를 매번 재등록하고 싶지 않은 경우입니다. 이때는 ref가 “최신 값을 담는 mutable box” 역할을 할 수 있습니다.
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
셋째, 이전 상태에서 다음 상태를 계산하면 충분한 경우입니다. 이때는 functional update가 더 명확합니다.
setCount((previous) => previous + 1);
세 방식은 서로 대체재가 아닙니다. 다시 등록해야 하는 effect인지, 최신 값을 읽어야 하는 callback인지, 이전 값으로 다음 값을 계산하면 되는 state update인지 구분해야 합니다. 특히 functional update는 “최신 count를 closure로 읽기”가 아니라 “React가 넘겨주는 직전 상태를 기준으로 계산하기”입니다. 그래서 interval 안에서 증가만 필요할 때는 count를 의존성에 넣는 것보다 더 선명할 수 있습니다.
useEffect(() => {
const id = setInterval(() => {
setCount((previous) => previous + 1);
}, 1000);
return () => clearInterval(id);
}, []);
여기서 callback은 오래된 render에서 만들어졌지만, 다음 값을 계산하는 입력은 closure의 count가 아니라 updater가 받는 previous입니다. AI가 만든 코드를 볼 때 이 구분이 없으면 ref, dependency, updater를 아무 데나 섞게 됩니다.
stale closure는 타이머뿐 아니라 API 요청에서도 보입니다.
useEffect(() => {
search(query).then((result) => {
setResult(result);
});
}, [query]);
query가 빠르게 바뀌면 오래된 요청이 나중에 끝나 최신 결과를 덮어쓸 수 있습니다. 이 버그는 타입상으로는 잘 보이지 않습니다. 실행 순서의 문제입니다.
useEffect(() => {
let cancelled = false;
search(query).then((result) => {
if (!cancelled) setResult(result);
});
return () => {
cancelled = true;
};
}, [query]);
실제 앱에서는 AbortController를 쓰는 편이 더 나을 수 있습니다. 중요한 것은 closure가 붙잡은 query가 “요청 시작 시점의 query”라는 점을 코드가 인정해야 한다는 겁니다.
useEffect(() => {
const controller = new AbortController();
search(query, { signal: controller.signal })
.then((result) => {
setResult(result);
})
.catch((error) => {
if (error.name !== "AbortError") {
reportError(error);
}
});
return () => {
controller.abort();
};
}, [query]);
이 코드는 “최신 query를 클로저로 읽게 만들자”가 아닙니다. 요청마다 자기 controller와 query를 가진 effect를 만들고, 다음 effect가 시작될 때 이전 요청의 결과가 화면을 덮지 못하게 끊는 쪽에 가깝습니다. 클로저 문제를 최신값 읽기 하나로만 보면 ref를 과하게 쓰거나, 모든 값을 dependency에 밀어 넣는 쪽으로 흐릅니다. 실행 지연이 있는 코드는 생성 시점의 값, 완료 시점의 화면 상태, 취소 경계를 같이 리뷰해야 합니다.
퀴즈북의 클로저 문제는 짧은 출력 예측으로 끝나기 쉽지만, 실제 프론트엔드 리뷰에서는 아래처럼 질문을 바꿔야 합니다.
| 책의 문제 형태 | 실무에서 바꿔 묻기 |
|---|---|
| 루프 안 함수가 어떤 값을 출력하는가? | 이 이벤트 핸들러들은 같은 바인딩을 공유해야 하는가, 항목마다 다른 바인딩을 가져야 하는가? |
| 함수가 바깥 변수를 기억하는가? | 이 콜백이 기억한 값은 생성 시점 기준이어야 하는가, 실행 시점 최신값이어야 하는가? |
| 나중에 실행하면 값이 바뀌는가? | async 완료 결과가 더 최신 화면을 덮어쓸 수 있는가? |
let으로 바꾸면 해결되는가? | 바인딩 분리는 됐지만 취소/정리/재등록 경계도 맞는가? |
이렇게 보면 Day 7은 “클로저를 이해했다”에서 끝나지 않습니다. Day 8의 task/microtask 순서, Effective TypeScript Day 5의 async narrowing, React effect cleanup까지 이어지는 리뷰 언어가 됩니다.
AI는 stale closure 경고를 보면 의존성 배열에 값을 전부 넣는 쪽으로 기계적으로 고치는 경우가 많습니다. 어떤 경우에는 맞습니다. 하지만 이벤트 리스너나 interval에서는 매번 재등록이 비용이거나, 등록/해제 타이밍이 더 복잡한 버그를 만들 수 있습니다. 반대로 ref를 남발하면 React의 데이터 흐름을 우회하게 됩니다.
리뷰에서는 “lint 경고가 사라졌는가?”보다 “이 콜백은 최신 값을 읽어야 하는가, 생성 시점 값을 고정해야 하는가?”를 먼저 봐야 합니다.
await 뒤에는 값의 최신성을 다시 의심해야 합니다.Post Q&A
자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
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를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.