자바스크립트 클로저와 스코프, 이것만 읽으면 확실히 이해합니다

자바스크립트를 공부하다 보면 반드시 만나는 두 가지 개념이 있습니다. 스코프와 클로저입니다. 객체와 함수 문법을 막 익히고 나면 이 두 개념의 벽에 부딪히게 되는데요. 많은 분이 이 벽 앞에서 한참을 헤맵니다.
27년 동안 개발하고 강의하며, 초보 개발자가 클로저에서 좌절하는 모습을 정말 많이 봐왔습니다. "클로저는 함수가 외부 변수를 기억하는 것입니다"라는 한 줄 설명으로는 절대 이해가 안 되거든요. 클로저를 이해하려면 먼저 스코프를 확실히 잡아야 합니다. 스코프가 잡히면 클로저는 따라옵니다.
이 글은 자바스크립트에서 객체와 함수의 기본 문법을 익힌 분을 위해 썼습니다. 호이스팅이나 메모이제이션 같은 심화 개념은 아직 몰라도 됩니다. 이 글 하나만 끝까지 읽으면 스코프와 클로저가 뭔지, 왜 중요한지, 실제로 어떻게 돌아가는지 확실히 잡을 수 있습니다.
스코프, 변수가 보이는 범위
프로그래밍에서 변수를 만들면, 그 변수를 아무 데서나 마음대로 쓸 수 있는 건 아닙니다. "여기서부터 여기까지만 접근할 수 있다"는 범위가 정해져 있죠. 이 범위가 스코프입니다.
MDN 공식 문서에서는 스코프를 "값과 표현식이 참조 가능한 현재 실행 컨텍스트"라고 정의합니다. 쉽게 말해서, 변수가 보이는 범위입니다. 어떤 변수가 특정 위치에서 보이면 "스코프 안에 있다", 안 보이면 "스코프 밖에 있다"고 합니다.
자바스크립트에는 세 종류의 스코프가 있습니다.
전역 스코프
코드의 가장 바깥쪽, 어떤 함수나 블록에도 속하지 않은 영역이 전역 스코프입니다. 전역 스코프에서 선언한 변수는 코드 어디에서든 접근할 수 있습니다.
let name = "김철수"; function sayHello() { console.log("안녕하세요, " + name); // "안녕하세요, 김철수" } sayHello(); console.log(name); // "김철수"
name 변수는 함수 바깥에서 선언했으므로 전역 스코프에 속합니다. sayHello 함수 안에서도, 함수 바깥에서도 자유롭게 접근할 수 있습니다.
전역 변수는 편리하지만 위험합니다. 코드 어디에서든 값을 바꿀 수 있기 때문이죠. 프로그램이 커지면 "이 변수를 누가 바꾼 거지?"하는 상황이 생깁니다. 실무에서 전역 변수를 최소화하라고 하는 이유입니다.
함수 스코프
함수 안에서 선언한 변수는 그 함수 안에서만 접근할 수 있습니다. 함수 바깥에서는 보이지 않죠.
function calculateTotal() { let price = 15000; let quantity = 3; let total = price * quantity; console.log(total); // 45000 } calculateTotal(); console.log(total); // ReferenceError: total is not defined
total 변수는 calculateTotal 함수 안에서 선언했습니다. 함수 안에서는 정상적으로 사용할 수 있지만, 함수 바깥에서 total에 접근하려고 하면 에러가 발생합니다. 자바스크립트 엔진이 "total이라는 변수는 여기서 보이지 않습니다"라고 알려주는 겁니다.
함수 스코프의 핵심은 이겁니다. 함수는 자기만의 독립된 공간을 만들고, 그 안에서 선언한 변수는 밖으로 새어나가지 않습니다.
블록 스코프
ES6에서 let과 const가 등장하면서 블록 스코프가 생겼습니다. 블록은 중괄호 {}로 감싼 영역을 말합니다. if문, for문, 또는 단순히 중괄호로 감싼 코드 블록이 해당합니다.
if (true) { let message = "블록 안에서 선언했습니다"; console.log(message); // "블록 안에서 선언했습니다" } console.log(message); // ReferenceError: message is not defined
let으로 선언한 message는 if 블록 안에서만 존재합니다. 블록 바깥에서는 접근할 수 없습니다. const도 마찬가지입니다.
다음 그림은 전역, 함수, 블록 스코프가 어떻게 중첩되는지, 그리고 var와 let/const가 인식하는 스코프 범위의 차이를 보여줍니다.

가장 바깥의 전역 스코프 안에 함수 스코프가, 함수 스코프 안에 블록 스코프가 들어있는 구조입니다. 안쪽 스코프에서는 바깥 스코프의 변수를 볼 수 있지만, 바깥에서 안쪽은 볼 수 없습니다. var는 함수 스코프만 경계로 인식하고, let과 const는 블록 스코프까지 인식합니다.
var, let, const의 차이도 여기서 짚고 넘어가겠습니다.
var, let, const의 스코프 차이
var는 자바스크립트 초창기부터 있던 변수 선언 키워드입니다. let과 const는 2015년 ES6에서 추가되었습니다. 이 세 키워드는 스코프 규칙이 다릅니다.
var는 함수 스코프만 인식합니다. 블록 스코프를 무시합니다.
if (true) { var surprise = "나는 블록을 뚫고 나왔습니다"; } console.log(surprise); // "나는 블록을 뚫고 나왔습니다"
var로 선언한 surprise는 if 블록 안에서 선언했지만, 블록 바깥에서도 아무 문제 없이 접근할 수 있습니다. var에게 블록의 중괄호는 아무 의미가 없기 때문입니다. var가 인식하는 경계는 오직 함수뿐입니다.
function test() { if (true) { var innerVar = "함수 안의 var"; } console.log(innerVar); // "함수 안의 var" - if 블록 밖이지만 같은 함수 안이므로 접근 가능 } test(); console.log(innerVar); // ReferenceError - 함수 밖이므로 접근 불가
반면 let과 const는 블록 스코프를 따릅니다. 중괄호 안에서 선언하면 그 안에서만 유효합니다.
function test() { if (true) { let blockLet = "블록 안의 let"; const blockConst = "블록 안의 const"; } console.log(blockLet); // ReferenceError console.log(blockConst); // ReferenceError }
이 차이를 정리하면 이렇습니다.
| 키워드 | 함수 스코프 | 블록 스코프 |
|---|---|---|
| var | O | X |
| let | O | O |
| const | O | O |
var의 이런 특성은 예측하기 어려운 버그를 만들어냅니다. 그래서 현대 자바스크립트에서는 let과 const를 쓰는 게 권장되죠. 이 글의 뒷부분에서 var의 스코프 특성이 만들어내는 유명한 버그를 직접 보게 될 겁니다.
스코프 체인, 변수를 찾아가는 여정
스코프의 기본 개념을 이해했으니, 이제 한 단계 더 들어가겠습니다. 함수 안에 함수가 있으면 어떻게 될까요?
let color = "빨강"; function outer() { let size = "크다"; function inner() { let shape = "동그라미"; console.log(shape); // "동그라미" console.log(size); // "크다" console.log(color); // "빨강" } inner(); } outer();
inner 함수 안에서 세 개의 변수를 출력합니다. shape은 자기 자신의 스코프에 있으니 바로 찾을 수 있습니다. 그런데 size와 color는 inner 함수 안에 없습니다. 그래도 에러 없이 정상 출력됩니다. 어떻게 된 걸까요?
자바스크립트 엔진은 변수를 찾을 때 특정한 순서를 따릅니다. 현재 스코프에서 먼저 찾고, 없으면 바로 바깥 스코프로 이동합니다. 거기에도 없으면 그 바깥 스코프로, 그래도 없으면 또 바깥으로. 이렇게 전역 스코프에 도달할 때까지 계속 바깥으로 나가면서 찾습니다. 이 탐색 경로를 스코프 체인이라고 부릅니다.
다음 그림은 inner 함수에서 color 변수를 찾을 때, 스코프 체인을 따라 안쪽에서 바깥쪽으로 탐색해 나가는 과정을 보여줍니다.

inner 함수 스코프에서 시작하여 outer 함수 스코프, 전역 스코프 순서로 변수를 찾아갑니다. 스코프 체인은 반드시 안에서 바깥으로만 이동하며, 바깥에서 안쪽으로는 접근할 수 없습니다.
위 코드에서 inner 함수가 size를 출력하려 할 때 벌어지는 일을 단계별로 보면 이렇습니다.
1단계. inner 함수의 스코프에서 size를 찾습니다. 없습니다.
2단계. 바깥 스코프인 outer 함수의 스코프로 이동합니다. size가 있습니다. "크다"를 가져옵니다.
color를 찾을 때는 한 단계를 더 거칩니다.
1단계. inner 함수의 스코프에서 color를 찾습니다. 없습니다.
2단계. 바깥 스코프인 outer 함수의 스코프로 이동합니다. 없습니다.
3단계. 그 바깥인 전역 스코프로 이동합니다. color가 있습니다. "빨강"을 가져옵니다.
만약 전역 스코프까지 갔는데도 변수를 찾지 못하면 그때 ReferenceError가 발생합니다.
중요한 규칙이 하나 있습니다. 스코프 체인은 안에서 바깥으로만 움직입니다. 바깥에서 안으로는 못 들어갑니다.
function outer() { function inner() { let secret = "비밀"; } inner(); console.log(secret); // ReferenceError }
outer 함수는 inner 함수 안에 있는 secret 변수에 접근할 수 없습니다. 부모는 자식의 스코프를 들여다볼 수 없지만, 자식은 부모의 스코프를 올려다볼 수 있습니다. 이 비대칭 규칙이 스코프 체인의 핵심입니다.
같은 이름의 변수가 여러 스코프에 있으면 어떻게 될까요?
let name = "전역 이름"; function greet() { let name = "함수 이름"; console.log(name); // "함수 이름" } greet(); console.log(name); // "전역 이름"
greet 함수 안에서 name을 출력하면, 자기 스코프에서 먼저 찾습니다. 자기 스코프에 name이 있으니까 거기서 멈춥니다. 전역 스코프의 name까지 갈 필요가 없습니다. 이것을 변수 섀도잉이라고 합니다. 안쪽 스코프의 변수가 바깥쪽 스코프의 같은 이름 변수를 가려버리는 현상입니다.
렉시컬 스코프, 코드를 적은 위치가 곧 법입니다
스코프 체인을 이해했으면, 중요한 질문을 하나 던져보겠습니다. 스코프 체인은 언제 결정될까요? 함수를 실행할 때 결정될까요, 아니면 코드를 작성할 때 결정될까요?
답은 "코드를 작성할 때"입니다. 정확히 말하면, 자바스크립트 엔진이 코드를 파싱하는 시점에 결정됩니다. 함수가 어디에서 호출되었는지는 상관없습니다. 함수가 어디에 작성되어 있는지가 스코프 체인을 결정합니다. 이것을 렉시컬 스코프, 또는 정적 스코프라고 부릅니다.
코드로 확인해보겠습니다.
let food = "김치"; function kitchen() { let food = "된장찌개"; function cook() { console.log(food); } return cook; } function serve() { let food = "비빔밥"; let myCook = kitchen(); myCook(); // 무엇이 출력될까요? } serve();
myCook()이 호출될 때, cook 함수 안에서 food를 찾습니다. cook 함수 자체에는 food가 없으니 스코프 체인을 따라 바깥으로 갑니다. 이때 어디로 갈까요?
myCook()이 실행되고 있는 곳은 serve 함수 안입니다. 그래서 serve의 food인 "비빔밥"이 출력될 거라고 생각할 수 있습니다. 하지만 결과는 "된장찌개"입니다.
cook 함수는 kitchen 함수 안에 작성되어 있습니다. 렉시컬 스코프에서는 함수가 작성된 위치를 기준으로 스코프 체인이 결정됩니다. cook의 바깥 스코프는 kitchen이고, kitchen의 food는 "된장찌개"입니다. cook이 어디서 호출되든, 그 스코프 체인은 바뀌지 않습니다.
렉시컬 스코프의 핵심이 이겁니다. 함수의 스코프 체인은 함수가 선언된 위치에 의해 고정됩니다. 호출 위치가 아무리 바뀌어도 스코프 체인은 변하지 않죠. 코드를 읽기만 해도 스코프를 파악할 수 있으니, 코드의 동작을 예측하기가 훨씬 쉬워집니다.
"코드를 작성한 위치"라는 말이 아직 와닿지 않는다면, 이렇게 생각해보면 됩니다. 자바스크립트 파일을 열어서 코드를 눈으로 읽을 때, 함수가 중괄호 안에 들어있으면 그 중괄호의 주인이 바로 그 함수의 바깥 스코프입니다. 코드를 눈으로 보는 것만으로 스코프 체인을 알 수 있습니다.
클로저, 함수가 기억하는 스코프
드디어 클로저입니다. 여기까지 잘 따라왔다면, 생각보다 간단하게 이해할 수 있습니다.
MDN의 공식 정의부터 보겠습니다. "클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합입니다." Kyle Simpson은 "You Don't Know JS Yet: Scope & Closures" 2판 7장에서 이렇게 썼습니다. "클로저는 함수가 자신의 렉시컬 스코프 밖에서 실행될 때에도, 그 렉시컬 스코프를 기억하고 접근할 수 있을 때 발생합니다."
이 정의만 읽으면 어렵게 느껴지죠. 코드를 보면서 하나씩 풀어보겠습니다.
클로저가 생기는 순간
앞에서 렉시컬 스코프를 설명할 때 이미 클로저를 본 적이 있습니다. 다시 한번 보겠습니다.
function kitchen() { let food = "된장찌개"; function cook() { console.log(food); } return cook; } let myCook = kitchen(); myCook(); // "된장찌개"
여기서 벌어지는 일을 자세히 뜯어보겠습니다.
1단계. kitchen() 함수가 호출됩니다.
2단계. kitchen 안에서 변수 food가 만들어지고 "된장찌개"가 할당됩니다.
3단계. cook 함수가 만들어집니다. 이때 cook은 자신이 만들어진 위치의 스코프 정보를 기억합니다.
4단계. cook 함수가 반환됩니다.
5단계. kitchen() 함수의 실행이 끝납니다. 보통 함수 실행이 끝나면 그 안의 지역 변수들은 사라지죠.
6단계. 그런데 myCook()을 호출하면 "된장찌개"가 출력됩니다. kitchen의 실행은 이미 끝났는데, food 변수가 아직 살아있습니다.
다음 그림은 클로저가 만들어지는 과정을 4단계로 보여줍니다. kitchen() 호출부터 myCook() 실행까지, 함수가 끝나도 변수가 살아남는 메커니즘을 확인할 수 있습니다.

kitchen() 함수가 실행되어 cook 함수를 만들고 반환합니다. kitchen 함수의 실행이 끝나면 보통 지역 변수가 사라지지만, cook 함수가 food 변수를 참조하고 있으므로 가비지 컬렉터가 food를 회수하지 못합니다. 나중에 myCook()을 호출하면 기억하고 있던 food 값("된장찌개")에 접근할 수 있습니다.
이것이 클로저입니다. cook 함수가 자신이 선언된 스코프의 변수 food를 기억하고 있기 때문에, kitchen 함수의 실행이 끝난 뒤에도 food에 접근할 수 있는 겁니다.
보통 함수가 끝나면 그 안의 변수들은 메모리에서 정리됩니다. 자바스크립트의 가비지 컬렉터가 "이 변수는 더 이상 아무도 안 쓰니까 치우자"하고 메모리를 회수하죠. 그런데 클로저가 만들어지면 상황이 달라집니다. 반환된 함수(cook)가 여전히 food를 참조하고 있으니, 가비지 컬렉터는 food를 치울 수 없습니다. 누군가 아직 쓰고 있으니까요.
정리하면, 클로저가 만들어지려면 두 가지 조건이 필요합니다. 첫째, 함수 안에 함수가 있어야 합니다. 둘째, 안쪽 함수가 바깥 함수의 변수를 참조해야 하고요. 이 두 조건이 충족되면 안쪽 함수가 클로저가 됩니다. 바깥 함수가 끝나더라도 참조하는 변수를 계속 기억합니다.
클로저는 값을 복사하는 게 아닙니다
클로저를 처음 접하면 "아, 변수의 값을 복사해서 들고 있는 거구나"라고 생각하기 쉬운데요. 그렇지 않습니다. 클로저는 변수 자체에 대한 참조를 유지합니다. 값이 아니라 변수를 기억하는 거죠.
function makeCounter() { let count = 0; return { increment: function() { count++; console.log(count); }, decrement: function() { count--; console.log(count); }, getCount: function() { return count; } }; } let counter = makeCounter(); counter.increment(); // 1 counter.increment(); // 2 counter.increment(); // 3 counter.decrement(); // 2 console.log(counter.getCount()); // 2
increment와 decrement와 getCount, 이 세 함수는 모두 같은 count 변수를 참조하고 있습니다. increment가 count를 올리면, decrement와 getCount도 올라간 값을 봅니다. 만약 클로저가 값을 복사하는 것이었다면, 각 함수가 따로 0을 복사해서 갖고 있을 테니 이런 동작은 불가능합니다.
클로저는 변수를 "공유"합니다. 같은 스코프에서 만들어진 클로저들은 같은 변수에 접근합니다.
각각의 클로저는 독립적인 환경을 갖습니다
한 가지 더 중요한 것이 있습니다. 함수를 여러 번 호출하면, 호출할 때마다 새로운 스코프가 만들어집니다.
function makeCounter() { let count = 0; return function() { count++; return count; }; } let counterA = makeCounter(); let counterB = makeCounter(); console.log(counterA()); // 1 console.log(counterA()); // 2 console.log(counterB()); // 1 (counterA와 독립!) console.log(counterA()); // 3 console.log(counterB()); // 2
makeCounter()를 두 번 호출하면 두 개의 독립된 스코프가 만들어집니다. counterA와 counterB는 각자의 count를 갖고 있습니다. 서로 영향을 주지 않습니다. makeCounter를 호출할 때마다 let count = 0이 새로 실행되면서 새로운 렉시컬 환경이 생기기 때문입니다.
이 특성은 기억해두면 좋습니다. 함수를 호출할 때마다 새로운 렉시컬 환경이 만들어지고, 그 안에서 생성된 클로저는 그 환경에 묶입니다.
클로저의 실전 활용
클로저가 뭔지 이해했으니, 이제 실전에서 어떻게 쓰이는지 보겠습니다.
데이터 은닉
자바스크립트에는 Java나 C++처럼 private 키워드가 없습니다. 클래스의 private 필드(#)가 나오기 전까지, 데이터를 숨기는 가장 확실한 방법이 클로저였습니다. 지금도 함수 기반 코드에서는 클로저로 데이터를 숨기는 패턴을 많이 쓰고요.
function createBankAccount(ownerName, initialBalance) { let balance = initialBalance; let transactionHistory = []; function recordTransaction(type, amount) { transactionHistory.push({ type: type, amount: amount, balance: balance, date: new Date().toLocaleString() }); } return { deposit: function(amount) { if (amount <= 0) { console.log("입금액은 0보다 커야 합니다."); return; } balance += amount; recordTransaction("입금", amount); console.log(amount + "원 입금. 잔액: " + balance + "원"); }, withdraw: function(amount) { if (amount <= 0) { console.log("출금액은 0보다 커야 합니다."); return; } if (amount > balance) { console.log("잔액이 부족합니다. 현재 잔액: " + balance + "원"); return; } balance -= amount; recordTransaction("출금", amount); console.log(amount + "원 출금. 잔액: " + balance + "원"); }, getBalance: function() { return balance; }, getHistory: function() { return transactionHistory.slice(); // 복사본 반환 }, getOwner: function() { return ownerName; } }; } let myAccount = createBankAccount("김철수", 10000); myAccount.deposit(5000); // "5000원 입금. 잔액: 15000원" myAccount.withdraw(3000); // "3000원 출금. 잔액: 12000원" console.log(myAccount.getBalance()); // 12000 console.log(myAccount.balance); // undefined - 직접 접근 불가! console.log(myAccount.transactionHistory); // undefined - 직접 접근 불가!
balance와 transactionHistory는 createBankAccount 함수 안의 지역 변수입니다. 반환된 객체의 메서드(클로저)를 통해서만 접근할 수 있습니다. 외부에서 myAccount.balance로 직접 접근하려고 하면 undefined가 나옵니다. 반환된 객체에는 balance라는 속성이 없기 때문입니다.
클로저를 이용한 데이터 은닉입니다. 중요한 데이터를 외부에서 함부로 변경할 수 없게 보호하면서, 정해진 메서드를 통해서만 접근하도록 막아두는 거죠. deposit과 withdraw 함수에 입력값 검증 로직이 있으니, 잘못된 값이 들어오는 것도 막을 수 있습니다.
팩토리 함수
같은 구조이지만 설정값이 다른 함수를 여러 개 만들어야 할 때, 클로저가 아주 유용합니다.
function createTaxCalculator(taxRate) { return function(price) { let tax = price * taxRate; return { price: price, tax: Math.round(tax), total: Math.round(price + tax) }; }; } let calcKoreaTax = createTaxCalculator(0.1); // 한국 부가세 10% let calcUKTax = createTaxCalculator(0.2); // 영국 VAT 20% let calcUSTax = createTaxCalculator(0.0725); // 미국 캘리포니아 7.25% console.log(calcKoreaTax(50000)); // { price: 50000, tax: 5000, total: 55000 } console.log(calcUKTax(50000)); // { price: 50000, tax: 10000, total: 60000 } console.log(calcUSTax(50000)); // { price: 50000, tax: 3625, total: 53625 }
createTaxCalculator는 세율(taxRate)을 받아서 새로운 함수를 반환합니다. 반환된 함수는 클로저이므로 taxRate을 기억하고 있죠. 한국 10%, 영국 20%, 미국 7.25%로 설정값만 다를 뿐, 세금 계산 로직은 동일합니다. 한 번 설정하면 이후에는 가격만 넣으면 됩니다.
이런 패턴은 실무에서 자주 보입니다. 설정값을 미리 고정해두고, 나중에 데이터만 넣어서 처리하는 함수를 만들 때 클로저가 쓰입니다.
하나 더 실용적인 예를 보겠습니다.
function createGreeting(greeting) { return function(name) { return greeting + ", " + name + "!"; }; } let sayHello = createGreeting("안녕하세요"); let sayBye = createGreeting("안녕히 가세요"); console.log(sayHello("김철수")); // "안녕하세요, 김철수!" console.log(sayHello("이영희")); // "안녕하세요, 이영희!" console.log(sayBye("김철수")); // "안녕히 가세요, 김철수!"
패턴은 같습니다. 바깥 함수로 설정값을 고정하고, 안쪽 함수로 변하는 값을 받는 거죠.
상태를 기억하는 함수
클로저는 함수 호출 사이에 상태를 유지할 수 있습니다. 전역 변수 없이도 함수가 "이전에 무슨 일이 있었는지" 기억하게 만들 수 있죠.
function createIdGenerator(prefix) { let nextId = 1; return function() { let id = prefix + "-" + String(nextId).padStart(4, "0"); nextId++; return id; }; } let userIdGen = createIdGenerator("USER"); let orderIdGen = createIdGenerator("ORD"); console.log(userIdGen()); // "USER-0001" console.log(userIdGen()); // "USER-0002" console.log(orderIdGen()); // "ORD-0001" console.log(userIdGen()); // "USER-0003" console.log(orderIdGen()); // "ORD-0002"
nextId는 외부에서 접근할 수 없습니다. 오직 반환된 함수를 호출할 때만 1씩 증가하죠. 전역 변수 없이 상태를 유지하는 깔끔한 방법입니다. userIdGen과 orderIdGen은 각자 독립된 nextId를 갖고 있으므로 서로 간섭하지도 않습니다.
콜백 함수에서의 클로저
마무리에서 다시 언급하겠지만, 클로저는 이미 곳곳에서 쓰이고 있습니다. 특히 콜백 함수와 비동기 처리에서 자연스럽게 등장하죠. 예제를 하나 보겠습니다.
function startCountdown(name, seconds) { let remaining = seconds; let timer = setInterval(function() { if (remaining > 0) { console.log(name + ": " + remaining + "초 남음"); remaining--; } else { console.log(name + ": 완료!"); clearInterval(timer); } }, 1000); } startCountdown("타이머A", 3); // 타이머A: 3초 남음 // 타이머A: 2초 남음 // 타이머A: 1초 남음 // 타이머A: 완료!
setInterval에 전달한 콜백 함수가 바로 클로저입니다. startCountdown 함수의 실행은 setInterval을 설정한 직후에 끝나지만, 콜백 함수는 1초마다 계속 실행되죠. 이 콜백이 name, remaining, timer 변수를 기억하고 있기 때문에, startCountdown이 끝난 후에도 이 변수들에 접근할 수 있습니다. 클로저의 전형적인 활용입니다.
클로저와 메모리
클로저의 장점을 많이 봤으니, 주의할 점도 짚어두겠습니다. 클로저가 외부 변수를 참조하는 한, 그 변수는 메모리에서 해제되지 않습니다. 앞에서 가비지 컬렉터가 클로저가 참조하는 변수를 치울 수 없다고 했던 거 기억하실 겁니다.
대부분은 문제가 되지 않습니다. 카운터 변수 하나, 세율 값 하나 정도는 메모리에 부담을 주지 않으니까요. 하지만 클로저가 필요 이상으로 큰 객체나 배열을 참조하고 있다면 상황이 달라집니다. 클로저를 사용하는 함수가 계속 만들어지기만 하고 해제되지 않으면, 해당 함수가 참조하는 변수도 계속 메모리에 쌓이죠.
해결 방법은 간단합니다. 클로저를 더 이상 사용하지 않으면 null을 할당해서 참조를 끊어주면 됩니다.
let counter = makeCounter(); counter(); // 1 counter(); // 2 // 더 이상 counter가 필요 없을 때 counter = null; // 참조를 끊으면 가비지 컬렉터가 메모리를 회수합니다
초보자 단계에서 메모리 문제를 크게 걱정할 필요는 없습니다. 다만 "클로저가 변수를 살려두는 만큼, 필요 없어지면 정리해야 한다"는 원칙 정도는 알아두면 좋습니다.
초보자가 흔히 빠지는 클로저 함정
클로저를 배운 초보자가 한 번쯤은 꼭 겪게 되는 유명한 버그가 있습니다. for 루프와 var를 함께 쓸 때 발생하는 문제입니다.
문제: 루프 안의 var와 비동기 함수
다음 코드의 결과를 예측해보겠습니다.
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log("카운트: " + i); }, 1000); }
기대하는 결과는 이렇습니다.
카운트: 0 카운트: 1 카운트: 2
하지만 실제 결과는 이렇습니다.
카운트: 3 카운트: 3 카운트: 3
다음 그림은 var를 사용했을 때 세 클로저가 하나의 i를 공유하는 문제와, let을 사용했을 때 각각 독립된 i를 갖는 해결 과정을 비교합니다.

왼쪽의 var 사용 시에는 세 개의 setTimeout 콜백이 모두 같은 하나의 i 변수를 참조합니다. 루프가 끝난 후 i는 3이 되어 있으므로 세 번 모두 "카운트: 3"이 출력됩니다. 오른쪽의 let 사용 시에는 루프가 돌 때마다 새로운 i가 만들어져서, 각 콜백이 독립된 i(0, 1, 2)를 참조합니다.
왜 이런 일이 벌어질까요? 두 가지를 기억하면 됩니다.
첫째, var는 블록 스코프가 없습니다. for 루프의 중괄호를 무시하죠. 그래서 var i는 루프 전체에서 하나의 변수입니다. 루프가 세 번 돌아도 i는 하나뿐입니다.
둘째, setTimeout 안의 함수는 즉시 실행되지 않습니다. 1초 후에 실행되죠. 그 1초 사이에 for 루프는 이미 다 돌아버립니다. 루프가 끝났을 때 i의 값은 3입니다.
setTimeout 안의 세 함수는 모두 클로저입니다. 같은 i 변수를 참조하고 있습니다. 1초 후에 세 함수가 실행될 때, i의 값을 확인하면 이미 3이 되어 있습니다. 세 함수 모두 같은 i를 보고 있으니, 세 번 다 "카운트: 3"이 출력됩니다.
해결: let을 사용하기
가장 깔끔한 해결책은 var를 let으로 바꾸는 것입니다.
for (let i = 0; i < 3; i++) { setTimeout(function() { console.log("카운트: " + i); }, 1000); } // 카운트: 0 // 카운트: 1 // 카운트: 2
let은 블록 스코프를 따릅니다. for 루프에서 let을 사용하면, 루프가 한 번 돌 때마다 새로운 스코프가 만들어지고 그 안에 새로운 i가 생깁니다. 루프가 세 번 돌면 세 개의 독립된 i가 만들어지는 겁니다. 첫 번째 i는 0, 두 번째 i는 1, 세 번째 i는 2입니다.
각 setTimeout 안의 함수는 자기 차례에 만들어진 i를 참조합니다. 독립된 스코프에 독립된 i가 있으니, 기대한 대로 0, 1, 2가 출력되는 겁니다.
이 문제는 var를 쓰지 않으면 생기지 않습니다. 현대 자바스크립트에서 let과 const를 권장하는 이유 중 하나입니다.
참고: IIFE를 이용한 해결 (ES6 이전 방법)
let이 없던 시절에는 IIFE(즉시 실행 함수 표현식)로 이 문제를 해결했습니다. 지금은 let을 쓰면 되지만, 오래된 코드나 기술 면접에서 가끔 등장하니 알아두면 좋습니다.
IIFE는 함수를 만들자마자 바로 실행하는 패턴입니다. 함수를 괄호로 감싸면 함수 표현식이 되고, 그 뒤에 ()를 붙이면 즉시 실행됩니다. 루프가 돌 때마다 이 함수를 즉시 호출하면서 현재 i 값을 매개변수 j로 넘기면, j는 그 함수만의 스코프에 갇히게 됩니다.
for (var i = 0; i < 3; i++) { (function(j) { setTimeout(function() { console.log("카운트: " + j); }, 1000); })(i); } // 카운트: 0 // 카운트: 1 // 카운트: 2
(function(j) { ... })(i) 부분을 풀어서 읽으면 이렇습니다. 먼저 function(j) { ... }라는 익명 함수를 만들고, 바깥의 괄호가 이것을 표현식으로 감싸고, 마지막 (i)가 현재 i 값을 인수로 넘기면서 즉시 호출합니다. 즉시 실행 함수는 함수이므로 자체적인 함수 스코프를 만들고, 매개변수 j는 그 스코프에 갇힙니다. var가 블록 스코프를 무시하니까, 함수 스코프를 강제로 만들어서 해결한 겁니다.
마무리
스코프와 클로저를 정리해보겠습니다.
스코프는 변수가 보이는 범위입니다. 전역, 함수, 블록 스코프가 있고, var는 함수 스코프만 따르고 let과 const는 블록 스코프까지 따릅니다. 스코프 체인은 변수를 찾아가는 경로입니다. 현재 스코프에서 시작해서 바깥으로, 전역까지 올라가면서 찾죠. 안에서 밖으로만 갈 수 있고, 반대 방향은 안 됩니다.
렉시컬 스코프는 이 스코프 체인이 언제 결정되는지에 대한 규칙입니다. 함수가 어디서 호출됐는지가 아니라, 어디에 작성되어 있는지가 기준이죠. 클로저는 함수가 자신이 선언된 스코프의 변수를 기억하는 현상이고요. 바깥 함수가 끝나도 안쪽 함수가 바깥 변수를 참조하고 있으면, 그 변수는 메모리에 남아있습니다. 값을 복사하는 게 아니라 변수 자체를 참조한다는 점이 핵심입니다.
이 네 가지 개념은 연결되어 있습니다. 스코프를 이해하면 스코프 체인이 보이고, 렉시컬 스코프를 이해하면 클로저가 왜 그렇게 동작하는지 납득이 됩니다. 여기서 다룬 내용이 확실히 잡혔다면, 호이스팅이나 실행 컨텍스트 같은 심화 개념을 배울 때 훨씬 수월할 겁니다.
강의를 하다 보면 "클로저를 어디에 쓰는지 모르겠다"는 질문을 많이 받습니다. 사실 이미 곳곳에서 쓰이고 있거든요. 이벤트 핸들러, 콜백 함수, React의 useState, Express의 미들웨어 체인. 자바스크립트로 코드를 짜면 클로저를 의식하지 않아도 쓰게 됩니다. 다만 그게 클로저라는 것을 알고 쓰는 사람과 모르고 쓰는 사람은 차이가 큽니다. 디버깅할 때, 코드를 설계할 때 그 차이가 드러나죠.
이 글의 예제를 직접 실행해보길 권합니다. 코드를 조금씩 바꿔가면서 결과가 어떻게 달라지는지 확인해보면, 읽기만 했을 때보다 이해가 훨씬 깊어집니다. 특히 클로저 부분은 직접 손으로 코드를 쳐보는 게 가장 좋은 학습 방법입니다.
다음 편에서는 호이스팅, 암묵적 매개변수(this, arguments), 함수 오버로딩, 메모이제이션을 다룹니다. 이 글에서 배운 클로저가 메모이제이션에서 어떻게 쓰이는지 직접 확인할 수 있습니다.
참고 자료
- MDN Web Docs, "Closures" (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures)
- MDN Web Docs, "Scope" (https://developer.mozilla.org/en-US/docs/Glossary/Scope)
- JavaScript.info, "Variable scope, closure" (https://javascript.info/closure)
- Kyle Simpson, "You Don't Know JS Yet: Scope & Closures, 2nd Edition" - Chapter 7: Using Closures (https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/ch7.md)
- PoiemaWeb, "클로저" (https://poiemaweb.com/js-closure)
- freeCodeCamp, "JavaScript Closure Tutorial" (https://www.freecodecamp.org/news/javascript-closure-lexical-scope/)






댓글
댓글을 작성하려면 이 필요합니다.