Tech Sep 20, 2026

클로저가 어려운 사람들을 위해

우리는 이미 클로저를 쓰고 있다.

이벤트 핸들러나 배열 메서드를 익숙하게 사용하면서도, 클로저라는 이름 앞에서는 설명이 막힐 수 있습니다. ‘함수와 렉시컬 환경의 조합’이라는 정의를 읽어도 평소 작성하던 코드와 어떻게 연결되는지 선뜻 떠오르지 않기 때문입니다.

콜백 안에서 바깥 변수를 읽는 코드를 작성했다면, 이름을 의식하지 않았을 뿐 이미 클로저를 사용한 경험이 있습니다. 이 글에서는 상품을 찾는 함수와 React의 삭제 버튼을 따라가며 그 동작을 먼저 확인한 뒤, 같은 원리로 상태와 설정을 보관하는 방법까지 살펴보겠습니다.

익숙한 코드에서 바깥 변수 찾기

상품 목록에서 selectedId와 같은 ID를 가진 상품을 찾는 코드입니다.

function findProduct(products, selectedId) {
  return products.find((product) => product.id === selectedId);
}

const products = [
  { id: 'p1', name: '키보드' },
  { id: 'p2', name: '마우스' },
];

console.log(findProduct(products, 'p2').name); // 마우스

find가 배열을 순회하며 콜백을 호출할 때, 현재 요소는 product라는 매개변수로 전달됩니다. 비교에 사용하는 selectedId는 콜백의 매개변수에 없지만, 바깥 함수인 findProduct가 받은 값을 그대로 읽을 수 있습니다.

find는 콜백에 현재 요소와 인덱스, 배열을 전달하며, 어떤 상품을 찾을지는 콜백이 판단하도록 맡깁니다. 이 예제에서는 콜백이 바깥의 selectedId를 읽어 비교하므로, find에 검색 조건을 전달하는 별도 인자를 추가할 필요가 없습니다. MDN의 find 콜백 설명

렉시컬 스코프는 변수를 찾는 기준입니다

화살표 함수가 selectedId를 읽으려면, 먼저 그 이름이 어느 변수를 가리키는지 정해야 합니다. 함수 안에 해당 선언이 없으므로 코드를 감싸고 있는 findProduct로 범위를 넓혀 매개변수 selectedId를 찾게 됩니다. 여기에도 없다면 더 바깥 범위를 따라갑니다.

렉시컬 스코프(lexical scope)는 함수를 어디서 호출했는지가 아니라, 코드의 어디에 정의했는지를 기준으로 사용할 수 있는 변수가 정해지는 규칙입니다.

이 예제에서 화살표 함수를 실제로 호출하는 쪽은 find이지만, selectedId를 찾는 기준은 화살표 함수가 작성된 위치입니다. 따라서 find의 내부 변수를 찾는 것이 아니라, 자신을 감싸는 findProduct의 매개변수를 읽습니다.

클로저는 함수와 바깥 환경의 연결입니다

findProduct(products, 'p2')를 실행하면 그 호출의 selectedId에는 'p2'가 전달되고, 함수 본문에서 화살표 함수가 만들어집니다. 이때 화살표 함수는 해당 호출의 바깥 변수에 접근할 수 있는 연결을 갖게 되므로, find에 전달된 뒤에도 그 selectedId를 읽어 비교할 수 있습니다.

클로저(closure)는 함수와 그 함수가 만들어진 렉시컬 환경의 조합입니다. 함수는 이 환경과의 연결을 통해 바깥 변수에 접근하며, 나중에 호출될 때도 그 연결을 사용할 수 있습니다.

‘렉시컬 환경’이라는 용어가 낯설다면, 지금은 함수가 바깥 변수를 찾아갈 수 있게 해 주는 환경으로 이해해도 됩니다. 렉시컬 스코프가 selectedId를 어디에서 찾을지 정하는 규칙이라면, 클로저는 함수가 만들어질 때 그 바깥 환경과 함께 연결되는 것을 설명합니다. MDN의 렉시컬 스코프와 클로저 설명

여기서 findProduct는 아직 실행 중이며, 콜백도 find 안에서 곧바로 호출됩니다. 그래도 화살표 함수가 생성될 때 외부 환경과의 연결이 만들어지므로 클로저를 사용하는 예제가 맞습니다. 바깥 함수의 실행이 끝난 뒤에도 변수를 사용할 수 있다는 것은 이 연결이 보여 주는 특징이지, 클로저가 만들어지기 위한 조건은 아닙니다.

삭제 버튼에서 클로저가 하는 일

React에서 상품 삭제 버튼을 만들 때도 같은 연결을 사용합니다.

function ProductItem({ product, onDelete }) {
  return (
    <button onClick={() => onDelete(product.id)}>
      {product.name} 삭제
    </button>
  );
}

버튼을 클릭하면 React가 이벤트 객체를 인자로 전달해 핸들러를 호출하지만, 삭제할 상품의 ID까지 전달해 주지는 않습니다. 위 화살표 함수는 컴포넌트가 받은 product와 onDelete를 바깥에서 읽을 수 있으므로, 이벤트 인자를 사용하지 않고도 해당 상품의 삭제를 요청할 수 있습니다. 컴포넌트 안에서 선언한 이벤트 핸들러가 props를 읽을 수 있다는 점은 React 공식 문서에서도 설명합니다.

컴포넌트가 핸들러를 만들 때는 이미 어떤 상품의 버튼인지 알고 있지만, 삭제 요청은 사용자가 클릭할 때 실행해야 합니다. 핸들러가 나중에도 product에 접근할 수 있기 때문에, 상품 ID를 DOM 속성에 따로 넣었다가 이벤트에서 다시 꺼내는 과정 없이 클릭 시점에 삭제를 요청할 수 있습니다.

배열 메서드와 이벤트 시스템은 정해진 인자를 전달해 콜백을 호출하므로, 검색 조건이나 상품 정보처럼 추가로 필요한 데이터는 콜백이 주변에서 읽도록 작성할 수 있습니다. 이런 코드를 쓸 때마다 클로저를 도입한다고 생각하지는 않아도, 함수와 외부 변수의 연결은 일상적인 콜백 안에서 계속 사용되고 있습니다.

바깥 함수의 실행이 끝나도 변수를 사용할 수 있습니다

앞의 콜백이 검색 조건이나 상품 정보를 읽을 때 사용한 연결은 바깥 함수의 실행이 끝난 뒤에도 유지될 수 있습니다. 호출할 때마다 숫자를 하나씩 올리는 카운터를 만들어 이 동작을 확인해 보겠습니다.

호출 사이에 이전 숫자를 보관할 수 있도록 count를 함수 안에 두고, 숫자를 올리는 increment와 현재 숫자를 읽는 getCount를 객체에 담아 반환하겠습니다. 아래 코드에서 반환된 객체를 사용하는 쪽은 두 함수를 통해 count를 읽거나 바꾸게 됩니다.

function createCounter() {
  let count = 0;

  return {
    increment() {
      count += 1;
    },
    getCount() {
      return count;
    },
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2

counter.increment()를 호출할 때는 이미 createCounter()의 실행이 끝났지만, 반환된 increment와 getCount는 여전히 그 안의 count를 읽고 수정할 수 있습니다. 바깥 함수의 실행이 끝나는 것과, 그 안에서 만든 변수를 더 이상 사용할 수 없게 되는 것은 서로 다른 일이기 때문입니다.

아래 시각화에서는 createCounter()가 안쪽 함수를 반환하는 경우와 반환하지 않는 경우를 비교합니다. 이 예제에서 호출이 끝난 뒤에도 count를 사용할 수 있는지는, 그 변수에 접근하는 함수가 바깥에 남아 있는지에 따라 달라집니다.

호출이 끝나면 안의 변수는 어떻게 될까
1 / 3
  1. function createCounter() {
  2. let count = 0;
  3. count += 1;
  4. }
createCounter() 실행 중
count 0
밖으로 나온 것
아직 없습니다

앞의 find 콜백과 지금의 카운터는 모두 자신이 만들어진 곳의 변수에 접근하지만, 그 연결을 사용하는 시점이 다릅니다.

비교할 내용find에 전달한 콜백카운터의 getCount
읽는 바깥 변수findProduct의 selectedIdcreateCounter의 count
예제에서 호출되는 시점findProduct 실행 중createCounter 실행이 끝난 뒤
외부 환경과 연결되는 시점화살표 함수가 만들어질 때getCount 메서드가 만들어질 때

카운터는 바깥 함수가 종료된 뒤에도 변수를 사용할 수 있어 클로저의 특징이 더 눈에 띕니다. 다만 종료 시점에 새로 연결되는 것은 아니며, 함수가 만들어질 때의 연결을 이후에도 사용하는 것입니다.

호출하는 곳이 달라져도 같은 변수를 읽습니다

getCount를 다른 함수에 넘겨 호출하더라도, 생성될 때 만들어진 외부 환경과의 연결은 유지됩니다. 따라서 앞서 selectedId를 찾았던 것처럼, count도 호출된 위치가 아닌 정의된 위치를 기준으로 찾게 됩니다. MDN의 클로저 설명

앞의 카운터를 만든 뒤, 이번에는 readCounter 안에서 getCount를 호출해 보겠습니다. readCounter에도 같은 이름의 count를 선언해 두었습니다.

function createCounter() {
  let count = 0;

  return {
    increment() {
      count += 1;
    },
    getCount() {
      return count;
    },
  };
}

function readCounter(counter) {
  const count = 100;
  return counter.getCount();
}

const counter = createCounter();
counter.increment();
counter.increment();

console.log(readCounter(counter)); // 2

readCounter에도 count가 있지만, getCount는 자신이 만들어진 createCounter 호출의 count를 읽으므로 결과는 100이 아닌 2입니다. 호출하는 쪽에 같은 이름의 변수를 선언해도 기존 함수가 참조하는 대상은 바뀌지 않습니다.

저장되는 것은 숫자의 복사본이 아닙니다

처음 getCount를 만들 때 count는 0이었으므로, 생성 당시의 숫자를 복사해 두었다면 두 번 증가시킨 뒤에도 0을 반환해야 합니다. 실제로는 increment와 getCount가 같은 변수를 사용하기 때문에, increment가 바꾼 값인 2를 읽게 됩니다.

클로저를 두고 ‘함수가 값을 기억한다’고 표현하기도 하지만, 이 예제에서는 두 함수가 같은 count에 계속 접근할 수 있다고 이해하는 편이 정확합니다. 숫자 0을 따로 보관한 것이 아니므로, 이후 변경도 조회 결과에 반영됩니다.

카운터마다 별도의 상태를 보관하기

두 버튼의 클릭 횟수를 따로 세려면 카운터도 각자의 숫자를 보관해야 합니다. 앞에서 만든 createCounter를 두 번 호출하면 호출마다 별도의 count가 생겨, 같은 코드로 독립된 카운터를 만들 수 있습니다.

const first = createCounter();
const second = createCounter();

first.increment();
first.increment();
second.increment();

console.log(first.getCount()); // 2
console.log(second.getCount()); // 1

first의 두 메서드는 첫 번째 호출에서 만든 count를, second의 두 메서드는 두 번째 호출에서 만든 count를 함께 사용합니다. 각 카운터 안에서는 두 메서드가 같은 변수를 다루지만 카운터끼리는 변수를 공유하지 않으므로, 한쪽을 증가시켜도 다른 쪽의 값에는 영향을 주지 않습니다.

반환된 객체에는 increment와 getCount만 공개되어 있으므로, 사용하는 쪽에서 count에 직접 새 값을 대입할 수도 없습니다. count는 createCounter 안의 지역 변수로 남아 있고, 외부에서는 increment를 호출하는 방법으로만 값을 바꾸게 됩니다.

이처럼 내부 데이터와 이를 다루는 함수를 묶고 외부에 공개할 접근 경로를 정하는 것을 캡슐화(encapsulation)라고 합니다. 이후 감소 메서드를 추가하면서 숫자가 음수가 되지 않도록 제한하고 싶다면, 그 메서드 안에서 조건을 검사해 사용하는 쪽마다 같은 검사를 반복하지 않도록 할 수 있습니다.

외부에서 수정할 수 있는 범위는 무엇을 반환하느냐에 따라 달라집니다. 이 카운터는 조회할 때 숫자를 반환하지만, 내부 객체를 그대로 반환하는 구현이라면 외부에서도 그 객체의 속성을 수정할 수 있으므로 클로저만으로 모든 데이터가 보호된다고 보기는 어렵습니다.

설정을 기억하는 함수 만들기

카운터처럼 바뀌는 상태를 보관하는 것 외에도, 함수를 만들 때 정한 설정을 이후 호출에서 재사용할 수 있습니다. 로그마다 어느 기능에서 남긴 메시지인지 표시하는 경우를 예로 들어 보겠습니다.

function log(scope, message) {
  console.log(`[${scope}] ${message}`);
}

log('payment', '결제 요청');
log('payment', '결제 완료');

결제 기능의 로그를 남길 때마다 같은 scope를 전달하고 있으므로, 로그 함수를 만들 때 한 번 정해 두고 이후에는 메시지만 받도록 바꿔 보겠습니다.

function createLogger(scope) {
  return function log(message) {
    console.log(`[${scope}] ${message}`);
  };
}

const paymentLog = createLogger('payment');
const deliveryLog = createLogger('delivery');

paymentLog('결제 요청'); // [payment] 결제 요청
paymentLog('결제 완료'); // [payment] 결제 완료
deliveryLog('배송 시작'); // [delivery] 배송 시작

createLogger를 호출할 때 전달한 scope는 반환된 log에서도 읽을 수 있으므로, paymentLog를 호출할 때는 메시지만 전달하면 됩니다. deliveryLog는 별도의 호출에서 받은 배송 기능의 이름을 사용해, 로그 형식을 공유하면서도 기능별 설정은 따로 유지합니다.

설정을 받아 그에 맞는 함수를 만드는 이런 방식을 함수 팩토리라고 부르며, API 클라이언트의 기본 주소나 검증 함수의 허용 길이를 미리 정해 두는 데도 활용할 수 있습니다. scope와 message를 나눈 것처럼, 여러 호출에서 유지할 설정과 호출마다 달라지는 입력을 구분할 때 유용합니다.

Zustand에서도 같은 구조를 사용합니다

직접 만든 카운터에서 사용한 상태 보관 방식은 React 상태 관리 라이브러리인 Zustand에서도 찾아볼 수 있습니다. 스토어를 만드는 코드에서 상태를 어디에 선언하고, 반환된 함수들이 어떻게 접근하는지 따라가 보겠습니다.

스토어는 상태를 보관하면서 변경이 생기면 미리 등록된 함수들에 알리는데, 이렇게 알림을 받을 함수를 등록하는 것을 구독이라고 부릅니다. 앞의 카운터에 숫자 대신 상태 객체를 넣고, 변경을 알릴 함수들의 목록을 더하면 아래와 같은 형태가 됩니다.

아래는 Zustand v5.0.8의 vanilla.ts 55~89행을 바탕으로 작성한 설명용 코드입니다. (원문에서 타입, 초기 상태 조회, 동일성 검사, 부분 병합과 교체 옵션을 덜어냈습니다. 원문 전체를 대체하는 구현은 아니며, 아래 setState는 전달받은 값으로 상태 전체를 교체합니다.)

function createStore(createState) {
  // 1. 클로저에 의해 보호되고 유지될 렉시컬 환경 변수들
  let state;                      // 스토어의 현재 상태 값을 보관
  const listeners = new Set();    // 상태 변경 시 알림을 받을 구독 함수(콜백) 목록

  // 2. 외부로 공개되어 state와 listeners에 접근할 메서드/함수 정의
  const getState = () => state;

  const setState = (nextState) => {
    const previousState = state;
    state = nextState;
    // 상태가 변경되면 등록된 모든 구독자(listener)에게 이전 상태와 새 상태를 전달
    listeners.forEach((listener) => listener(state, previousState));
  };

  const subscribe = (listener) => {
    // 세트(Set)에 구독 함수 추가
    listeners.add(listener);
    
    // !중요: 반환되는 해제 함수는 생성될 시점의 `listeners`와 `listener` 참조를 함께 쥐고 있음
    return () => listeners.delete(listener);
  };

  const api = { getState, setState, subscribe };
  
  // 초기 상태 설정 (createState 콜백 실행)
  state = createState(setState, getState, api); 
  return api;
}

// ----------------------------------------------------
// 사용 예시 및 클로저 동작 과정
// ----------------------------------------------------

// 스토어 생성: createStore()가 실행을 마치고 종료되지만, 
// 내부의 state와 listeners는 반환된 store 객체의 메서드들에 의해 캡슐화되어 유지됩니다.
const store = createStore(() => ({ count: 0 }));

// 구독 등록: subscribe 실행 시 () => listeners.delete(listener) 형태의 해제 함수가 반환되며,
// 이 해제 함수는 고유한 listener 매개변수 식별자를 기억함
const unsubscribe = store.subscribe((state) => {
  console.log(state.count);
});

store.setState({ count: 1 }); // listeners 순회 실행 -> "1" 출력
console.log(store.getState().count); // "1" 출력

// 구독 해제: unsubscribe() 호출 시 반환되었던 화살표 함수가 실행되어 
// 렉시컬 스코프상의 listeners에서 정확히 해당 구독자만 제거함
unsubscribe();

store.setState({ count: 2 }); // listeners가 비어 있으므로 console.log 실행되지 않음

getState와 setState는 같은 state를 읽고 쓰며, subscribe는 그 옆에 선언된 listeners에 구독 함수를 추가합니다. 세 함수 모두 스토어를 만들 때의 변수에 계속 접근할 수 있어, createStore의 실행이 끝난 뒤에도 상태와 구독 목록을 관리할 수 있습니다.

subscribe가 반환하는 () => listeners.delete(listener)에는 스토어의 listeners뿐 아니라, 해당 구독을 등록할 때 받은 listener도 연결되어 있습니다. 이 덕분에 구독을 여러 번 등록하더라도 해제 함수마다 자신이 제거할 대상을 구분할 수 있습니다.

여기서 클로저는 상태와 구독 목록에 계속 접근할 수 있게 하고, 변경 알림은 listeners.forEach가 등록된 함수들을 실행하면서 전달합니다. 상태를 클로저에 보관한다고 React 화면이 자동으로 갱신되는 것은 아니며, 이 구독을 React에 연결하는 과정은 Zustand 코드 분석 글에서 이어서 볼 수 있습니다.

등록한 작업의 해제 함수를 반환하기

구독을 등록한 쪽에서는 나중에 그 구독을 해제할 방법도 필요합니다. subscribe가 반환한 함수에 정리에 필요한 정보가 연결되어 있으므로, 호출자는 목록이 Set인지 어떤 항목을 지워야 하는지 몰라도 반환받은 함수를 실행해 구독을 끝낼 수 있습니다.

다음 코드는 앞에서 만든 store에 서로 다른 구독 함수를 등록합니다.

// 1. 첫 번째 구독 등록
// subscribe()가 실행되면서 () => listeners.delete(firstListener) 형태의 해제 함수를 반환
// 이때 반환된 stopFirst 함수는 자신이 지워야 할 '첫 번째 listener'를 클로저로 기억
const stopFirst = store.subscribe((state) => {
  console.log('첫 번째 구독:', state.count);
});

// 2. 두 번째 구독 등록
// stopSecond 함수 역시 동일한 listeners Set을 바라보지만, 
// 자신이 지워야 할 '두 번째 listener' 참조를 독자적으로 쥐고 있음
const stopSecond = store.subscribe((state) => {
  console.log('두 번째 구독:', state.count);
});

// 3. 첫 번째 구독만 해제
// stopFirst()를 실행하면 렉시컬 환경에 보관되어 있던 '첫 번째 listener'만 Set에서 삭제됨
stopFirst();

// 4. 상태 변경
// listeners Set에는 여전히 두 번째 listener가 남아 있으므로 "두 번째 구독: 3"만 출력됨
store.setState({ count: 3 }); // 출력 -> 두 번째 구독: 3

// 5. 두 번째 구독 해제
// 남은 두 번째 listener까지 Set에서 제거
stopSecond();

stopFirst와 stopSecond가 접근하는 구독 목록은 같지만, 제거할 listener는 각각의 subscribe 호출에서 받은 함수입니다. 따라서 stopFirst()를 실행해도 두 번째 구독은 목록에 남아, 다음 상태 변경 때 숫자 3을 출력합니다.

이벤트나 타이머를 등록할 때도 정리에 필요한 정보를 사용하는 함수를 함께 만들어 반환할 수 있습니다. 시작할 때 정해진 정보를 종료할 때 다시 전달할 필요가 없어, 사용하는 쪽에서는 반환받은 해제 함수만 보관하면 됩니다.

익숙한 콜백에서 상태를 보관하는 함수까지

클로저는 find의 비교 함수가 검색 조건을 읽을 때도, Zustand의 메서드가 스토어 상태에 접근할 때도 같은 방식으로 동작합니다. 외부 변수와 연결된 함수를 호출 중에만 사용하는지, 반환해서 이후에도 사용하는지에 따라 활용 모습이 달라집니다.

익숙한 이벤트 핸들러를 다시 읽을 때, 그 안의 변수가 어디에 선언되어 있는지 따라가 보아도 좋겠습니다. 매개변수로 받은 값과 바깥에서 가져오는 값을 구분하다 보면, 이미 사용하던 코드에서 클로저가 필요한 자리를 찾을 수 있습니다.