면접에서 "클로저가 뭔가요?"라는 질문을 받았을 때, 정의만 읊고 끝나면 아쉽다. 중요한 건 왜 이게 필요한지, 그리고 실제로 어디에 쓰이는지를 아는 것이다.
사실 우리는 이미 클로저를 매일 쓰고 있다. React의 useState, useEffect, useCallback 같은 훅들이 전부 클로저를 기반으로 동작하기 때문이다. 클로저를 모르면 React에서 왜 이상한 버그가 생기는지 이해할 수 없고, 의존성 배열도 그냥 ESLint가 빨간 줄 그으니까 넣는 수준에 머무르게 된다.
클로저를 이해하려면 먼저 스코프(Scope)를 알아야 한다.
function sayHello() {let message = "안녕하세요";console.log(message);}sayHello(); // "안녕하세요"console.log(message); // ReferenceError: message is not defined
함수 안에서 만든 message는 함수 안에서만 쓸 수 있다. 함수 밖에서 접근하려고 하면 에러가 난다. 이게 렉시컬 스코프(Lexical Scope) 규칙이다.
금고를 생각해보자. 금고 안에 넣은 물건은 금고 문을 열어야만 꺼낼 수 있다. 아무 데서나 손을 뻗어서 금고 안의 물건을 집을 수 있다면 금고의 의미가 없다.
변수도 마찬가지다. 함수 안에서 만든 변수를 아무 데서나 접근할 수 있다면, 코드가 복잡해졌을 때 어디서 이 변수가 바뀌는지 추적할 수가 없다. 스코프는 변수를 보호하는 울타리 같은 것이다.
function getCount() {let count = 0;count++;return count;}console.log(getCount()); // 1console
getCount()를 아무리 호출해도 결과는 항상 1이다. 함수가 끝날 때마다 count가 메모리에서 사라지고, 다음 호출 때 다시 0으로 만들어지기 때문이다.
그렇다면 함수가 끝나도 변수를 유지하고 싶으면 어떻게 해야 할까?
let count = 0;function increment() {count++;return count;}console.log(increment()); // 1
전역 변수를 쓰면 값이 유지된다. 하지만 이 방식은 문제가 많다.
| 문제 | 설명 |
| 오염 | 다른 코드에서 실수로 같은 이름의 변수를 쓰면 충돌 |
| 추적 어려움 | 프로젝트가 커지면 이 변수가 어디서 바뀌는지 찾기 힘듦 |
| 보안 취약 | 아무 데서나 count = 9999처럼 조작 가능 |
변수를 함수 안에 숨기면서도, 필요할 때 접근할 수 있는 방법이 필요하다. 이게 클로저다.
클로저는 함수가 자신이 생성된 환경을 기억하는 것이다.
함수 안에서 다른 함수를 만들어서 리턴하면, 리턴된 함수는 자신이 태어난 곳의 변수들을 기억한다. 부모 함수가 끝나도 그 변수들은 사라지지 않는다.
function createCounter() {let count = 0;return {increase: function() {count++;return count;},
createCounter는 객체를 리턴하는데, 이 객체 안의 함수들이 count를 사용하고 있다.
const counter = createCounter();console.log(counter.getCount()); // 0counter.increase();counter.increase();console
createCounter()가 끝났는데도 count가 살아있다! increase, decrease, getCount 함수들이 count를 붙잡고 있기 때문이다.
const counterA = createCounter();const counterB = createCounter();counterA.increase();counterA.increase();counterA.increase();
createCounter()를 호출할 때마다 새로운 count가 만들어진다. 각 카운터는 서로 영향을 주지 않는다.
일반적으로 함수가 끝나면 그 안의 변수는 가비지 컬렉션 대상이 된다. 아무도 그 변수를 참조하지 않으니까.
그런데 클로저는 다르다. 리턴된 함수가 외부 변수를 참조하고 있다.
increase: function() {count++; // count를 참조 중return count;}
자바스크립트 엔진은 "아, 이 함수가 count를 쓰고 있네. 지우면 안 되겠다"라고 판단해서 메모리에 남겨둔다.
좀 더 정확히 말하면, 함수는 생성될 때 자신의 렉시컬 환경을 저장한다. 렉시컬 환경이란 그 함수가 접근할 수 있는 변수들의 목록이다.
counter 객체 내부┌─────────────────────────────────┐│ increase 함수 ││ decrease 함수 ││ getCount 함수 ││ ───────────────────────────────││ [[Environment]] ││ └─ count: 0 │ ← 렉시컬 환경└─────────────────────────────────┘
counter가 메모리에 있는 한, 이 렉시컬 환경도 유지된다.
React의 useState도 클로저로 만들어졌다. 아주 단순화하면 이런 모습이다
function useState(initialValue) {let state = initialValue;const getState = () => state;const setState = (newValue) => {state
실제 useState는 렌더링 트리거 등 복잡한 로직이 있지만, 상태를 클로저로 기억한다는 핵심은 같다.
외부에서 직접 건드리면 안 되는 값을 보호할 수 있다.
function createWallet(initialMoney) {let money = initialMoney;return {deposit: function(amount) {if (amount > 0) {money
money에 직접 접근할 방법이 없다. 오직 deposit, withdraw 함수를 통해서만 조작할 수 있다.
특정 값을 기억하는 함수를 찍어낼 수 있다.
function createGreeting(greeting) {return function(name) {return `${greeting}, ${name}!`;};}
sayHello는 "안녕하세요"를, sayBye는 "안녕히 가세요"를 각각 기억하고 있다.
function once(fn) {let called = false;let result;return function(...args) {if (!called) {called =
called가 클로저로 유지되기 때문에, 처음 호출 이후로는 함수가 실행되지 않는다.
React에서 클로저 때문에 생기는 버그는 정말 흔하다. 이해 없이 넘어가면 계속 같은 실수를 반복하게 된다.
장바구니에 상품을 추가하는 버튼을 만들었다고 하자.
function Cart() {const [itemCount, setItemCount] = useState(0);const addItem = () => {// 버튼 클릭 1초 후에 수량 증가setTimeout
버튼을 연속으로 5번 빠르게 누르면 5개가 될 것 같지만, 실제로는 1개만 된다.
왜일까? addItem 함수가 만들어지는 시점에 itemCount는 0이다. setTimeout 안의 함수는 이 itemCount = 0을 클로저로 캡처한다. 5번 클릭해도 각각 0 + 1 = 1을 계산하는 것이다.
해결: 함수형 업데이트
const addItem = () => {setTimeout(() => {setItemCount(prev => prev + 1); // prev는 항상 최신 값}, 1000);
prev => prev + 1을 쓰면 React가 현재 최신 상태를 prev로 넘겨준다.
실시간으로 경과 시간을 표시하는 컴포넌트를 만들어보자.
function Stopwatch() {const [time, setTime] = useState(0);useEffect(() => {const timer = setInterval(()
화면에는 계속 "1초"만 표시된다.
useEffect가 처음 실행될 때 setInterval 안의 함수가 만들어진다. 이 함수는 그 순간의 time = 0을 클로저로 캡처한다. 의존성 배열이 비어있으니 effect가 다시 실행되지 않고, 함수는 계속 0 + 1 = 1만 계산한다.
해결 방법 1: 의존성 배열에 추가
useEffect(() => {const timer = setInterval(() => {setTime(time + 1);}, 1000);return
해결 방법 2: 함수형 업데이트
useEffect(() => {const timer = setInterval(() => {setTime(prev => prev + 1);}, 1000);
function TodoApp() {const [todos, setTodos] = useState([]);const [input, setInput] = useState("");const
input에 뭘 쓰고 추가 버튼을 눌러도 빈 문자열만 추가된다. useCallback이 처음 만들어질 때의 todos = [], input = ""을 기억하고 있기 때문이다.
해결: 의존성 배열에 추가
const addTodo = useCallback(() => {setTodos([...todos, input]);setInput("");}, [todos, input]);
또는 함수형 업데이트를 쓰면 todos 의존성을 없앨 수 있다.
const addTodo = useCallback(() => {setTodos(prev => [...prev, input]);setInput("");}, [input]);
for (var i = 0; i < 3; i++) {setTimeout(() => {console.log(i);}, 1000);
var는 함수 스코프라서 반복문 전체가 하나의 i를 공유한다. 반복문이 끝나면 i는 3이고, 세 함수 모두 이 3을 본다.
해결: let 사용
for (let i = 0; i < 3; i++) {setTimeout(() => {console.log(i);}, 1000);
let은 블록 스코프라서 반복마다 새로운 i가 생기고, 각 함수가 자기만의 i를 캡처한다.
function createTools() {let value = 0;return {increment: () => ++value,decrement: () => --value
increment와 decrement가 같은 value를 공유하고 있다. 의도한 거면 OK, 아니면 버그다. 독립적으로 동작해야 한다면 별도의 클로저를 만들어야 한다.
클로저는 외부 변수를 참조하는 한 그 변수가 메모리에서 해제되지 않는다. 큰 데이터를 클로저가 붙잡고 있으면 메모리 낭비가 될 수 있다.
function createProcessor() {const hugeData = new Array(1000000).fill("data");return function process() {// hugeData를 직접 안 써도 참조는 유지됨return
필요 없어진 클로저는 null로 해제하거나, 애초에 불필요한 참조를 만들지 않도록 구조를 개선해보면 좋을 것 같다.