Grace
grace's dev_note
Grace
전체 방문자
오늘
어제
  • 분류 전체보기
    • FrontEnd
      • Next.js
      • React
      • ReactNativ..
      • Vue
    • Javascript
      • 러닝 자바스크립트
      • 모던 자바스크립트
    • CS
    • DataScienc..
      • Data Struc..
      • LeetCode
    • BackEnd
      • Express
      • Node.js
      • Nest.js
    • DevOps
      • Docker
    • 매일메일
    • 회고
    • 코드캠프

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 자바스크립트
  • nest.js
  • 알고리즘
  • pinia
  • javascript
  • node.js
  • Vue3
  • Vite
  • 번들러
  • Express
  • vitejs
  • Vue.js
  • 함수
  • React Native
  • PostgreSQL
  • backend
  • Vue
  • postgres
  • tanstack
  • vue-query

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Grace

grace's dev_note

매일메일

클로저(Closure) 완벽 정리 — 자바스크립트 개발자라면 반드시 알아야 할 핵심 개념

2025. 10. 12. 12:04

클로저(Closure)는 자바스크립트의 핵심 개념 중 하나로, 함수가 선언될 때의 환경(스코프)을 기억하는 함수를 말합니다.


🧩 1. 기본 개념

자바스크립트에서는 함수가 선언될 때의 스코프(lexical scope) 를 기억합니다.
이때, 내부 함수(inner function) 가 자신을 둘러싼 외부 함수(outer function) 의 변수에 접근할 수 있을 때,
그 관계를 유지한 채로 외부 함수가 종료된 이후에도 접근 가능한 구조 — 이것이 바로 클로저입니다.


📘 2. 예시 코드로 이해하기

function makeCounter() {
  let count = 0; // 외부 함수의 지역 변수

  return function() { // 내부 함수 (클로저)
    count++;
    return count;
  };
}

const counter = makeCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

👉 무슨 일이 일어나고 있을까?

  1. makeCounter()가 실행되면 지역 변수 count가 생성됩니다.
  2. 내부 함수가 return되며, 이 함수는 count에 접근할 수 있는 권한을 가진 채 밖으로 나갑니다.
  3. makeCounter()의 실행은 끝났지만, 내부 함수가 count를 참조 중이기 때문에 GC(가비지 컬렉터) 가 그 변수를 제거하지 않습니다.
  4. 그 결과, counter()를 호출할 때마다 count 값이 유지되고 증가합니다.

🔍 3. 클로저의 특징 요약

항목 설명
정의 함수가 선언될 당시의 스코프를 기억하여, 외부 함수의 변수에 접근할 수 있는 내부 함수
형태 함수 안의 함수
생성 시점 함수가 선언될 때(정의 시점)
소멸 시점 외부 함수가 끝나도 내부 함수가 참조 중이면 메모리에서 유지
주요 효과 데이터 은닉, 상태 유지, 모듈화 가능

🧱 4. 클로저의 대표적인 활용 예시

✅ (1) 데이터 은닉 / 캡슐화

 
function createAccount() {
  let balance = 0;

  return {
    deposit(amount) {
      balance += amount;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createAccount();
account.deposit(1000);
console.log(account.getBalance()); // 1000

→ 외부에서는 balance를 직접 수정할 수 없고, 오직 deposit() / getBalance()로만 접근 가능.


✅ (2) 이벤트 핸들러에서 상태 유지

function createClickCounter(button) {
  let count = 0;

  button.addEventListener('click', function() {
    count++;
    console.log(`Clicked ${count} times`);
  });
}

→ 각 버튼마다 클릭 횟수를 “기억”하는 클로저를 가짐.


✅ (3) React에서의 예시 (useState와 비슷한 원리)

 
function useCounter() {
  let count = 0;

  function increment() {
    count++;
    console.log(count);
  }

  return [() => count, increment];
}

const [getCount, increment] = useCounter();
increment(); // 1
increment(); // 2
console.log(getCount()); // 2

⚠️ 5. 주의할 점

문제 설명
메모리 누수 클로저가 너무 많은 변수를 오래 참조하면 메모리 점유가 계속 유지될 수 있음
의도치 않은 값 공유 반복문 안에서 var를 사용하면 모든 클로저가 같은 변수를 참조하게 됨
해결법 let 사용 또는 즉시실행함수(IIFE)로 스코프 분리
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000); // 3 3 3
}

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000); // 0 1 2
}

⚛️ 6. React / TypeScript 실전 예제

React에서는 클로저가 매우 자주 사용됩니다.
특히 useEffect, useState, 이벤트 핸들러, 비동기 로직에서요.

✅ 예제 1: useEffect와 클로저

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log("count:", count); // ⚠️ 클로저 발생
    }, 1000);

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

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}
 
 

🧠 여기서 무슨 일이 일어날까?

  • useEffect는 mount 시점에 한 번만 실행됩니다 ([]).
  • 따라서 setInterval 안의 함수는 초기 렌더링 당시의 count를 기억하는 클로저를 만듭니다.
  • 이후 버튼을 눌러도 count가 증가해도,
    console.log("count:", count)는 0만 계속 찍습니다!

이건 바로 클로저의 “환경을 기억하는 성질” 때문이에요.

✅ 해결 방법

현재의 count 값이 필요하다면 함수형 업데이트나 ref를 이용해야 합니다.

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => {
      console.log("count:", c);
      return c + 1;
    });
  }, 1000);
  return () => clearInterval(interval);
}, []);

이렇게 하면 setCount의 인자 (c)가 매번 최신 상태를 전달받기 때문에 클로저 문제를 피할 수 있습니다.


✅ 예제 2: 이벤트 핸들러에서 상태 유지

import { useState } from "react";

export default function ToggleButton() {
  const [isOn, setIsOn] = useState(false);

  function handleClick() {
    // handleClick이 선언될 때의 스코프를 기억함 (클로저)
    console.log("현재 상태:", isOn);
    setIsOn(!isOn);
  }

  return <button onClick={handleClick}>{isOn ? "ON" : "OFF"}</button>;
}

📌 동작 설명

  • handleClick 함수는 렌더링될 때마다 새롭게 만들어집니다.
  • 하지만 이벤트 핸들러 내부의 isOn은 그 함수가 생성된 당시의 state 값을 기억합니다.
  • React는 매 렌더마다 새로운 클로저를 만들기 때문에,
    이벤트가 트리거될 때 현재 렌더의 상태값을 참조하게 됩니다.

즉, React는 클로저를 매 렌더마다 새로 만들어서 “이전 상태 유지” 대신 “현재 상태 반영”을 합니다.


✅ 예제 3: 비동기 요청에서 클로저 주의하기

 
useEffect(() => {
  let isMounted = true;

  async function fetchData() {
    const res = await fetch("/api/data");
    const data = await res.json();

    if (isMounted) {
      setData(data);
    }
  }

  fetchData();

  return () => {
    isMounted = false;
  };
}, []);

⚠️ 왜 이렇게 하냐면:

  • fetchData가 실행되는 동안 컴포넌트가 언마운트되면 setData()를 호출하면 에러가 납니다.
  • isMounted 변수가 “현재 컴포넌트가 살아 있는지”를 클로저로 기억하고,
    cleanup에서 false로 바꿔주는 패턴이에요.

⚛️ 7. 클로저, 그리고 useCallback / useMemo / 비동기 버그의 진짜 이유

🧩 1️⃣ 클로저와 React의 관계

React 컴포넌트는 렌더링될 때마다 함수가 새로 호출됩니다.
즉, 컴포넌트 내부의 모든 변수, 함수, state 참조가 매 렌더마다 새롭게 만들어집니다.

→ 따라서 “이전 렌더의 변수나 state를 참조하는 클로저”가 발생할 수 있어요.
React의 모든 훅은 사실상 “클로저 위에서 작동하는 구조”입니다.


⚙️ 2️⃣ useCallback / useMemo에서 클로저가 중요한 이유

💡 (1) useCallback은 함수를 “메모이제이션”한다

 
const handleClick = useCallback(() => {
  console.log(count);
}, []);

❌ 문제:

  • [] 의존성 배열이 비어 있으면 count의 초기값만 기억한 클로저가 유지됩니다.
  • 즉, 이후 count가 바뀌어도 handleClick은 옛날 count를 콘솔에 찍습니다.

✅ 해결:

의존성 배열에 count를 넣어야, React가 새로운 count를 클로저에 캡처한 새 함수를 만들어줍니다.

 
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

즉,

  • useCallback은 클로저의 “스냅샷”을 저장하는 훅,
  • deps는 “이 클로저를 언제 새로 만들어야 하는가”를 React에게 알려주는 장치예요.

⚙️ (2) useMemo도 똑같이 클로저를 기억한다

 
const doubled = useMemo(() => {
  console.log('memoized');
  return count * 2;
}, []);
  • []로 두면 처음 count만 기억하고, 이후 변경돼도 다시 계산하지 않아요.
  • [count]를 넣으면 새 count로 다시 계산합니다.

👉 useMemo의 콜백도 결국 클로저입니다.
React는 “의존성 배열을 기준으로 클로저를 새로 만들지 말지”를 결정하죠.


⚠️ 3️⃣ stale closure(오래된 클로저) 문제

🧠 개념

Stale Closure란, 오래된 변수 값을 기억하는 클로저 때문에
최신 state나 props를 읽지 못하는 버그를 말합니다.


🧨 예제: setTimeout 안에서 발생하는 stale closure

function Timer() {
  const [count, setCount] = useState(0);

  const start = () => {
    setTimeout(() => {
      console.log("Count:", count); // ❌ 항상 오래된 count!
    }, 2000);
  };

  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={start}>Start</button>
    </>
  );
}

🧩 원인:

  • start 함수는 렌더링 시점의 count를 기억하는 클로저를 만듭니다.
  • 2초 후 콜백 실행 시점엔 이미 여러 번 렌더링이 일어났을 수 있지만,
    setTimeout 내부 클로저는 여전히 **“start 버튼을 눌렀던 순간의 count”**만 가지고 있습니다.

🧱 4️⃣ stale closure 방지 패턴 정리

✅ (1) 최신 값을 가져오려면 함수형 업데이트 사용

 
setCount(c => c + 1);

→ 이 패턴은 React가 내부적으로 최신 state를 전달해주기 때문에,
클로저에 오래된 state가 남아 있어도 안전합니다.


✅ (2) useRef로 최신 값을 추적하기

 
function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count; // 항상 최신값으로 갱신
  }, [count]);

  const start = () => {
    setTimeout(() => {
      console.log("Count:", countRef.current); // ✅ 항상 최신값
    }, 2000);
  };

  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={start}>Start</button>
    </>
  );
}

👉 useRef는 렌더링과 무관하게 변수를 지속적으로 유지하므로, stale closure를 방지할 수 있습니다.


✅ (3) useEvent (React 19+ or useCallbackRef 패턴)

React 19부터 공식적으로 useEvent 훅이 추가됩니다.
이는 “항상 최신 클로저를 보장”하는 훅입니다.

 
import { useEvent } from "react";

function Timer() {
  const [count, setCount] = useState(0);

  const onTimeout = useEvent(() => {
    console.log("Count:", count); // ✅ 항상 최신 count
  });

  const start = () => setTimeout(onTimeout, 2000);

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

⚡️ useEvent는 렌더링 시점을 새로 캡처하지 않고, 내부적으로 최신 클로저를 유지합니다.


✅ (4) 커스텀 훅으로 안전한 비동기 로직 관리하기

 
function useSafeAsync(callback: () => void, deps: any[]) {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  return useCallback((...args: any[]) => {
    callbackRef.current(...args);
  }, []);
}

사용 예시:

 
const safeLog = useSafeAsync(() => console.log(count), [count]);

→ 이렇게 하면, 오래된 클로저가 아닌 항상 최신 상태를 참조하는 함수를 반환합니다.


🧠 요약 정리

클로저는 단순히 “함수 안의 함수”가 아니라,
함수가 선언될 당시의 스코프를 기억하는 함수예요.

이를 통해 함수 외부에서 접근할 수 없는 변수를 안전하게 유지하고,
필요할 때마다 그 값을 참조하거나 수정할 수 있죠.

즉, 클로저는

  • 상태를 기억하는 함수,
  • 데이터를 은닉하는 도구,
  • 그리고 React의 useCallback / useMemo 같은 훅에서 핵심적으로 작동하는 메커니즘이에요.

하지만 너무 많은 변수를 참조하면 메모리 누수가 생길 수 있으니,
의도를 명확히 하고 꼭 필요한 경우에만 사용하는 습관이 중요합니다.

결국,

클로저를 이해한다는 건 자바스크립트의 “함수”와 “스코프”를 진짜로 이해한다는 뜻이에요.

저작자표시 비영리 변경금지 (새창열림)

'매일메일' 카테고리의 다른 글

실행 컨텍스트(Execution Context) 완벽 이해  (0) 2025.10.18
브라우저 렌더링 최적화: Reflow와 Repaint 완벽 이해  (0) 2025.10.18
    '매일메일' 카테고리의 다른 글
    • 실행 컨텍스트(Execution Context) 완벽 이해
    • 브라우저 렌더링 최적화: Reflow와 Repaint 완벽 이해
    Grace
    Grace
    기술 및 회고 블로그

    티스토리툴바