자바스크립트 퀴즈북 리마인드 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, 2let은 반복마다 새 블록 바인딩을 만들기 때문에 각 콜백이 닫아두는 대상이 달라집니다.
클로저를 설명할 때 “함수가 바깥 값을 기억한다”라고만 말하면 절반만 맞습니다. 실제 리뷰에서는 무엇을 기억하는지 더 정확히 봐야 합니다.
function makeReader() {
let value = 1;
const read = () => value;
value = 2;
return read;
}
console.log(makeReader()()); // 2read가 만들어질 때 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, 이벤트 리스너의 의존성이 실제로 읽는 값과 맞는가?
최신 값이 필요하다면 state updater, ref, 인자 전달 중 어떤 모델이 더 명확한가?
오래된 async 결과가 최신 화면 상태를 덮어쓸 가능성은 없는가?
클로저를 값 저장소로 외우면 “왜 예전 값이지?”에서 멈춥니다. 바인딩 참조로 보면 생성 위치, 공유 여부, 실행 지연을 순서대로 추적할 수 있습니다.
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 경고가 사라졌는가?”보다 “이 콜백은 최신 값을 읽어야 하는가, 생성 시점 값을 고정해야 하는가?”를 먼저 봐야 합니다.
Day 6 scope/hoisting은 이 글의 전제입니다. 함수가 생성된 위치의 바인딩을 기억한다는 모델이 먼저 필요합니다.
Day 8 event loop는 closure가 “나중에 언제 실행되는지”를 설명합니다. 오래된 값 문제는 생성 위치와 실행 시점이 함께 만들어냅니다.
Effective TypeScript Day 5의 async narrowing도 같은 위험을 다룹니다. await 뒤에는 값의 최신성을 다시 의심해야 합니다.
Post Q&A
자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.