자바스크립트 고급 #1 클로저와 스코프

자바스크립트 고급 시리즈 시작입니다. 기초/중급에서 다룬 도구들을 자유롭게 쓸 수 있게 됐다면, 이제는 그 도구들이 왜 그렇게 동작하는지를 들여다볼 차례입니다.

총 7편으로 구성됩니다.

  • #1 클로저와 스코프 ← 이번 글
  • #2 this 바인딩과 호출 패턴
  • #3 프로토타입 체인
  • #4 이벤트 루프와 태스크
  • #5 메모리 모델과 GC
  • #6 Symbol, WeakRef, Proxy
  • #7 모듈 시스템 깊이

이번 글은 자바스크립트의 가장 중요한 개념 중 하나 — 클로저.

스코프란 — 변수가 보이는 범위 #

먼저 스코프(scope) 의 의미를 정리합시다. 스코프는 변수가 어디서 보이는지를 규정합니다.

블록 스코프
{
  const a = 10;
  console.log(a);   // 10
}
console.log(a);     // ✗ ReferenceError

{} 안에서 선언한 변수는 그 안에서만 보입니다. 기초 #2 에서 본 let/const의 블록 스코프가 이것입니다.

함수 스코프 + 블록 스코프 #

자바스크립트는 두 가지 스코프를 가집니다.

  • 함수 스코프varfunction 선언이 따름. 함수 단위로 범위 결정.
  • 블록 스코프let/const가 따름. {...} 단위.

새 코드는 let/const를 쓰니 거의 블록 스코프만 신경 쓰면 됩니다.

렉시컬 스코프 — 어디 적었느냐로 결정 #

자바스크립트는 렉시컬(lexical) 스코프를 따릅니다. 변수가 어디서 보이는지가 함수가 어떻게 호출됐느냐가 아니라 어디 정의됐느냐로 결정됩니다.

렉시컬 스코프
const message = '바깥';

function inner() {
  console.log(message);   // '바깥' — 자기 위치에서 보이는 변수
}

function outer() {
  const message = '안쪽';
  inner();                 // 어디서 호출하든 결과는 같음
}

outer();   // '바깥'

innerouter 안에서 호출됐지만, innermessage자기가 정의된 위치에서 보이는 바깥 변수를 가리킵니다. 호출 위치가 아니라 정의 위치가 기준입니다.

클로저 — 함수가 자기 스코프를 들고 다님 #

기초 #4 에서 살짝 봤던 카운터 예제.

카운터 — 클로저
function createCounter() {
  let count = 0;

  return function() {
    count = count + 1;
    return count;
  };
}

const counter = createCounter();
counter();   // 1
counter();   // 2
counter();   // 3

createCounter가 끝났는데도 그 안의 count가 살아 있습니다. 반환된 함수가 자기가 만들어진 환경(스코프)을 끌고 다니기 때문입니다. 이걸 클로저(closure)라 부릅니다.

정확한 정의 #

클로저는 함수와, 그 함수가 만들어진 렉시컬 환경의 조합

함수 자체만이 아니라, 그 함수가 참조하는 외부 변수들까지 묶어서 부르는 이름입니다. 자바스크립트의 모든 함수는 사실 클로저입니다 — 다만 외부 변수를 안 쓰는 함수는 의미가 약할 뿐.

클로저가 만들어 내는 것 — private 상태 #

여러 인스턴스의 분리된 상태
function createCounter() {
  let count = 0;
  return {
    increment() { count++; return count; },
    decrement() { count--; return count; },
    get value() { return count; },
  };
}

const a = createCounter();
const b = createCounter();

a.increment();  // 1
a.increment();  // 2
b.increment();  // 1 — a와 무관

console.log(a.value);  // 2
console.log(b.value);  // 1

a.count;        // undefined — 외부에서 직접 접근 불가

각 호출마다 새로운 클로저가 만들어져 count를 따로 가집니다. 외부에서 a.count로 접근할 수 없습니다 — 모듈 패턴의 private 상태가 이렇게 만들어집니다.

ES2022의 #필드 (중급 #1) 가 등장하기 전, 자바스크립트의 private은 거의 클로저로 만들어졌습니다.

콜백과 클로저 — 가장 자주 만나는 패턴 #

비동기 콜백, 이벤트 핸들러는 사실상 클로저의 응용입니다.

이벤트 핸들러 — 클로저
function attachHandlers(buttons) {
  buttons.forEach((btn, i) => {
    btn.addEventListener('click', () => {
      console.log(`${i}번째 버튼 클릭`);
    });
  });
}

각 핸들러가 자기 차례의 i 값을 기억합니다. 함수가 살아 있는 동안 그 시점의 i도 같이 살아있습니다.

옛날의 함정 — var와 클로저 #

ES2015 이전 자바스크립트의 가장 유명한 함정.

var의 함정
function attachOld(buttons) {
  for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
      console.log(`${i}번째 버튼`);
    });
  }
}

이 코드는 모든 버튼이 마지막 i 값을 출력합니다. var가 함수 스코프라 모든 콜백이 같은 i를 공유했기 때문입니다.

let으로 바꾸면 해결됩니다.

let — 매 반복마다 새 변수
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log(`${i}번째 버튼`);
  });
}

let은 블록 스코프라 매 반복마다 새 i가 만들어져 각 콜백이 다른 i를 캡처합니다. let/const가 등장한 큰 이유 중 하나가 정확히 이 부분이었습니다.

클로저로 만드는 패턴들 #

1) 부분 적용 (Partial Application) #

인자를 미리 묶기
function add(a, b) {
  return a + b;
}

function partial(fn, ...preset) {
  return function(...rest) {
    return fn(...preset, ...rest);
  };
}

const add5 = partial(add, 5);
add5(3);   // 8
add5(10);  // 15

preset을 클로저가 기억해 둡니다. 함수형 프로그래밍의 흔한 도구입니다.

2) 메모이제이션 (Memoization) #

결과 캐싱
function memoize(fn) {
  const cache = new Map();
  return function(arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

const slowSquare = (n) => {
  console.log(`계산: ${n}`);
  return n * n;
};

const fastSquare = memoize(slowSquare);
fastSquare(5);   // 계산: 5 → 25
fastSquare(5);   // 25 (캐시)
fastSquare(3);   // 계산: 3 → 9

cache Map이 클로저로 살아남습니다. React의 useMemo, lodash의 memoize 모두 같은 아이디어입니다.

3) 디바운스 / 쓰로틀 #

디바운스
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const handleSearch = debounce((query) => {
  fetch(`/api/search?q=${query}`);
}, 300);

input.addEventListener('input', (e) => handleSearch(e.target.value));

timer 변수가 호출 사이에 살아 있어 이전 타이머를 취소합니다. 입력이 멈춘 뒤 300ms가 지나야 실제 호출이 나갑니다.

함정 — 너무 오래 살아남는 클로저 #

클로저가 외부 변수를 캡처하면 그 변수가 함수가 살아 있는 동안 GC 되지 않습니다.

메모리 누수 가능성
function setupHeavy() {
  const huge = new Array(1_000_000).fill(0);  // 큰 배열

  return function() {
    console.log('hi');
  };
}

const fn = setupHeavy();   // huge가 fn에 의해 살아남음 (필요 없는데도)

위 함수는 huge를 사용하지 않는데도 클로저가 캡처해서 메모리에 살아있습니다. 모던 자바스크립트 엔진은 사용하지 않는 변수는 캡처하지 않으려 노력 하지만, 의도적으로 큰 객체를 빠르게 풀어주려면 직접 null로 해 주는 게 안전합니다.

명시적으로 풀어주기
function setupHeavy() {
  let huge = new Array(1_000_000).fill(0);
  process(huge);
  huge = null;   // 더 이상 필요 없으면 풀기

  return function() { console.log('hi'); };
}

이 내용은 #5 메모리 모델 에서 더 자세히 다룹니다.

IIFE 다시 보기 — 옛 모듈 패턴 #

기초 #4 에서 IIFE를 잠시 봤습니다. 클로저 관점에서 다시 보면, 모듈 시스템이 없던 시절의 private 스코프 격리가 이거였습니다.

옛 IIFE 모듈 패턴
const counter = (function() {
  let count = 0;
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    get value() { return count; },
  };
})();

counter.increment();  // 1
counter.count;         // undefined — 못 봄

ES Modules가 등장한 뒤로는 거의 안 쓰지만, 옛 라이브러리 코드에 자주 등장합니다.

TDZ (Temporal Dead Zone) #

let/const에는 한 가지 흥미로운 동작이 있습니다.

TDZ
console.log(x);          // ✗ ReferenceError
let x = 10;

선언 줄 이전에 접근하면 에러가 납니다. varundefined 였는데 let에러를 던집니다.

이걸 임시 사각 지대(Temporal Dead Zone) 라 부릅니다. 변수가 선언되는 줄 이전에는 “존재하지만 사용 불가” 상태입니다. 실수로 선언 전에 사용하는 사고를 막아 줍니다.

마무리 #

이번 글에서 정리한 내용:

  • 스코프 — 변수가 보이는 범위 (블록 / 함수 스코프)
  • 자바스크립트는 렉시컬 스코프 — 정의 위치 기준
  • 클로저 = 함수 + 그 함수의 렉시컬 환경
  • private 상태, 콜백, 부분 적용, 메모이제이션, 디바운스 모두 클로저
  • var가 만드는 옛 함정 — let이 해결
  • 클로저는 캡처한 변수의 메모리를 살려둠 — 큰 객체는 명시적으로 풀기
  • TDZ — let/const는 선언 전 접근 시 에러

다음 글(#2 this 바인딩과 호출 패턴)에서는 자바스크립트의 또 다른 헷갈리는 개념 — this가 호출 방식에 따라 어떻게 결정되는지, call/apply/bind의 의미와 화살표 함수의 차이를 다룹니다.

X