LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값

2026년 6월 28일·10분 읽기
오늘의 질문: “클로저는 값을 복사해 둔 스냅샷일까, 변수 바인딩을 계속 따라가는 참조일까?”
원천 앵커: JavaScript Quizbook Chapter 03의 스코프/클로저 문제군 중 var 루프 콜백, 렉시컬 환경, 함수 생성 시점, 콜백 지연 실행을 프론트엔드 리뷰 기준으로 다시 묶었습니다. Day 6이 “이름을 어디서 찾는가”였다면, Day 7은 “나중에 실행되는 함수가 어떤 바인딩을 계속 보고 있는가”입니다.

먼저 맞혀보기

js
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으로 바꾸면 결과가 달라집니다.

js
for (let i = 0; i < 3; i += 1) {
  result.push(() => i);
}
// 0, 1, 2

let은 반복마다 새 블록 바인딩을 만들기 때문에 각 콜백이 닫아두는 대상이 달라집니다.

스냅샷이 아니라 “환경을 향한 링크”로 보기

클로저를 설명할 때 “함수가 바깥 값을 기억한다”라고만 말하면 절반만 맞습니다. 실제 리뷰에서는 무엇을 기억하는지 더 정확히 봐야 합니다.

js
function makeReader() {
  let value = 1;

  const read = () => value;
  value = 2;

  return read;
}

console.log(makeReader()()); // 2

read가 만들어질 때 value의 숫자 1을 복사했다면 출력은 1이어야 합니다. 하지만 함수는 렉시컬 환경의 value 바인딩을 따라가므로, 나중에 바뀐 2를 읽습니다. 반대로 원시값을 별도 상수에 담아 넘기면 그 지역 값은 더 이상 원본 객체 경로를 따라가지 않습니다.

js
function makeReader(user) {
  const name = user.name;

  return {
    readSnapshot: () => name,
    readLivePath: () => user.name,
  };
}

이 차이가 코드 리뷰에서 중요합니다. readSnapshot은 함수 생성 시점의 지역 상수 name을 읽고, readLivePath는 같은 user 객체를 통해 현재 name을 다시 읽습니다. 클로저 버그는 “값이 복사됐나?”보다 “어떤 경로를 닫아두었나?”로 질문해야 빨리 보입니다.

틀리기 쉬운 직감

“함수가 만들어질 때 값이 복사된다”라고 생각하면 클로저 버그를 잘못 고칩니다. 클로저가 기억하는 것은 보통 값 자체가 아니라 렉시컬 환경에 있는 바인딩입니다. 그래서 같은 바인딩을 여러 함수가 공유하면 나중 값이 같이 보이고, 렌더·이벤트·타이머 사이에 생성 시점이 어긋나면 오래된 경로를 읽습니다.

클로저와 오래된 바인딩 리뷰 지도

stale closure는 “React만의 버그”가 아니다

React에서 자주 보일 뿐, 원인은 자바스크립트 함수 생성 위치입니다.

jsx
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를 더 작게 분해하기

React에서 stale closure를 고칠 때 의존성 배열만 늘리면 끝난다고 생각하기 쉽습니다. 하지만 실제로는 세 가지 선택지가 있습니다.

첫째, 콜백을 다시 만들고 다시 등록해야 하는 경우입니다. effect 안에서 읽는 값이 바뀌면 effect도 다시 실행되어야 합니다.

jsx
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => clearInterval(id);
}, [count]);

둘째, 최신 값은 필요하지만 effect를 매번 재등록하고 싶지 않은 경우입니다. 이때는 ref가 “최신 값을 담는 mutable box” 역할을 할 수 있습니다.

jsx
const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current);
  }, 1000);

  return () => clearInterval(id);
}, []);

셋째, 이전 상태에서 다음 상태를 계산하면 충분한 경우입니다. 이때는 functional update가 더 명확합니다.

jsx
setCount((previous) => previous + 1);

세 방식은 서로 대체재가 아닙니다. 다시 등록해야 하는 effect인지, 최신 값을 읽어야 하는 callback인지, 이전 값으로 다음 값을 계산하면 되는 state update인지 구분해야 합니다. 특히 functional update는 “최신 count를 closure로 읽기”가 아니라 “React가 넘겨주는 직전 상태를 기준으로 계산하기”입니다. 그래서 interval 안에서 증가만 필요할 때는 count를 의존성에 넣는 것보다 더 선명할 수 있습니다.

jsx
useEffect(() => {
  const id = setInterval(() => {
    setCount((previous) => previous + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

여기서 callback은 오래된 render에서 만들어졌지만, 다음 값을 계산하는 입력은 closure의 count가 아니라 updater가 받는 previous입니다. AI가 만든 코드를 볼 때 이 구분이 없으면 ref, dependency, updater를 아무 데나 섞게 됩니다.

async closure와 취소 경계

stale closure는 타이머뿐 아니라 API 요청에서도 보입니다.

jsx
useEffect(() => {
  search(query).then((result) => {
    setResult(result);
  });
}, [query]);

query가 빠르게 바뀌면 오래된 요청이 나중에 끝나 최신 결과를 덮어쓸 수 있습니다. 이 버그는 타입상으로는 잘 보이지 않습니다. 실행 순서의 문제입니다.

jsx
useEffect(() => {
  let cancelled = false;

  search(query).then((result) => {
    if (!cancelled) setResult(result);
  });

  return () => {
    cancelled = true;
  };
}, [query]);

실제 앱에서는 AbortController를 쓰는 편이 더 나을 수 있습니다. 중요한 것은 closure가 붙잡은 query가 “요청 시작 시점의 query”라는 점을 코드가 인정해야 한다는 겁니다.

jsx
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가 만든 코드에서 자주 보이는 실수

AI는 stale closure 경고를 보면 의존성 배열에 값을 전부 넣는 쪽으로 기계적으로 고치는 경우가 많습니다. 어떤 경우에는 맞습니다. 하지만 이벤트 리스너나 interval에서는 매번 재등록이 비용이거나, 등록/해제 타이밍이 더 복잡한 버그를 만들 수 있습니다. 반대로 ref를 남발하면 React의 데이터 흐름을 우회하게 됩니다.

리뷰에서는 “lint 경고가 사라졌는가?”보다 “이 콜백은 최신 값을 읽어야 하는가, 생성 시점 값을 고정해야 하는가?”를 먼저 봐야 합니다.

연결해서 읽기

Day 6 scope/hoisting은 이 글의 전제입니다. 함수가 생성된 위치의 바인딩을 기억한다는 모델이 먼저 필요합니다. Day 8 event loop는 closure가 “나중에 언제 실행되는지”를 설명합니다. 오래된 값 문제는 생성 위치와 실행 시점이 함께 만들어냅니다. Effective TypeScript Day 5의 async narrowing도 같은 위험을 다룹니다. await 뒤에는 값의 최신성을 다시 의심해야 합니다.

Day 7 복습 퀴즈

Quiz1 / 5
Q.var를 쓴 첫 예제의 출력은 무엇일까요?

Post Q&A

오케이징에게 물어보기

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/javascript-quizbook
파일 13개, 폴더 0개
자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을 수 없을까자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네 가지 방법자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와 ToPrimitive 흐름자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티 descriptor와 freeze의 경계자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이 정한다자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도 프로토타입을 읽는다자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

같은 섹션의 대표 이미지

13 posts · latest first
ESM live binding과 dynamic import, circular dependency 리뷰 포인트를 보여주는 module graph 다이어그램
Study26. 07. 09.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.

ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
DOM 이벤트가 capture 단계로 내려가고 bubble 단계로 올라가는 경로 다이어그램
Study26. 07. 08.

자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.

DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
인스턴스에서 프로토타입과 Object.prototype으로 이어지는 프로퍼티 조회 경로 다이어그램
Study26. 07. 07.

자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도.

프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 07.SEOJing
입력, 순수 계산, 출력, 부수 효과 경계를 나누어 함수 설계를 보여주는 다이어그램
Study26. 07. 06.

자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다.

고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.

26. 07. 06.SEOJing
메서드 호출, 분리된 함수, 이벤트 콜백, bind와 arrow function의 this 차이를 보여주는 다이어그램
Study26. 07. 05.

자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이.

this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.

26. 07. 05.SEOJing
콜 스택이 비워진 뒤 마이크로태스크 큐가 먼저 실행되고 다음 태스크로 넘어가는 이벤트 루프 다이어그램
Study26. 07. 04.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와.

콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
클로저가 생성 위치의 바인딩을 기억해 나중 실행에서 오래된 값을 읽을 수 있음을 보여주는 다이어그램
Study26. 06. 29.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된.

클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
스코프 체인과 호이스팅 관계를 단순화한 다이어그램
Study26. 06. 28.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅.

스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.

26. 06. 28.SEOJing
JavaScript 객체 참조와 Map, Set, WeakMap의 참조 구조를 비교한 다이어그램
Study26. 06. 27.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사,.

객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 27.SEOJing
JavaScript 프로퍼티 descriptor가 data descriptor와 accessor descriptor로 갈라지는 구조 다이어그램
Study26. 06. 26.

자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티.

객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
객체가 ToPrimitive를 거쳐 + 연산자의 문자열 연결 또는 숫자 덧셈으로 갈라지는 흐름 다이어그램
Study26. 06. 25.

자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와.

객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.

26. 06. 25.SEOJing
==, ===, Object.is, SameValueZero의 차이를 정리한 JavaScript equality 다이어그램
Study26. 06. 24.

자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네.

==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.

26. 06. 24.SEOJing
JavaScript Number, safe integer, NaN, -0, BigInt를 한 장으로 정리한 다이어그램
Study26. 06. 23.

자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을.

자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 23.SEOJing