FEInterview Prep

javascript · high priority

JavaScript 클로저 & 스코프

렉시컬 환경 · 스코프 체인 · Stale Closure — React Hooks 의 근간

intermediate 난이도4시간토스카카오네이버당근배민
시작 전
이해도
매우 낮음

학습 개요

탄생 배경

쉬운 설명

복잡한 개념을 실생활 비유로 설명합니다.

퇴근하면서 사무실 책상을 가방에 넣어 나가기

함수(사람) 가 사무실(스코프) 에서 작업한 뒤 나갈 때, 책상(지역 변수) 을 가방(클로저) 에 넣고 나갑니다. 사무실 자체(실행 컨텍스트) 는 이미 닫혔지만, 책상 위의 메모(변수) 는 가방 안에 남아 나중에도 꺼내 쓸 수 있습니다. 여러 사람이 같은 책상을 공유했다면, 한 사람이 내용을 바꾸면 다른 사람도 같은 변화를 봅니다.

핵심 개념

자바스크립트는 **렉시컬(정적) 스코프** 언어입니다. 변수의 유효 범위가 함수가 어디서 호출됐느냐가 아니라 **어디서 선언됐느냐** 로 결정된다는 뜻입니다. 엔진은 코드를 파싱하는 시점에 각 함수가 참조할 수 있는 변수 환경을 이미 알고 있고, 런타임에는 그 "설계도" 대로만 찾아갑니다.

동적 스코프였다면? (자바스크립트는 렉시컬이라 이렇게 동작하지 않음)javascript
1let x = 'outer';
2
3function printX() {
4 console.log(x); // ← 어떤 x?
5}
6
7function caller() {
8 let x = 'inner';
9 printX();
10}
11
12caller();
13// 실제 출력: 'outer'
14//
15// printX 의 x 는 "printX 가 선언된 위치" 를 기준으로 렉시컬 탐색되므로
16// caller 의 x 는 전혀 보이지 않는다.
17// (동적 스코프 언어라면 'inner' 가 찍힐 것)

Lexical Environment

현재 스코프의 식별자 → 값 매핑과, 부모 Lexical Environment 로의 참조(outer) 를 담는 구조.

Variable Environment

var 선언과 함수 선언을 담는 환경. let/const 와는 분리되어 있음.

Execution Context

함수 실행 단위. Lexical Environment, Variable Environment, this 바인딩, 코드 실행 상태를 묶은 것.

실무 적용

어떤 상황에서 사용하는가

React 컴포넌트에서 socket 이벤트를 수신해 최신 사용자 정보를 보여줘야 하는데, 이벤트가 항상 로그인 직후의 "옛날 user" 만 찍힌다.

어떻게 적용하는가

효과 콜백이 클로저로 첫 렌더의 user 를 캡처한 것이 원인이다. 두 가지 중 선택: (1) deps 에 user 를 넣어 user 가 바뀔 때마다 socket 리스너를 재바인딩. (2) useRef 로 최신 user 를 보관하고 리스너 안에서는 ref.current 를 읽는다. (2) 는 재바인딩 비용이 없지만 대신 React 렌더-데이터 동기화가 느슨해질 수 있으므로 이 점을 팀이 인지한 채로 써야 한다.

흔한 실수와 안티패턴

  • useEffect 의 deps 를 비워두고 그 안에서 state 를 읽기 → stale closure 의 대표 원인
  • setState 를 여러 번 연달아 호출할 때 `setCount(count + 1)` 를 반복 → 같은 count 를 캡처하므로 최종 +1 만 반영. 함수형 업데이트 사용.
  • 이벤트 핸들러에서 외부 DOM 참조를 클로저로 보관한 뒤 removeEventListener 누락 → 메모리 누수
  • debounce/throttle 을 렌더마다 새로 만들어버려 내부 timer 가 매번 초기화 — `useCallback` 이나 `useRef` 로 안정화
  • for + var 로 async 콜백 배열을 만들고 각각 다른 i 를 기대 → 실제로는 하나의 i 공유

흔한 오해

오해

"클로저는 특별한 문법이다."

교정

자바스크립트에서 함수는 **모두** 클로저다 — 선언된 곳의 환경을 항상 기억한다.

왜 중요

스펙상 모든 함수 객체는 [[Environment]] 슬롯에 생성 시 Lexical Environment 참조를 가진다. "클로저 함수 / 일반 함수" 가 따로 있는 것이 아니다.

오해

"클로저는 값을 복사해서 들고 다닌다."

교정

값이 아니라 **변수 바인딩(참조)** 을 캡처한다 — 나중에 그 변수가 바뀌면 클로저가 보는 값도 바뀐다.

왜 중요

이 차이 때문에 var + 루프 문제, stale closure, React Hooks 동작이 설명된다.

오해

"var 의 함수 스코프만 이해하면 let/const 도 그냥 비슷한 것이다."

교정

let/const 는 블록 스코프 + TDZ 라 클로저의 캡처 타이밍이 바뀐다 — 루프 안에서 iteration 마다 새 바인딩이 만들어진다.

왜 중요

for (let i ...) 가 iteration 당 새로운 i 를 생성한다는 ES2015 스펙의 특수 규칙 때문에, var 로 재현 안 되는 의미가 나온다.

면접 질문

중급토스카카오네이버당근

답변 방향 힌트

var 의 스코프와 클로저의 "참조 캡처" 특성을 함께 설명하세요.

반드시 언급할 키워드

  • var 는 함수 스코프 — 루프 안에 생성된 i 가 하나
  • 세 콜백 모두 동일한 i 를 참조
  • setTimeout 이 실행될 즈음엔 루프가 끝나 i=3
  • 따라서 출력은 3,3,3
  • 수정 1: var → let (iteration 마다 새 바인딩)
  • 수정 2: IIFE 로 값 고정

예상 꼬리 질문

  • for (let ...) 이 iteration 마다 새 바인딩을 만든다는 사실은 어떤 스펙에서 정의되나요?
  • `for (const ...)` 도 같은 특성을 가지나요?

자기 점검

스크롤 올리지 말고 답해보세요. 클로저를 한 문장으로 정의하면?

기대 키워드

함수선언 시점렉시컬 환경참조유지

자주 하는 오해

"특정 함수 타입" 으로 이해하기 쉽지만, 자바스크립트의 모든 함수가 클로저입니다. 중요한 포인트는 "선언 시점의 렉시컬 환경" 이 함수와 함께 heap 에 유지된다는 것입니다.

React 에서 useEffect deps 를 비워두고 그 안에서 state 를 읽으면 왜 stale 해지나요?

기대 키워드

렌더 스냅샷클로저참조첫 렌더함수형 업데이트

자주 하는 오해

"deps 를 비워두면 매번 최신이 자동으로 반영된다" 고 거꾸로 오해하기 쉽지만, 실제로는 deps 가 비어있을수록 effect 가 다시 생성되지 않아 첫 렌더의 state 를 영원히 참조합니다.

var 와 let 의 스코프 차이가 클로저 캡처에서 실제로 어떤 행동 차이를 만드는지 설명해보세요.

기대 키워드

함수 스코프블록 스코프iteration새 바인딩for-let

자주 하는 오해

"let 은 재할당이 안 된다" 는 식의 피상적 차이로만 기억하면, for 루프에서 iteration 당 새 바인딩이 만들어진다는 본질을 놓칩니다. 이 차이가 클로저 캡처에서 결정적 차이를 만듭니다.

학습 자료