-
[JavaScript] 스코프(Scope)와 클로저(Closure)Front-End/JavaScript 2023. 4. 5. 04:07
🍀 목차
글의 목적
스코프
스코프의 구분
스코프 이해
+ LHS, RHS
어휘적 스코프(Lexical Scope)
함수 레벨 스코프 vs 블록 레벨 스코프
클로저
클로저 예제글의 목적
자바스크립트를 학습하다 보면
스코프(Scope)
,클로저(Closure)
라는 단어를 빈번하게 마주칠 수 있었다. 그럴 때마다 검색해서 대략적인 감만 잡고 넘어갔었다.😎 "클로저가 무엇인가요?"라는 질문을 받았을 때, 내가 과연 이해한 답변을 할지, 외운 답변을 할지 생각해 봤을 때 후자에 가깝다고 느꼈다.
그렇기에 이 글은 자바스크립트의 스코프와 클로저를 이해하기 위해 작성됐다.
스코프스코프는 자바스크립트뿐만 아니라 여러 프로그래밍 언어의 기본 개념이다.
우선, 아래 코드의 실행 결과를 예측해 보자.
var x = 'global'; function foo(){ var x = 'function scope'; console.log(x); } foo(); // (1) console.log(x); // (2)
변수 x는 중복 선언이 되었으며, 함수 foo 내부에서 x를 참조할 때와 전역 변수 x를 참조할 때 2개의 변수 중 어떤 것을 참조해야 하는가? 이때 등장하는 개념이
스코프
이다. 특정 장소에 변수를 저장하고 나중에 그 변수를 찾는 데 잘 정의된 규칙이 필요한데, 그 규칙이라고 이해하면 된다.- 스코프(Scope, 유효 범위) : 참조 대상 식별자(identifier, 변수나 함수 이름과 같이 다른 대상과 구분하여 식별할 수 있는 유일한 이름)를 찾아내기 위한 규칙. 자바스크립트는 이 규칙대로 식별자를 찾는다.
개발을 하다 보면 변수를 전역으로 선언할 때도, 코드 블록(if, for, while 등)이나 함수 내에 선언할 때도 있다. 여러 함수나 코드 블록에 중첩된 식별자는 자신이 어디에서 선언됐는지에 의해 유효한(다른 코드가 자신을 참조할 수 있는) 범위를 갖게 된다. 모든 변수는 각자의 스코프를 갖는 것.
위 예제 코드도 스코프의 규칙에 따라 전역 변수 x는 어디서든 참조할 수 있지만, 함수 foo 내부의 x는 foo 내부에서만 참조할 수 있고 외부에서는 참조할 수 없다.
스코프가 없다면?
같은 식별자 이름에 충돌을 일으켜 프로그램 전체에서 해당 이름은 하나밖에 사용할 수 없다.
디렉터리가 없다면 같은 이름을 갖는 파일을 하나밖에 만들 수 없다.
스코프도 이와 같이 식별자 이름의 충돌을 방지한다.스코프의 구분
스코프는 다음과 같이 2가지로 구분된다.
- 전역 스코프(Global Scope) : 코드 어디에서든지 참조 가능.
- 지역 스코프(Local Scope or Function-level Scope) : 함수 코드 블록이 만든 스코프. 함수 자신과 하위 함수에서만 참조 가능. 블록 내부 영역(Block-level Scope)도 지역 스코프이다.
변수의 선언 위치가 전역이라면 전역 스코프를 갖는 전역 변수가 되고,
선언 위치가 지역(함수나 블록 내부 등)이라면 지역 스코프를 갖는 지역 변수가 된다.
스코프 이해
먼저, 해당 목차는 「YOU DON'T KNOW JS : 타입과 문법, 스코프와 클로저」 5 챕터 부분을 정리하고 추가한 내용임을 밝힙니다.
"var a = 2;"
라는 구문은 어떻게 처리되는 걸까?우선 아래의 개념들이 필요하다.
- 엔진 : 컴파일레이션(프로그램을 이루는 소스 코드가 실행되기 전 거치는 3단계
[토크나이징/렉싱 + 파싱 + 코드 생성]
)의 전 과정과 JS 프로그램 실행을 책임진다. - 컴파일러 : 엔진의 친구, 파싱과 코드 생성의 모든 잡일을 수행.
- 스코프 : 엔진의 또 다른 친구, 선언된 모든 변수 검색 목록을 작성하고 유지. 또한, 엄격한 규칙을 강제하여 현재 실행 코드에서 변수의 적용 방식을 정한다.
엔진은 위의 구문을 만나게 되면 두 개의 구문으로 보게 된다.
1. 컴파일러가 컴파일레이션 과정에서 처리할 구문(잡일 시키기.)
2. 실행 과정에서 엔진이 처리할 구문(받아서 내가 실행.)
컴파일러는 첫 번째로 렉싱(토큰 인식 시 무상태 방식으로 하지 않음)을 통해 구문을 토큰(의미 있는 조각)으로 쪼갠다. 그 후 토큰을 파싱해 트리 구조(AST(Abstract Syntax Tree) : 추상 구문 트리)로 만든다. 코드 생성 과정에서는 다음 과정을 거쳐 프로그램을 처리한다.
①
var a
를 만나면 스코프에게 변수 a가 특정한 스코프 컬렉션 안에 있는지 묻는다.😎(스코프) : 있음 👉 컴파일러는 선언을 무시하고 지나감.
🥺(스코프) : 없음 👉 컴파일러는 새로운 변수 a를 스코프 컬렉션 내에 선언하라고 요청.
②
a = 2
대입문을 처리하기 위해 나중에 엔진이 실행할 수 있는 코드를 생성한다. 해당 코드는 스코프에게 a라는 변수가 현재 스코프 컬렉션 내에 접근할 수 있는지 확인한다.😎(스코프) : 가능 👉 엔진은 변수 a 사용.
🥺(스코프) : 불가능 👉 다른 곳(중첩 스코프)을 살핀다.
위 과정을 거쳐 엔진이 변수를 찾게 되면 값 2를 넣고, 못 찾으면 에러를 발생시킨다. 결과적으로 요약하면 변수 대입문을 처리하는 것은 아래의 두 동작이 일어난다.
첫째, 컴파일러가 변수를 선언(현재 스코프에 미리 변수가 선언되지 않은 경우).
둘째, 엔진이 스코프에서 변수를 찾고 변수가 있다면 값을 대입.
중첩 스코프에서는?
중첩 스코프
스코프는 변수 이름으로 해당 변수를 찾기 위한 규칙의 집합이다.
하나의 블록이나 함수는 다른 블록이나 함수 안에 중첩될 수 있으므로 스코프도 다른 스코프에 중첩될 수 있다. 따라서 대상 변수를 현재 스코프에서 발견하지 못하면 엔진은 다음 바깥의 스코프로 넘어가는 방식으로 변수를 찾거나글로벌 스코프
로 불리는 가장 바깥 스코프에 도달할 때까지 반복한다.function foo(a){ console.log(a+b); } var b = 2; foo(2); //4
b에 대한 참조는 foo 스코프 안에서는 할 수 없다. 함수를 포함하는 스코프(상위 스코프, 위의 경우는 글로벌 스코프)에서 처리해야 한다.
🔧(엔진) : foo 스코프, b에 대한 RHS 참조가 필요한데, 들어본 적 있어?
🥺(스코프) : 못 들어 봤어. 딴 데 가봐.
🔧(엔진) : foo 바깥 스코프(글로벌 스코프)! b에 대해 들어봤어?? RHS 참조가 필요한데...
😎(스코프) : 들어봤어. 여기 있어.
중첩 스코프 탐사 시에 사용되는 규칙은 다음과 같다.
- 엔진은 현재 스코프에서 변수를 찾다, 찾지 못하면 한 단계씩 상위 스코프로 올라간다.
- 최상위인 글로벌 스코프에 도달하면 변수를 찾든, 못 찾았든 검색을 멈춘다.
흠... 근데 RHS가 뭘까?!
+ LHS, RHS
예시
var a=2;
에서 a=2 대입문을 처리하기 위해 컴파일러는 엔진이 실행할 수 있는 코드를 생성한다고 언급했다. 해당 코드를 실행할 때 엔진은 변수 a가 선언된 적이 있는지 스코프에게 검색한다.엔진이 수행하는 검색방법은 두 가지가 있다.
- LHS(Left-Hand Side) : 변수가 대입 연산자의 왼쪽에 있을 때와 같이 값을 넣어야 하는 경우 수행하는 검색방법. 대입 연산은 여러 방식으로 일어날 수 있기에 단순히 "대입 연산자의 왼쪽"이라고 이해하기보다는 대입할 대상이 있다는 의미로 이해하자.
- RHS(Right-Hand Side) : 변수가 대입 연산자의 오른쪽에 있을 때와 같이 단순 특정 변수의 값을 찾는 검색방법. "Retrieve(가져오라) His/Her(그의/그녀의) Source(소스)"의 약자라고 이해하면 쉽다. "가서 값을 가져오라".
a = 2의 경우에는 어떤 검색방법이 수행될까? LHS이다. 값을 넣어야 하기 때문이다.
console.log(a);
위 코드의 a에 대한 참조는 RHS이다. a에 아무것도 대입하지 않기 때문이다. console.log() 또한 실행되려면 참조가 필요한데, console 객체를 RHS 검색하여 log 메서드가 있는지 확인한다.
이제 엔진과 스코프도 추가하여 아래 코드의 실행과정을 이해해 보자.
function foo(a){ console.log(a); // 2 } foo(2);
🔧(엔진) : 스코프, foo에 대한 RHS 참조가 필요해. 들어본 적 있어?
😎(스코프) : 들어봤어. 컴파일러가 좀 전에 선언했어. foo는 함수야. 이걸 봐.
🔧(엔진) : 고마워. 이제 foo를 실행해야겠다. 음... a에 대한 LHS 참조도 구해야 하는데, 들어본 적 있어?
😎(스코프) : 들어봤어. 컴파일러가 a를 foo의 인자로 좀 전에 선언했어. 이걸 봐.
🔧(엔진) : 고마워. 이제 2를 a에 대입해야겠다. 음... console에 대한 RHS 검색이 필요해. 가능해?
😎(스코프) : console을 찾았어. 내장돼 있더라. 이걸 봐.
🔧(엔진) : 고마워. 이제 log()를 찾아보면... 이건 함수구나. 스코프, 나한테도 있긴 할 테지만 확실하게 해두고 싶은데 a의 RHS 참조 찾는 것 좀 도와줄 수 있을까?
😎(스코프) : 여기 있어.
🔧(엔진) : 이제 a의 값을... 2구나. log()에 넘겨야겠다.
function foo(a){ var b = a; return a+b; } var c = foo(2);
해당 코드를 엔진과 스코프의 대화처럼 생각해 본 후, 아래의 퀴즈를 풀어보자.
① 모든 LHS 검색을 찾아보자(3개).
더보기c = ...
,a = 2
,b = ...
② 모든 RHS 검색을 찾아보자(4개).
더보기foo(2 ...
,= a;
,a ...
,... b
LHS와 RHS를 왜 구분해야 할까?
변수가 아직 선언되지 않았을 때(모든 스코프에서 찾지 못했을 때) 서로 다르게 동작하기 때문이다.
결론을 요약하면 아래와 같다.
- RHS 참조가 대상을 찾지 못하면
ReferenceError
. - LHS 참조가 대상을 찾지 못하면 암시적으로 글로벌 스코프에 같은 이름의 새로운 변수 생성(엄격 모드가 아니라면).
과정은 아래와 같다.
function foo(a){ console.log(a+b); b = a; } foo(2);
b에 대한 첫 RHS 검색이 실패하면 다시는 b를 찾을 수 없다. 이렇게 스코프에서 찾지 못한 변수는 선언되지 않은 변수라 하며, 중첩 스코프 안 어디서도 변수를 찾을 수 없다면 엔진은
ReferenceError
을 발생시킨다.반면, 엔진이 LHS 검색을 수행하며 변수를 찾다 글로벌 스코프까지 도착한다면, 프로그램이
Strict Mode
(엄격 모드)로 동작하는 것이 아니라면 글로벌 스코프는 엔진이 검색하고 있는 이름을 가진 새로운 변수를 넘겨준다. "없었는데 하나 만들어주마"라는 느낌.엄격 모드라면, 글로벌 스코프의 암시적 변수 생성을 막기 때문에 RHS와 비슷하게
ReferenceError
를 발생시킨다.RHS 검색 결과 변수를 찾았으나 함수가 아닌 값을 함수처럼 실행하는 등의 불가능한 일을 하려고 할 때 엔진은
TypeError
를 발생시킨다.- ReferenceError : 스코프에서 대상을 찾았는지와 관련.
- TypeError : 스코프 검색은 성공했으나 결괏값으로 적합하지 않은/불가능한 시도를 한 경우.
LHS, RHS 참조 검색은 모두 현재 실행 중인 스코프에서 시작하며, 필요한 경우 한 스코프씩 중첩 스코프의 상위 스코프로 넘어가며 변수를 찾아간다(글로벌 스코프에 이를 때까지).
정리하면, 스코프는 어디서 어떻게 변수를 찾는가를 결정하는 규칙의 집합이고, 변수를 검색하는 이유는 변수에 값을 대입하거나(LHS) 변수의 값을 얻어오기 위해서이다(RHS).
어휘적 스코프(Lexical Scope)
렉시컬 스코프
라고도 불리는데, 어원은 컴파일러의 토크나이징/렉싱 작업과 관련이 있다.렉싱 처리 과정에서는 소스 코드 문자열을 분석하여 상태 유지 파싱의 결과로 생성된 토큰에 의미를 부여한다.
렉시컬 스코프는 프로그래머가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초하여 렉서가 코드를 처리할 때 확정된다(렉싱 타임에 정의되는 스코프).
function foo(a){ var b = a * 2; function bar(c){ console.log(a,b,c); } bar( b * 3 ); } foo(2); // 2, 4, 12
위 코드에는 3개의 중첩 스코프가 있다.
① 글로벌 스코프를 감싸고 있고, 오직 하나의 변수 foo만 있다.
② foo의 스코프를 감싸고 있고, 3개의 변수(a, bar, b)를 포함한다.
③ bar의 스코프를 감싸고 있고, 하나의 변수(c)만을 포함한다.
bar는 foo의 스코프 내부에 완전히 포함된다. foo 내부에서 bar를 선언했기 때문이다.
엔진은 console.log() 구문을 실행하고 a, b, c를 검색한다. 검색은 가장 안쪽인 bar() 스코프부터 시작하여, 해당 변수를 찾지 못하면 한 단계 올라가 foo() 스코프, ... 로 변수를 찾을 때까지(or 글로벌 스코프까지) 올라간다.
만약, c가 bar(), foo() 내부에 모두 존재하면 bar() 내부부터 사용하고, foo()에는 c를 찾으러 가지도 않는다.
스코프는 목표 대상을 찾는 즉시 검색을 중단하기에 중첩 스코프 층에 걸쳐 같은 변수 이름을 정의할 수 있다. 이를
섀도잉
이라고 한다.어떤 함수가 어디서 또는 어떻게 호출되는지에 상관없이 함수의 렉시컬 스코프는 함수가 선언된 위치에 따라 정의된다. 함수 선언 시점에 상위 스코프가 결정되기에 호출은 스코프 결정에 전혀 영향을 끼치지 않는다.
렉시컬 스코프 검색 과정은 a, b, c와 같은 일차 확인자 검색에만 적용된다. foo.bar.baz의 참조를 찾는다고 하면 렉시컬 스코프 검색은 foo 확인자를 찾는 데 사용되지만, 일단 foo를 찾고 나서는 객체 속성 접근 규칙을 통해 bar, baz의 속성을 각각 가져온다.
🥶 "함수 선언 위치에 따라 결정이 무슨... 뜻이지?" 라는 생각이 들어서 다른 예시를 찾아봤다.
var x = 1; function foo(){ var x = 10; bar(); } function bar(){ console.log(x); } foo(); // (1) bar(); // (2)
렉시컬 스코프는 프로그래머가 작성할 때 함수를 어디에 선언했는지에 따라 결정된다고 했다.
어디에서 호출하였는지는 중요하지 않다.
실행 단계에서 전역 공간의 변수 x, foo 함수 안의 변수 x, bar 함수 안의 변수 x의 스코프가 결정되며,
함수들의 상위 스코프는 전역 스코프이기에 (1), (2) 모두 전역 변수 x의 값 1을 출력하여 1이 두 번 출력된다.
📌 선언이 핵심!
함수 foo안의 함수 bar 호출 👉 bar는 전역 공간에 선언되었기에 전역 x를 출력.
전역공간의 함수 bar 호출 👉 bar는 전역 공간에 선언되었기에 전역 x를 출력.알아두기
런타임에 스코프를 수정하거나 새로운 렉시컬 스코프를 만드는 방법은 권장하지 않는다.
JS 엔진은 컴파일레이션 단계에서 상당수의 최적화 작업을 진행한다. 이 작업 중의 하나는 렉싱된 코드를 분석하여 모든 변수와 함수 선언문이 어디 있는지 파악하고 실행 과정에서 변수 검색을 더 빨리 하는 것이다.
그러나 렉시컬 스코프를 속인다면, 엔진은 미리 확인해 둔 변수의 위치가 틀릴 수 있다고 가정해야 한다. 새로운 렉시컬 스코프가 생길 수 있기 때문이다. 이는 대다수 최적화가 의미 없어지는 것과 같다.함수 레벨 스코프 vs 블록 레벨 스코프
변수 선언 키워드
var
와let
,const
의 차이점의 한 가지로 scope의 범위의 특징을 꼽을 수 있다.var는 함수 레벨 스코프라면 let과 const는 블록 레벨 스코프이다. 둘의 차이는 무엇일까?
- 함수 레벨 스코프
var a = 1; // 전역변수 (function () { var b = 20; // IIFE, 지역변수 })(); console.log(a); // 1 console.log(b); // ReferenceError : b is not defined
JS는 기본적으로 함수 레벨 스코프를 사용한다. 함수 내부에서 선언된 변수를 다른 상위 스코프에서 호출할 경우
ReferenceError
가 발생한다. 지역 변수 b는 상위 스코프인 글로벌 스코프에서 참조될 수 없다.전역과 지역 공간에 중복 선언된 변수는 어떠할까?
var x = 'global'; function foo(){ var x = 'local'; console.log(x); } foo(); // 'local' console.log(x); // 'global'
검색은 가장 안쪽의 스코프(자기 자신)부터 가장 바깥 범위의 스코프(글로벌 스코프)로 진행되기에 foo() 안의 x는 지역 변수 x가, 글로벌 스코프에서는 전역 변수 x가 출력되게 된다.
함수 내에 존재하는 함수(내부 함수)의 경우는?
var x = 'global'; function foo(){ var x = 'local'; console.log(x); function bar(){ console.log(x); } bar(); } foo(); console.log(x); // ==> output // 'local' // 'local' // 'global'
내부 함수 bar에서 참조하는 변수 x는 함수 foo의 지역변수이다.
정리하면 함수 스코프는 모든 변수가 해당 함수에 속하며, 함수 전체(중첩 스코프에서도)에 걸쳐 사용, 재사용되기도 한다.
- 블록 레벨 스코프
함수가 가장 일반적인 스코프 단위이지만 JS에는 블록 레벨 스코프도 존재한다.
익숙한 코드로 이해해 보자.
for(var i=0;i<10;i++){ console.log(i); }
for 반복문 안의 변수 i는 보통 해당 반복문에서만 사용되고 유효할 것이다. 그런데 var은 함수 레벨 스코프이기 때문에 for문 밖에서도 접근이 가능하다.
for문 안에서만 사용되는 변수 i는 예상과 달리 for문 밖에서도 살아있는 것이다. 혼란스러운 재사용이 가능해지는 것.
블록 스코프는 이럴 때 정보를 코드 블록 안에 숨길 수 있어 보다 유용한 개발이 가능하다. 원래 JS의 블록 스코프 기능은 비주류적인 요소로만 구현이 가능했으나, 키워드 let이 등장하며 보다 안전한 변수 선언, 개발이 가능해졌다.
let은 선언된 변수를 둘러싼 아무 블록(
{ }
같은)의 스코프에 붙인다.위의 코드도 let으로 선언하면 해당 변수는 for문 블록에서만 사용이 가능하다.
let을 사용한 선언문은 속하는 스코프에서 호이스팅 효과를 받지 않는다는 것도 알아두자.
{ console.log(bar); // ReferenceError let bar = 2; // 선언문 전에는 명백히 '존재'하지 않음. }
가비지 컬렉션
블록스코프가 유용한 또 다른 점은 메모리를 회수하기 위한 클로저 그리고 가비지 컬렉션과도 관련 있다.
명시적으로 블록을 선언하여 해당 변수의 영역을 한정한다면, 엔진은 더 이상 해당 변수가 필요 없다는 사실을 더 명료하게 알 수 있다(해당 변수가 많은 메모리를 잡아먹는 자료구조로 이루어져 있다면 더욱 유용할 것이다).해당 파트를 작성하며 역시 let이 좋구나~ 란 생각이 들었었는데, 책의 마지막 정리 부분에 다음 내용이 있어서 같이 기록해 본다.
쉽게 착각하지만, 블록 스코프는 var 함수 스코프를 완전히 대체할 수 없다. 두 기능은 공존하며 개발자들은 함수 스코프와 블록 스코프 기술을 같이 사용할 수 있어야 하고 그래야 한다.
두 변수 선언 키워드의 특징을 이해하고 사용하면 보다 유용한 개발이 가능할 것 같다(아직 var만 써야 하는? var를 유용하게 사용할 수 있는? 방법은 잘 모르겠지만...😌).
클로저클로저는 JS의 모든 곳에 존재한다. 여러 함수형 프로그래밍 언어의 보편적 특성이기에, 새롭게 익히는 문법이나 패턴을 배워야 할 도구도 아니다. 클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생한다. 의도적으로 클로저를 생성할 필요도 없으며 모든 코드에서 생성되고 사용되기도 한다. 그렇기에 클로저를 이해하는 것은 자신의 코드에서 이미 일어나고 있던 것을 인식하는 것과 같다.
클로저의 핵심은 "함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때도 이 스코프에 접근할 수 있게 하는 기능"이라는 것이다.
function foo(){ var a = 2; function bar(){ console.log(a); // 2 } bar(); } foo();
함수 bar()는 렉시컬 스코프 검색 규칙을 통해 바깥(상위) 스코프의 변수 a에 접근할 수 있다(RHS). 이것이 클로저인가?
음... a를 참조하는 bar()를 설명하는 가장 정확한 방식은 렉시컬 스코프 검색 규칙에 따라 설명하는 것이고, 이 규칙은 클로저의 일부일 뿐이라는 것이 중요하다.
함수 bar()는 함수 foo()와 글로벌 스코프 같이 접근 가능한 스코프에 대한 클로저를 가진다. 또한 foo()의 내부함수이기 때문에 foo() 스코프에서 닫힌다. 클로저의 핵심인 "함수가 렉시컬 스코프 밖에서 실행될 때도 해당 스코프에 접근할 수 있게 하는 기능"에는 모호한 것이다.
아직은 이해가 잘 안 간다.
function foo(){ var a = 2; function bar(){ console.log(a); } return bar; } var baz = foo(); baz(); // 2
foo()를 실행하여 반환한 값(함수 bar())을 변수 baz에 대입하고 baz()를 호출한다. 이는 당연하게 변수 참조로 내부 함수 bar()가 실행된 것이다. 다만, 함수 bar는 함수가 선언된 렉시컬 스코프 밖에서 실행됐다.
foo()가 실행된 후에는 foo()의 내부 스코프가 사라졌다고 생각할 수 있다. 엔진이 가비지 콜렉터로 더는 사용하지 않는 메모리를 해제시키는 것을 알기 때문이다.
그러나 foo의 내부 스코프는 여전히 bar()에 의해 사용 중이다. 내부 함수 bar는 foo() 스코프에 대한 렉시컬 스코프 클로저를 가지고, foo()는 bar()가 나중에 참조할 수 있도록 스코프를 살려둔다.
즉, bar()는 여전히 해당 스코프에 대한 참조를 가지는데, 그 참조를 바로
클로저
라고 부른다.위의 코드에서 함수는 원래 코드의 렉시컬 스코프에서 완전히 벗어나 호출됐다. 클로저는 호출된 함수가 원래 선언된 렉시컬 스코프에 계속해서 접근할 수 있도록 허용한다. 어떤 방식이든 함수를 값으로 넘겨 다른 위치에서 호출하는 행위(콜백 등)는 모두 클로저가 작용한 예다.
function wait(message){ setTimeout(function timer(){ console.log(message); },1000); } wait("Hello, Closure!");
위 코드는 내부 함수 timer를 setTimeout()의 인자로 넘겼다. timer은 wait() 함수의 스코프에 대한 스코프 클로저를 갖고 있으므로 변수 message에 대한 참조를 유지하고 사용할 수 있다. wait() 실행 1초 후, 내부 스코프는 사라져야 하지만 익명의 함수가 여전히 wait 내부 스코프에 대한 클로저를 가지고 있다.
엔진 내부의 내부 함수 setTimeout()은 인자(함수 등)의 참조가 존재한다. 엔진은 해당 함수 참조를 호출하여 내부 함수 timer를 호출하므로 timer의 렉시컬 스코프는 여전히 남아있다.
좀 더 익숙한 코드로 생각해보자.
for(var i=1;i<=5;i++){ setTimeout(function timer(){ console.log(i); }, i*1000); }
위 코드의 목적은 1,2,3,4,5까지 한 번에 하나씩 일 초마다 출력하는 것이다.
그러나, 실제로는 일 초마다 한 번씩 6만 5번 출력된다.
반복문의 종료 조건은 i<=5 이기에 반복문이 끝나게 된 상태에서 i는 6이다. 출력된 값은 반복문이 끝났을 때의 i를 반영한 것이다.
timeout 함수의 콜백은 반복문이 끝나고 작동한다. setTimeout의 delay를 0으로 줬어도 매번 6을 출력한다.
원하는 코드의 목적을 생각하면 각각 그때의 i를 잡아두는 것이 필요하다. 위처럼 글로벌 스코프 클로저를 공유하여 하나의 i만이 존재하면 안 되는 것. 더 많은 닫힌 스코프가 필요하다. 그럼 IIFE를 사용하면 되는 걸까?
for(var i=1;i<=5;i++){ (function(){ setTimeout(function timer(){ console.log(i); }, i*1000); })(); }
결과는 의도대로 동작하지 않는다. 각각의 timeout 함수 콜백은 확실히 반복마다 각 IIFE가 생성한 자신만의 스코프를 가지지만, 해당 스코프는 비어있기 때문에 단순히 닫힌 스코프만으로는 부족하다는 것을 알 수 있다.
IIFE는 단순 빈 스코프이기에 자체 변수가 필요하다. 반복마다 i의 값을 저장할 변수가 필요한 것.
//(1) 해당 i값으로 초기화 되며 각 스코프가 만들어 지기에 의도대로 동작한다. for(var i=1;i<=5;i++){ (function(j){ setTimeout(function timer(){ console.log(j); }, j*1000); })(i); } //(2) for(var i=1;i<=5;i++){ (function(){ var j = i; setTimeout(function timer(){ console.log(j); }, j*1000); })(); }
IIFE를 사용하지 않고 블록 레벨 스코프를 가지는 키워드 let으로도 위 문제를 해결할 수 있다.
for(let i=1;i<=5;i++){ setTimeout(function timer(){ console.log(i); }, i*1000); }
반복문 초기식에서 let으로 선언된 변수는 반복할 때마다 선언되게 된다. 따라서 반복마다 이전 반복이 끝난 후의 값으로 초기화된다.
클로저는 표준이자, 함수를 값으로 마음대로 넘길 수 있는 렉시컬 스코프 환경에서 코드를 작성하는 방법이다.
정리하면 함수를 렉시컬 스코프 밖에서 호출해도 함수 자신의 렉시컬 스코프를 기억하고 접근할 수 있는 특성을 말한다.
이러한 클로저를 통해 반복문이 어떻게 작동하는지 추적해갈수도 있고, 현재 상태를 기억하게 할 수도 있으며, 정보의 은닉도 가능하다.
클로저 예제- 콜백 함수 내부에서 외부 데이터를 사용하고자 할 때
var students = ['young', 'ju', 'noh']; var $ul = document.createElement('ul'); students.forEach(function (student){ // (A) var $li = document.createElement('li'); $li.innerText = student; $li.addEventListener('click', function(){ // (B) alert('your name is ' + student); }); $ul.appendChild($li); }); document.body.appendChild($ul);
students 배열을 순회하며 li를 생성, 각 li를 클릭 시 해당 리스너에 alert로 해당 학생의 이름이 출력되는 콜백을 등록한다. 익명의 콜백함수 (A) 자체는 외부 변수를 사용하지 않고 있지만, 이벤트 리스너에 넘겨준 콜백 (B)는 내부에서 외부변수 student를 참조하고 있다(클로저 존재). 최소한 (B) 함수가 참조할 예정인 변수 student에 대해서는 (A)를 종료한 뒤라도 가비지 콜렉터 대상에서 제외되어 계속 참조 가능할 것이다.
- 정보 은닉(접근 권한 제어)
정보 은닉은 어떤 모듈 내부 로직에 대해 외부로의 노출을 최소화하여 모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 프로그래밍 개념 중 하나이다.
Java 같은 언어에는 외부에서 접근 가능한 멤버 변수에는 public, 내부에서만 사용하는 멤버 변수에는 private 접근 제한자를 붙인다. JS는 기본적으로 변수에 이러한 접근 권한을 직접 부여하도록 설계돼 있지 않다. 하지만 접근 권한 제어를 클로저를 이용하여 구분하는 것이 가능하다.
var outer = function(){ var a = 1; var inner = function(){ return ++a; }; return inner; }; var outer2 = outer(); console.log(outer2()); console.log(outer2());
outer 함수가 종료되면 inner 함수를 반환하므로 outer 함수의 지역변수인 a를 외부에서도 읽을 수 있게 됐다. 이렇듯 클로저를 활용하면 외부 스코프에서도 return을 활용해 선택적으로 일부 함수 내부 변수들에 대한 접근 권한을 부여 가능하다.
- React의 Hook
React 함수 컴포넌트에서는 useState hook을 사용하여 상태 변경을 감지하고 반영하게 된다. 이를 감지하려면 이전 상태에 대한 정보도 가지고 있어야 하는데, 이때 클로저가 사용된다.
node_modules/react/cjs/react.development.js
를 확인해 보면 useState 함수는 다음과 같이 구현되어 있다.resolveDispatcher
함수는ReactCurrentDispatcher
의 current 값을 받아 return 시킨다.ReactCurrentDispatcher
은 current 프로퍼티를 가진 전역에서 사용되는 객체이다.따라서, useState의 리턴 값은 ReactCurrentDispatcher라는 전역 객체에서 온다는 것이다.
클로저를 활용하여 useState가 어디에서 실행되었든 간에, useState 외부에 선언된 변수의 값에 접근하고 있다.
참고자료
"카일 심슨, YOU DON'T KNOW JS : 타입과 문법, 스코프와 클로저, 이일웅, 최병현, 한빛미디어, 2017"
https://poiemaweb.com/js-scope
"정재남, 코어 자바스크립트, 위키북스, 2019"
https://velog.io/@ggong/useState-Hook%EA%B3%BC-%ED%81%B4%EB%A1%9C%EC%A0%80