Devin.KR
로그인

자바스크립트 스코프와 클로저 - 반복문 var 버그와 상태 감추기 (JS 중급 9단원)

개발자 조회 1

이 단원에서 배우는 것

초급 8단원까지 변수와 자료형, 함수, 배열과 객체, DOM 과 이벤트를 다뤘다. 거기까지는 "문법이 이렇게 생겼고 이렇게 쓰면 된다"였다. 중급은 "왜 그렇게 동작하는가"로 넘어가는 구간이고, 그 첫 관문이 스코프와 클로저다. 이 단원부터 16단원까지는 사내 도서 대출 서비스 하나를 계속 손본다. 도서(book)·회원(member)·대출(loan) 세 가지 데이터가 매 단원 그대로 이어진다. 기준 환경은 ES2022 이상(Node.js 18 이상, 2022년 이후 브라우저)이다.

  • 스코프 체인이 선언된 위치로 결정된다는 것과, 그래서 클로저가 무엇을 붙잡는지 설명한다.
  • 대출번호 발급기·조회 캐시처럼 클로저가 실제로 쓰이는 자리를 직접 만든다.
  • var 반복문에서 콜백이 전부 같은 값을 보는 고전 버그의 원인을 짚고 세 가지 방법으로 고친다.

왜 필요한가

도서 대출 시스템에서 대출번호를 발급한다고 하자. 가장 먼저 떠오르는 코드는 이렇다.

let loanSeq = 0;

function nextLoanId() {
  loanSeq += 1;
  return 'L2026-' + String(loanSeq).padStart(4, '0');
}

동작은 한다. 문제는 loanSeq 가 밖에 노출돼 있다는 점이다. 같은 파일 어디서든 loanSeq = 0 을 할 수 있고, 나중에 반납번호 발급기를 추가하면 returnSeq, reserveSeq 가 줄줄이 전역에 쌓인다. 누가 언제 그 값을 건드렸는지 추적하려면 파일 전체를 읽어야 한다.

필요한 건 함수만 밖으로 내보내고 그 함수가 쓰는 상태는 안에 가두는 것이다. 자바스크립트에는 이것을 위한 별도 문법이 없다. 대신 함수가 자기가 만들어진 자리의 변수를 계속 붙잡고 있다는 성질을 쓴다. 이 성질을 클로저라고 부른다. 정의를 외워서 쓸모가 생기는 개념이 아니라, 위 문제를 풀다 보면 자연스럽게 도달하는 도구다.

문법과 예제

스코프는 호출 위치가 아니라 선언 위치로 정해진다

변수를 찾을 때 자바스크립트는 "지금 실행 중인 함수 안 → 그 함수가 적혀 있던 바깥 → 그 바깥 → … → 전역" 순서로 올라간다. 이 사슬을 스코프 체인이라 한다. 중요한 건 적혀 있던이다. 어디서 호출됐는지는 아무 상관이 없다.

const branch = '본관';

function printBranch() {
  console.log(branch);   // 선언 위치 기준으로 바깥을 본다
}

function run() {
  const branch = '분관';  // 이건 printBranch 와 무관하다
  printBranch();
}

run();   // '본관'

printBranchrun 안에서 호출됐지만 runbranch 는 보이지 않는다. printBranch적힌 곳의 바깥은 전역이기 때문이다. 이 규칙을 렉시컬 스코프라고 한다. 다음 단원에서 다룰 this 는 정반대로 호출 방식에 따라 달라지는데, 두 규칙이 다르다는 점이 혼란의 출발점이다.

클로저: 함수가 바깥 변수를 데리고 다닌다

이제 발급기를 함수 안에 가둔다.

function createLoanIdGenerator(prefix) {
  let seq = 0;                       // 밖에서 접근할 방법이 없다

  return function () {
    seq += 1;
    return `${prefix}-${String(seq).padStart(4, '0')}`;
  };
}

const nextLoanId = createLoanIdGenerator('L2026');
console.log(nextLoanId());   // L2026-0001
console.log(nextLoanId());   // L2026-0002
console.log(nextLoanId());   // L2026-0003

const nextReserveId = createLoanIdGenerator('R2026');
console.log(nextReserveId()); // R2026-0001  ← 서로 다른 seq 를 가진다

createLoanIdGenerator 는 이미 끝났는데 seq 는 왜 살아 있는가. 반환된 함수가 seq 를 참조하고 있으니 그 변수를 담고 있던 환경이 함께 유지되기 때문이다. 그리고 createLoanIdGenerator 를 두 번 호출하면 환경도 두 개 생긴다. nextLoanIdnextReserveIdseq 는 완전히 별개다.

여러 함수가 하나의 환경을 공유한다

반환값을 객체로 만들면 여러 함수가 같은 상태를 공유하되 밖에서는 그 상태에 손댈 수 없다.

function createLoanDesk(limit) {
  const loans = new Map();   // bookId -> memberId

  return {
    borrow(bookId, memberId) {
      if (loans.size >= limit) throw new Error(`대출 한도 ${limit}건 초과`);
      if (loans.has(bookId)) throw new Error(`${bookId} 는 이미 대출 중`);
      loans.set(bookId, memberId);
      return loans.size;
    },
    giveBack(bookId) {
      return loans.delete(bookId);
    },
    get count() {
      return loans.size;
    },
    list() {
      return [...loans.entries()].map(([bookId, memberId]) => ({ bookId, memberId }));
    }
  };
}

const desk = createLoanDesk(2);
desk.borrow('B001', 'M01');
desk.borrow('B003', 'M01');
console.log(desk.count);        // 2
console.log(desk.loans);        // undefined ← 밖에서는 보이지 않는다

try {
  desk.borrow('B005', 'M02');
} catch (e) {
  console.log(e.message);       // 대출 한도 2건 초과
}

desk.loansundefined 인 것이 핵심이다. 상태를 읽는 방법은 countlist() 뿐이고, 바꾸는 방법은 borrowgiveBack 뿐이다. 한도 검사를 건너뛸 수 있는 경로가 없다. 11단원에서 배울 class#private 필드가 같은 일을 문법으로 해 주지만, 클로저 쪽이 더 오래됐고 지금도 라이브러리 내부에서 흔하다.

실무형 예제: 조회 캐시

도서 상세 조회가 느리다고 하자. 같은 bookId 를 반복 조회하는 화면이라면 캐시를 붙인다. 캐시 저장소가 전역에 있으면 테스트마다 오염되므로, 클로저 안에 둔다.

function withCache(loader, { maxSize = 100 } = {}) {
  const cache = new Map();
  let hit = 0;
  let miss = 0;

  function get(key) {
    if (cache.has(key)) {
      hit += 1;
      return cache.get(key);
    }
    miss += 1;
    const value = loader(key);
    if (cache.size >= maxSize) {
      cache.delete(cache.keys().next().value);   // 가장 오래된 것부터 버린다
    }
    cache.set(key, value);
    return value;
  }

  get.stats = () => ({ hit, miss, size: cache.size });
  get.clear = () => { cache.clear(); hit = 0; miss = 0; };
  return get;
}

const BOOK_DB = {
  B001: { id: 'B001', title: '자바스크립트 완벽 가이드', stock: 2 },
  B002: { id: 'B002', title: '리팩터링', stock: 0 }
};

const findBook = withCache((id) => {
  console.log(`  [DB 조회] ${id}`);
  return BOOK_DB[id] ?? null;
});

findBook('B001');
findBook('B001');
findBook('B002');
console.log(findBook.stats());   // { hit: 1, miss: 2, size: 2 }

Map 은 삽입 순서를 유지하므로 cache.keys().next().value 가 가장 먼저 들어온 키다. 함수도 객체이므로 get.stats 처럼 프로퍼티를 붙일 수 있다.

실무에서 자주 틀리는 것

1. var 반복문 안에서 만든 콜백이 전부 마지막 값을 본다

도서 목록에 버튼을 달거나 타이머를 거는 코드에서 매번 나온다.

const bookIds = ['B001', 'B002', 'B003'];
const handlers = [];

for (var i = 0; i < bookIds.length; i++) {
  handlers.push(function () { return bookIds[i]; });
}

console.log(handlers.map(h => h()));
// [ undefined, undefined, undefined ]   ← i 가 전부 3 이다

원인은 var함수 단위 변수라서다. 반복이 세 번 돌아도 i 는 하나뿐이고, 세 함수가 모두 그 하나를 붙잡는다. 반복이 끝난 시점의 i 는 3 이므로 셋 다 3 을 본다.

여기서 오해하기 쉬운 지점이 있다. 클로저가 붙잡는 것은 값이 아니라 변수 자체다. 함수를 만들 때의 값을 복사해 두는 게 아니라, 실행하는 순간 그 변수를 다시 읽는다. 그래서 나중에 바뀐 값이 보인다.

고치는 방법은 세 가지다.

// (1) let — 반복마다 새 바인딩이 생긴다. ES6 이후로는 이게 정답이다.
for (let i = 0; i < bookIds.length; i++) {
  handlers.push(() => bookIds[i]);
}

// (2) 즉시 실행 함수로 값을 인자에 복사 — let 이 없던 시절 관용구. 오래된 코드에서 만난다.
for (var j = 0; j < bookIds.length; j++) {
  (function (captured) {
    handlers.push(function () { return bookIds[captured]; });
  })(j);
}

// (3) 애초에 반복문을 쓰지 않는다 — 14단원에서 다룬다.
const handlers3 = bookIds.map((id) => () => id);

주의할 점: let 이 통하는 것은 for (let i = ...) 처럼 반복문 헤더에서 선언했을 때다. for (const id of bookIds) 도 마찬가지로 안전하다. 하지만 반복문 바깥에서 let 으로 선언하고 안에서 대입만 하면 var 와 똑같이 깨진다.

2. 클로저가 큰 데이터를 계속 붙잡아 메모리가 안 빠진다

클로저는 자기가 참조하는 바깥 변수를 살려 둔다. 콜백을 어딘가에 등록해 두면 그 콜백이 참조하는 것들도 함께 산다.

function startLoanPolling(bookList) {   // bookList 가 수만 건이라고 하자
  const timerId = setInterval(() => {
    console.log(`감시 중: ${bookList.length}건`);
  }, 1000);

  return () => clearInterval(timerId);   // 정지 함수를 반드시 돌려준다
}

const stop = startLoanPolling(bigBookList);
// ... 화면을 떠날 때
stop();   // 이걸 부르지 않으면 bigBookList 는 영원히 회수되지 않는다

브라우저에서는 addEventListener 로 등록한 핸들러도 같다. 등록만 하고 removeEventListener 를 안 하면, 그 핸들러가 붙잡은 DOM 노드와 데이터가 그대로 남는다. 콜백을 등록하는 함수를 만들 때는 해제 함수를 반환하는 것을 습관으로 삼는다. 그리고 콜백 안에서 정말 필요한 값만 참조한다. 위 예에서 길이만 쓴다면 const total = bookList.length 로 숫자만 붙잡는 편이 낫다.

3. 선언 전에 접근하면 undefined 가 아니라 에러다

letconst 도 호이스팅은 된다. 다만 선언문에 도달하기 전까지는 접근이 금지된 구간(TDZ, temporal dead zone)에 있다.

function report() {
  console.log(varCount);    // undefined   (var 는 이렇게 나온다)
  console.log(letCount);    // ReferenceError: Cannot access 'letCount' before initialization
  var varCount = 1;
  let letCount = 2;
}

이게 오히려 좋은 성질이다. var 는 오타로 선언 전에 읽어도 undefined 로 조용히 넘어가고, 문제가 한참 뒤에 터진다. let 은 그 자리에서 멈춘다. 중급부터는 var 를 쓸 이유가 없다. 재할당하면 let, 안 하면 const 다.

4. 클로저를 반복 호출 안에서 새로 만든다

// 나쁨: 렌더링마다 캐시가 새로 생겨 캐시 역할을 전혀 못 한다
function renderBookRow(id) {
  const findBook = withCache(loadBookFromDb);   // ← 매번 새 Map
  return findBook(id);
}

// 좋음: 캐시는 한 번만 만든다
const findBook = withCache(loadBookFromDb);
function renderBookRow(id) {
  return findBook(id);
}

클로저를 만드는 함수(createXxx, withXxx)는 모듈 최상단이나 초기화 시점에 한 번만 호출한다. 호출할 때마다 상태가 새로 생긴다는 것이 클로저의 장점이자 함정이다.

스스로 확인하기

  1. 아래 코드의 출력은 무엇이며, 왜 그런가.
    const make = () => {
      let n = 0;
      return { up: () => ++n, get: () => n };
    };
    const a = make();
    const b = make();
    a.up(); a.up(); b.up();
    console.log(a.get(), b.get());
  2. 도서별 대출 횟수를 세는 createLoanCounter() 를 만들어라. count('B001') 을 부르면 그 도서의 누적 횟수를 1 늘리고 늘어난 값을 반환하고, count.top() 은 가장 많이 대출된 bookId 를 반환한다. 내부 저장소는 밖에서 보이면 안 된다.
  3. 다음 코드는 ['B001', 'B002', 'B003'] 을 1초 간격으로 출력하려 했으나 undefined 만 세 번 나온다. 원인을 한 문장으로 쓰고, var 를 그대로 둔 채 고쳐라.
    const ids = ['B001', 'B002', 'B003'];
    for (var i = 0; i < ids.length; i++) {
      setTimeout(() => console.log(ids[i]), (i + 1) * 1000);
    }

정답

  1. 2 1. make() 를 두 번 호출했으므로 n 을 담은 환경도 두 개 만들어졌다. ab 는 서로 다른 n 을 붙잡는다. 같은 make 함수에서 나왔다는 사실은 상태 공유와 무관하다.
  2. function createLoanCounter() {
      const counts = new Map();
    
      function count(bookId) {
        const next = (counts.get(bookId) ?? 0) + 1;
        counts.set(bookId, next);
        return next;
      }
    
      count.top = () => {
        let bestId = null;
        let best = -1;
        for (const [id, n] of counts) {
          if (n > best) { best = n; bestId = id; }
        }
        return bestId;
      };
    
      return count;
    }
    
    const count = createLoanCounter();
    count('B001'); count('B003'); count('B001');
    console.log(count.top());   // B001
  3. 원인: var i 는 반복문 전체에서 변수 하나뿐이라 세 콜백이 같은 i 를 붙잡고, 타이머가 실행될 때 그 값은 이미 3 이다. var 를 유지하며 고치려면 값을 인자로 복사한다.
    for (var i = 0; i < ids.length; i++) {
      (function (idx) {
        setTimeout(() => console.log(ids[idx]), (idx + 1) * 1000);
      })(i);
    }
    실무에서는 varlet 으로 바꾸는 것이 정답이다. 이 문제는 즉시 실행 함수 관용구를 읽을 수 있는지 확인하기 위한 것이다.