특정 장소에 변수를 저장하고 나중에 그 변수를 찾는데 필요한 규칙
식별자 이름으로 변수를 찾기 위한 규칙의 집합
스코프는 다음과 같은 기준으로 분류할 수 있다.
스코프가 결정되는 시점을 기준으로 분류
렉시컬 스코프
코드를 작성할 때 결정된다.
함수가 선언된 위치와 관련있다.
(중첩 스코프)
동적 스코프
런타임에 결정된다.
함수가 호출된 위치와 관련있다.
(콜스택)
스코프의 단위에 관한 분류
함수 스코프 : 함수가 스코프의 단위
블록 스코프 : 블록이 스코프의 단위
동적 스코프와 대비되는 개념인 만큼 정적 스코프라고 표현해도 될 것 같은데 굳이 용어를 렉시컬 스코프라고 지은 이유부터 알아보자.
이를 알아보기 위해 다시 스코프의 정의로 돌아가보면, 스코프는 결국 변수를 저장하고 탐색하는 것에 관련된 개념이다.
우리는 자바스크립트 파일에 코드를 작성함으로써 변수를 선언하고
데이터를 할당한다.
하지말 실제 변수(및 함수)의 선언과 데이터의 할당 작업은 서로 다른 두 개의 주체에 의해 구분되어 수행된다.
변수(및 함수)의 선언은 컴파일러의 몫이고,
데이터의 할당은 자바스크립트 엔진의 몫이다.
그럼 다음의 예제를 통해 스코프가 어떻게 형성되는지 알아보자.
var a = 2;
function b() {}
달랑 위의 예제 두 줄로 된 자바스크립트 파일이 있다고 가정해보자.
먼저 컴파일러는 자바스크립트 파일을 읽어내려가면서 문자열 "var a = 2;"는 "var", "a", "=", "2", ";"로, 문자열 "function b() {}"는 "function", "b", "()", "{}"로 의미를 갖는 최소 단위(토큰)로 구분한다.
(이 과정을 Lexing(렉싱)이라 한다)
컴파일러는 Lexing의 결과물인 토큰들을 프로그램 문법 구조에 맞춰 트리 구조로 변환한다.
(이 과정을 Parsing이라 한다)
예제가 앞의 두 과정을 거치면 'var a', 'a = 2', "function b() {}" 세 부분으로 나뉘게 된다.
컴파일러는 자바스크립트 엔진이 실행할 수 있는 코드를 만들기 위해 이를 다시 읽어들인다.
'var a'는 변수를 선언하는 부분이다.
컴파일러는 '현재 스코프'에 식별자 a에 해당하는 변수가 있는지 검색한다.
'현재 스코프'에 식별자가 이미 있다면 별도의 작업을 하지 않고,
'현재 스코프'에 식별자가 없다면 컴파일러가 '현재 스코프'에 새로이 변수를 선언한다.
이때 변수의 초기값으로 undefined의 메모리 주소값이 할당된다.
(즉, 변수의 선언은 컴파일러의 몫이다)
다음으로 컴파일러가 읽는 구문은 'a = 2'이다.
마지막으로 컴파일러가 읽는 구문은 'function b() {}'이다.
앞서 변수의 선언문과는 달리 함수 선언문은 선언과 대입이 나누어지지 않는다.
따라서 '현재 스코프'에 식별자 b에 해당하는 변수가 있다면 해당 변수에 함수 b의 메모리 주소값을 덮어쓰고,
만약 '현재 스코프'에 식별자 b에 해당하는 변수가 없다면 '현재 스코프'에 b 변수를 새로 만들어 함수 b의 메모리 주소값을 할당한다.
즉, 함수 선언문에서는 초기값으로 undefined의 메모리 주소값을 할당하는 작업이 없다.
다만, 'var 변수명 = function 함수명() {}'과 같은 형태의 함수 표현식은 일반 변수와 동일하게 취급한다.
만약, 함수 선언문과 변수 선언문의 동일한 식별자를 사용할 경우 변수 선언문은 무시되고 함수 선언문만이 남는다
(이는 호이스팅에서 다시 언급한다)
참고로, 이런 컴파일의 단위는 함수이다.
이러한 컴파일 작업은 함수를 호출할 때마다 실행되는데, 이는 다음 포스팅에서 더 자세하게 다루겠다
(콜 스택과 실행 컨텍스트에 대해서도 다뤄야 하기 때문)
앞서 컴파일러가 생성한 코드의 내용은 변수 a에 2의 메모리 주소값을 할당하라는 것이다.
자바스크립트 엔진은 2의 메모리 주소값을 할당할 변수 a를 스코프에서 찾는다.
(여기서 변수의 탐색은 LHS로 이루어지는데, 아래에서 곧 설명한다)
'현재 스코프'에 변수 a가 있다면 사용하고,
'현재 스코프'에 변수 a가 없다면 상위의 스코프로 넘어가서 검색을 계속한다
(이는 중첩 스코프라는 개념으로 곧 설명한다)
여기서는 '현재 스코프'에 앞서 컴파일러가 선언해 둔 변수 a가 있으므로, 바로 2의 메모리 주소값을 할당한다.
앞서 살펴봤듯이 변수의 선언은 컴파일러가, 데이터의 할당은 자바스크립트 엔진이 한다.
즉, 자바스크립트 엔진이 코드를 실행할 때 필요한 변수는 컴파일 시점에서, 보다 구체적으로는 Lexing시점에서 이미 결정된다.
이것이 바로 렉시컬 스코프의 어원이다.
데이터를 할당받을 변수의 탐색(LHS)
할당된 데이터를 참조하기 위한 변수의 탐색(RHS)
var a = 2;
function woobuntu(a) {
// 아래에서 넘겨준 인자 '닉값'의 주소값이 a에 할당된다.
// 즉, a = '닉값'; 연산이 일어난다.
}
woobuntu("닉값");
function foo(a) {
// 3. 아래에서 인자로 넘겨준 2의 주소값을 a에 할당해야 하므로
// a에 대해 LHS탐색 수행
var b = a;
// 4. b에 대해서 LHS탐색 수행
// 5. b에 a가 가리키는 값을 할당하려는 것이므로,
// a가 가리키는 값을 찾기 위해 a에 대해 RHS탐색 수행
console.log(a + b);
// 6. console객체에 대해 RHS탐색 수행
// 7. a와 b에 대해 각각 RHS 탐색 수행
}
var c = foo(2);
// 1. c에 대해서는 LHS 탐색 수행
// 2. foo함수 호출을 위해서 foo에 대해 RHS 탐색 수행
위의 예제6번에서 console 객체를 찾는 것은 언급했지만 log메소드를 찾는 것은 언급하지 않았다.
이는 렉시컬 스코프의 변수 탐색 과정은 '일차 식별자' 탐색에만 적용되기 때문이다.
.log와 같은 접근은 property에 대한 접근이므로 객체의 내부 property인 [[Get]]을 호출해서 탐색한다.
(property descriptor 참조)
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2);
곧 설명하겠지만, 기본적으로 자바스크립트는 함수 기반 스코프를 사용한다.
따라서 여기서도 일단 함수 기반 스코프를 바탕으로 중첩 스코프에 대해 설명하고, 블록 스코프는 아래에서 다시 설명한다.
위의 예제는 총 3개의 스코프가 중첩되어 있다.
foo함수가 선언되고 호출된 글로벌 스코프
foo함수 스코프
bar함수 스코프
여기서 bar함수 스코프는 상위의 스코프인 foo함수 스코프에 완전히 포함되고, foo함수 스코프는 상위의 스코프인 글로벌 스코프에 완전히 포함된다.
(중첩과 교차가 다르다는 것을 명심하자)
이렇듯 중첩 스코프는 함수가 선언된 위치에 따라 결정된다.
앞서 언급한 두 종류의 변수 탐색은 '현재 스코프'부터 시작하여 점차 상위의 스코프로 넘어가면서 수행된다.
ex) bar함수가 실행될 때는 bar함수 스코프가 '현재 스코프'이다.
앞서 변수 탐색이 LHS와 RHS로 나뉜다고 했다.
이렇게 변수 탐색을 구분해야 하는 이유는 탐색을 실패했을 때의 결과가 다르기 때문이다.
function foo() {
// "use strict";
lhs = 1;
console.log(rhs1);
}
foo();
먼저, lhs에 대해 살펴보자.
foo함수 스코프 내에 lhs가 선언된 적이 없으니(var, const, let등의 키워드도 없고, 함수의 파라미터에도 설정되어 있지 않으니) 앞서와 같이 중첩 스코프를 타고 올라가면서 LHS 탐색을 수행한다.
그런데 글로벌 스코프까지 와도 lhs는 보이지 않는다.
다음으로 rhs에 대해 살펴보자.
rhs 역시 마찬가지로 foo함수 스코프 내에 선언된 바가 없으므로 중첩 스코프를 타고 올라가면서 RHS 탐색을 수행한다.
글로벌 스코프에서도 rhs를 찾을 수 없는데 이때는 strict모드와 관계없이 rhs가 선언되지 않았다며 ReferenceError를 반환한다.
앞서 스코프의 분류 기준을 '스코프가 결정되는 시점'과 '스코프의 단위' 두 가지로 나누었다.
그런데, '스코프의 단위'는 렉시컬 스코프에만 해당하는 개념이다.
즉, 함수 스코프와 블록 스코프는 렉시컬 스코프 안에서의 분류이다.
함수 스코프에서 모든 변수는 함수에 속하고, 이러한 변수는 중첩 스코프 상에서 함수 자신을 포함한 자신의 하위 스코프에서 사용할 수 있다.
즉, 거꾸로 말하면 함수 안에 들어 있는 변수는 함수 밖의(상위의) 스코프에서는 접근할 수 없다는 것이다.
앞서 함수 안에 들어 있는 변수는 함수 밖에서는 접근할 수 없다는 것을 확인했다.
이는 소프트웨어 디자인 원칙인 '최소 권한의 원칙'과 밀접한 관련이 있다.
'최소 권한의 원칙'은 모듈/객체의 API를 설계할 때 최소한의 접근 권한만을 부여해야 한다는 것이다.
function doSomething() {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething(2);
위의 예제에서 변수 b와 doSomethingElse함수는 오직 doSomething 함수 안에서만 필요로 한다.
하지만 글로벌 스코프에 선언되어 있으므로 doSomething함수 밖에서도 접근 가능하다.
doSomething함수에서만 필요로 하는 변수와 함수인데, doSomething함수 밖에서도 접근이 가능하니 변수나 함수의 내용이 바뀐다거나 하면, doSomething함수의 기능에 문제가 생길 여지가 있다.
그러니 다음과 같이 doSomething 함수에서만 사용하는 변수와 함수는 doSomething함수 스코프 안으로 숨기는 것이 좋다.
function doSomething() {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
doSomething(2);
이제 doSomething함수 밖에서 doSomethingElse나 b에 접근할 수 없으므로 doSomething함수의 기능은 보장된다.
즉, 각 변수와 함수에 대한 접근 권한을 최소화함으로써 프로그램의 안전성을 보장하려는 것이다.
function foo() {
function bar(a) {
i = 3;
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2);
}
}
foo();
foo함수를 호출한 결과 for문이 돌면서 bar함수를 호출하게 되는데,
문제는 변수 i가 foo함수 스코프 내에 속하기 때문에 bar함수에서도 접근 가능하고, for문에서도 사용한다는 것이다.
for문에서 처음 선언된 변수 i는 초기값으로 0을 가리킨다.
이제 bar함수를 호출하고 인자로는 0*2(즉, 0)이 넘어간다.
bar함수 내부에서 i = 3을 수행하는데, bar함수 스코프에는 변수 i가 선언된 적이 없기 때문에 상위 스코프인 foo함수 스코프로 넘어가 변수 i를 찾는다.
그래서 i는 3을 가리키게 된다.
매번 bar가 호출될 때마다 i는 3을 가리키게 되고, for문의 조건인 i<10에 걸리기 때문에 무한 반복을 돌게 된다.
따라서 bar함수 내부에서만 사용할 변수 i를 따로 만들기 위해,
'i = 3;'을 'var i = 3'과 같이 변경한다.
이제 bar함수에서 사용하는 변수 i는 bar함수 스코프 내에 존재하기 때문에 foo함수 스코프로 넘어가 foo함수 스코프의 변수 i를 건드릴 일이 없다.
모든 식별자 이름을 고유하게 설정하는 것도 방법이지만, 현실적으로 어렵기 때문에 위와 같이 스코프를 구분하여 변수를 숨기는 것이 최선이다.
변수를 스코프 안에 적절하게 숨기지 않은 라이브러리 여러 개를 한 프로그램에서 불러들이면 라이브러리 간에 변수의 충돌이 발생한다.
이런 변수 충돌을 막기 위한 '권장하지 않는' 방법 중 하나로 '네임스페이스'를 활용하는 방법이 있다.
글로벌 스코프에 고유의 이름을 가진 객체를 선언하여, 이 객체의 이름을 라이브러리의 네임스페이스로 이용하는 방법이다.
var nameSpaceOfLibrary = {
awesome: "stuff",
doSomething: function () {
// ...
},
doAnotherThing: function () {
// ...
},
};
즉 위와 같은 형태가 된다는 것인데, 문제는 렉시컬 스코프의 변수 탐색은 '일차 식별자'까지만 해당된다는 것이다.
'일차 식별자'인 nameSpaceOfLibarary는 글로벌 스코프에 있으니 이는 어디서든 접근 가능하며,
awesome이나 doSomething은 객체의 property이기 때문에 이러한 property에 대한 접근은 렉시컬 스코프의 변수 탐색이 아닌 객체 내부 property인 [[Get]]의 호출로 이루어진다.
다시 말하자면, 라이브러리의 모든 변수에 접근 가능하다는 것이다.
변수의 충돌 문제는 막았으나, 프로그램의 안정성 문제로 돌아가게 된다.
위의 예제에서는 글로벌 스코프에 foo라는 식별자로 함수가 선언된다.
따라서 글로벌 스코프에서 foo라는 식별자로 foo함수에 접근할 수 있다.
그리고 foo함수 내부에서도 foo함수 자신을 참조할 수 있다.
(중첩 스코프를 타고 올라가면 글로벌 스코프에 foo함수가 있기 때문)
이번 예제에서는 글로벌 스코프에 bar라는 식별자로 변수가 선언되고, 코드 실행 시점에서 함수 foo의 메모리 주소값이 bar에 할당된다.
bar함수를 실행하면 foo함수의 내용을 출력한다.
그런데 전역 스코프에서 foo를 출력하면 RHS 탐색이 실패하여 foo가 선언되지 않았다고 한다.
즉, 두번째 예제에서 foo함수는 글로벌 스코프가 아닌 foo함수 스코프 내에 선언되어 있는 것이다.
다시 말해 함수 선언문은 자신이 선언된 스코프의 변수가 되고, 함수 표현식은 함수 자신의 스코프의 변수가 된다.
(다음 포스팅에서 개발자 도구를 사용한 예제가 있으니 이를 참고하면 이해가 더 쉬울 것 같다)
함수 표현식의 이러한 성질을 이용하면 함수의 상위 스코프를 오염시키지 않는 방법이 몇 가지 있는데 이에 대해 알아보자
참고로, 함수 선언문과 함수 표현식은 function키워드가 구문의 시작 위치에 있는지로 구분한다.
setTimeout(function foo() {
console.log("1초 후에 출력된다");
}, 1000);
// console.log(foo);
// 당연히 foo의 RHS 탐색은 실패하고 ReferenceError를 반환한다.
앞서 언급했듯이 function키워드가 구문의 시작 위치에 있지 않으므로, 이렇게 콜백에 함수를 넘겨주는 것 또한 함수 표현식이다.
이렇게 foo함수가 setTimeout함수의 콜백 인자로만 사용될 경우, 다른 곳에서 foo함수에 접근할 수 있도록 할 이유가 없다.
그러니 foo함수가 다른 곳에서도 사용될 것이 아니라면 굳이 setTimeout함수 밖에서 선언하여 콜백의 인자로 전달할 필요가 없다.
setTimeout(function () {
console.log("1초 후에 출력된다");
}, 1000);
아마 대부분은 이렇게 이름 없는 함수를 콜백의 인자로 넘기는 형태에 더 익숙할 것이다.
이렇게 이름이 없는 형태의 함수 표현식을 '익명 함수 표현식'이라 한다.
(함수 선언문은 이름 없이는 선언이 불가능하다)
그러나 익명 함수의 경우 스택 추적 시 이름이 표시되지 않아 디버깅이 어렵다.
또한, 함수의 이름 자체만으로도 코드에 대한 정보가 되므로 되도록 익명 함수 표현식은 사용하지 않는 편이 좋다.
위의 예제에서처럼 함수를 ()로 감싸는 것 또한 함수 표현식이다
(function키워드가 구문의 시작 위치에 있지 않으므로)
그렇게 감싼 함수를 ()로 바로 호출시키는 것을 '즉시 호출 함수 표현식'이라 부른다.
이처럼, 일회성으로 실행되는 함수의 경우 굳이 다른 곳에서 접근하게 권한을 줄 필요가 없기 때문에 함수 표현식으로 만들어 즉시 실행시키면 된다.
for (var i = 0; i < 10; i++) {
console.log(i);
}
위와 같이 코드를 짰다면, 변수 i를 오직 for문 안에서 유효 범위를 갖게 하기 위한 의도일 것이다.
그러나 지금까지 살펴봤듯이 변수 i는 글로벌 스코프에 속해, 프로그램 어디서나 접근 가능하다.
그렇다고 아래와 같이 코드를 짜는 것은 너무 번거롭다.
if (true) {
let woobuntu = "닉값하고 싶다";
console.log(woobuntu);
}
이 경우 woobuntu는 자신을 둘러싼 블록인 if문의 블록을 스코프로 가진다.
대부분 이러한 형태의 블록 스코프가 익숙할 텐데 사실 이는 썩 명시적인 방법은 아니다.
if (true) {
{
let woobuntu = "닉값하고 싶다";
console.log(woobuntu);
}
}
위와 같이 해당 변수가 블록 스코프에 속함을 명시적으로 표현해주는 것이 좋다고 한다.
이렇게 하면, 리팩토링을 할 때 if문의 위치나 의미를 변화시키지 않고도 전체 블록을 옮기기가 쉬워진다고 한다.
for (var i = 0; i < 10; i++) {
// 여기서 변수 i는 컴파일 시점에 단 한번 선언된다.
console.log(i);
}
for (let i = 0; i < 10; i++) {
// 하지만 let을 이용한 변수의 선언은 자바스크립트 엔진이 실행 단계에서
// 처리하기 때문에 반복문이 돌 때마다 매번 변수를 다시 선언한다.
// 즉 아래의 변형된 for문이 실행된다고 생각하면 된다.
console.log(i);
}
for (var i = 0; i < 10; i++) {
let k = i;
// 이렇게 매번 변수에 증가된 i값이 할당되는 것이다.
console.log(k);
}
const 역시 블록 스코프에 변수를 선언하는 한 방법이지만, 문자 그대로 상수를 의미한다.
따라서 선언만 먼저 해두고 할당을 나중에 한다던가,
나중에 값을 바꾼다던가 하는 행위는 불가능하다.
다만, 값이 객체라면 property의 값을 바꾸는 것은 가능하다.
(당연히 하위 객체 타입 모두에 해당되는 내용이다)
식별자 | a | a.woo | a.buntu | |||
---|---|---|---|---|---|---|
주소 | @100 | @101 | @102 | @103 | @104 | @105 |
데이터 | @101 | {woo:@102, buntu:@103} | @104 | @105 | "우" | "분투" |
식별자 | a | a.woo | a.buntu | ||||
---|---|---|---|---|---|---|---|
주소 | @100 | @101 | @102 | @103 | @104 | @105 | @106 |
데이터 | @101 | {woo:@102, buntu:@103} | @106 | @105 | "분투" | "리눅스" |
따라서 a의 property인 woo와 buntu의 데이터값은 얼마든지 다른 메모리 주소를 참조할 수 있다.
이러한 property값도 불변값으로 만들고 싶다면, property descriptor를 이용한 불변 객체를 만드는 것이 타당하다.
위 예제에서 보다시피 catch문의 err는 catch문 블록 밖에서는 접근이 불가능하다.
이를 활용하여 ES6이전 환경에서도 블록 스코프를 활용할 수 있다.
앞서 var를 이용한 변수의 선언은 컴파일러가 컴파일할 때,
데이터의 할당은 자바스크립트의 엔진이 실행할 때 처리한다는 점을 살펴봤다.
즉, 자바스크립트 엔진이 코드를 실행하는 시점에는 스코프에 이미 컴파일러가 생성해 둔 변수들이 존재한다.
foo();
// 컴파일 시점에서 밑의 함수 선언문을 컴파일러가 읽고, 글로벌 스코프에 함수 foo를 선언해두었기 때문에 여기서 foo의 RHS 탐색은 성공한다.
function foo() {
console.log(a);
// 컴파일 시점에서 밑의 var a를 컴파일러가 읽어 foo함수 스코프에 변수 a를 선언해두었기 때문에 역시 여기서 a의 RHS 탐색은 성공한다.
// 다만, 아직 a = 2;코드가 실행되지 않았기 때문에 foo함수 스코프에 있는 변수 a는 초기값으로 undefined를 가리키고 있는 상태이다.
var a = 2;
}
function foo() {
var a;
console.log(a);
a = 2;
}
foo();
foo();
// 컴파일 시점에서 컴파일러가 밑의 'var foo'를 읽고 글로벌 스코프에 변수 foo를 선언해두었다.
// 그러나 아직 foo = function bar(){}가 실행되기 전이므로 foo는 초기값 undefined를 가리킨다.
// undefined는 함수가 아닌데 함수처럼 호출했으니 TypeError를 반환한다.
bar();
// 기명 함수 표현식에서 함수, 즉 함수의 식별자는 함수 자신의 스코프에서 변수가 된다.
// 즉, 해당 함수 밖에서 접근할 수 없다.(ReferenceError 반환)
var foo = function bar() {
console.log(bar);
// 비록 앞서 호출한 foo가 잘못된 위치에서 호출되어 이 구문이 실행되지는 않겠지만,
// 정상적인 호출이었다면, 기명 함수 표현식에서 함수의 이름 bar는 함수 자신의 스코프의 변수로 존재하기 때문에
// 여기서 bar에 대한 RHS탐색은 성공한다.
};
foo();
// 밑에서 변수 선언문과 함수 선언문이 동일한 식별자 foo를 사용하므로,
// 변수 선언문은 무시되고, 함수 선언문만 남아 함수가 호출된다.
var foo;
function foo() {
console.log(1);
}
foo = function () {
console.log(2);
};
// 변수 foo의 값을 새로운 함수의 메모리 주소값으로 대체한다.
console.log(a);
// ReferenceError: Cannot access 'a' before initialization
let a = 3;
// 브라우저와 Node.js에서의 error가 다르네;;
아예 선언하지 않은 변수에 접근할 경우 "Uncaught ReferenceError: a is not defined"가 뜨는 것을 고려해보면, 컴파일 시점에서 변수의 선언 자체는 감지하는 것 같은데 정확히 무슨 원리로 안 되는 것인지는 모르겠다.
(심지어 YDKJS에서도 그냥 호이스팅이 안된다고만 설명되어 있을 뿐 왜 안 되는지에 대한 설명은 없다)
값을 초기화하기 전에 접근하면 안 된다니 렉시컬 스코프의 규칙을 벗어나는 건지 뭔지 모르겠다
(x같은 자스)
렉시컬 스코프에 대해 제대로 이해했다면, 사실 클로저는 따로 이해해야 할 부분이 아니다.
클로저라는 명칭을 따로 붙일 것도 없이 클로저가 곧 렉시컬 스코프고, 렉시컬 스코프가 곧 클로저이기 때문이다.
function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}
foo(); // 2
이것이 지금까지 다뤄온 예제의 형태로, 함수(bar)가 자신이 선언된 스코프에서 호출되는 경우이다.
지금까지 살펴본 것처럼 bar함수에서 a에 대한 RHS탐색을 시작하면, 중첩 스코프를 타고 올라가 a를 찾는 것이 자연스럽게 이해가 된다.
그렇다면 다음의 예제는 어떨까?
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2
함수(bar)가 자신이 선언된 스코프 밖에서 호출된 경우이다.
만약, (bar함수를 참조하는) baz함수가 글로벌 스코프에서 호출되었기 때문에 foo함수 스코프에 있는 변수 a에 접근하지 못하는 것이 아닌가 생각된다면 렉시컬 스코프 개념에 대해 완전히 이해하지 못한 것이다.
앞서도 언급한 것처럼, 렉시컬 스코프는 렉스 타임에 결정되는 스코프이다.
작성된 자바스크립트 파일을 컴파일하면서 함수가 어디에 선언되었는지를 바탕으로 중첩 스코프의 구조가 결정된다.
그러니 함수가 어디에서 호출되었는지는 렉시컬 스코프와 하등 관계가 없다.
bar함수를 어디에서 호출하던 bar함수의 중첩 스코프는 'bar함수 스코프' -> 'foo함수 스코프' -> '글로벌 스코프'의 구조를 갖는 것이다.
function foo() {
var a = 2;
function bar() {
function baz() {
console.log(a);
}
return baz;
}
return bar();
}
var closure = foo();
closure(); // 2
foo함수의 호출 결과로 closure변수는 baz함수를 가리키게 된다.
baz함수를 호출하면, baz함수가 선언될 당시의 중첩 스코프(baz함수 스코프 -> bar함수 스코프 -> foo함수 스코프 -> 글로벌 스코프)를 따라 변수 a를 탐색한다.
foo함수 스코프에 변수 a가 있기 때문에 RHS 탐색은 성공한다.
아마 이 개념이 헷갈리는 사람들은 garbage collector에 대한 개념이 그 원인일 것이다.
foo함수가 호출이 '완료'된 이후 foo함수 스코프에 존재하는 변수들을 garbage collector가 수거해서 메모리를 절감한다고 생각하기 때문이다.
물론, 이는 맞다.
다만, 함수가 자신이 선언된 스코프 밖에서 호출되지 않을 때에 한해서.
위의 예제처럼 함수가 함수를 반환하는 경우나, 아래에서 살펴보는 콜백의 경우처럼 함수가 자신이 선언된 스코프 밖에서 호출되는 경우에는 해당 함수의 중첩 스코프 상에 존재하는 변수들을 메모리에서 수거하지 않는다.
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait("Hello, closure");
timer함수의 중첩 스코프 구조는 'timer함수 스코프 -> wait함수 스코프 -> 글로벌 스코프'이다.
따라서 wait함수의 호출 결과로 1초 후에 timer함수가 호출되더라도 여전히 wait함수 스코프에 존재하는 message에 접근할 수 있다.
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
1초마다 1, 2, 3, 4, 5와 같이 증가한 숫자를 출력할 목적으로 코드를 작성했지만, 예상과는 달리 6만 1초 간격으로 5번 출력될 뿐이다.
이는 사실 비동기에 대한 이해가 필요하지만, 앞뒤 자르고 얘기하자면 콜백 함수는 반복문이 끝나고 실행된다.
반복문이 끝난 시점에서 글로벌 스코프에 존재하는 i는 6을 가리키고, timer함수는 자신의 중첩 스코프에서 이 i를 찾아 출력한 것이다.
이 문제에 대한 해답은 반복문마다 호출되는 함수가 다 다른 함수라는 것이다.
timer함수를 밖에서 선언한 뒤 콜백으로 넘겨주는 것이 아니라, 반복문을 돌때마다 timer함수를 선언했기 때문이다.
이 timer함수들이 모두 같은 중첩 스코프 구조를 가지고 있기 때문에 한 함수가 5번 호출된 것이라 착각하기 쉽지만, 실제로는 5개의 다른 함수가 각각 1번씩 호출된 것이다.
각각이 다른 함수라는 것이 중요한 이유는, 각각이 반복문 안에서 다른 스코프를 가질 수 있기 때문이다.
for (var i = 1; i <= 5; i++) {
(function (k) {
setTimeout(function timer() {
console.log(k);
}, k * 1000);
})(i);
}
setTimeout함수를 IIFE로 감싸면서, 원래의 중첩 스코프 구조(timer함수 스코프 -> 글로벌 스코프)에서 한 단계가 더 추가되었다(timer함수 스코프 -> IIFE 스코프 -> 글로벌 스코프)
또한, 반복을 돌 때마다 i의 값을 IIFE안에서 k로 고정시킴으로써 모든 timer함수들의 IIFE 스코프는 저마다 다른 k값을 가지게 되었다.
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
앞서 let을 설명할 때도 다루었지만, let키워드가 반복문과 결합되면 반복을 돌때마다 변수를 새로 선언한다.
또한, let 키워드는 블록을 스코프로 간주하기 때문에 for문의 블록이 앞선 해답의 IIFE의 역할을 하게 된다.
(=스코프를 생성한다)
for (var i = 1; i <= 5; i++) {
(function () {
var k = i;
setTimeout(function timer() {
console.log(k);
}, k * 1000);
})();
}
그럼 함수가 자신이 선언된 스코프 밖에서 호출될 때는 해당 중첩 스코프 상에 존재하는 모든 변수들은 속절없이 메모리를 잡아먹고 있어야 하는걸까?
꼭 그런 것도 아니다
function foo() {
var a = 2;
function someFunction(data) {
// do something
}
{
const someReallyBigData = {};
someFunction(someReallyBigData);
}
function bar() {
function baz() {
console.log(a);
}
return baz;
}
return bar();
}
var closure = foo();
closure(); // 2
앞서 '충돌 회피'에 대한 대응책으로 '글로벌 네임스페이스'를 다룬 적이 있다.
이 방법은 변수의 충돌 자체는 막을 수 있지만, 라이브러리의 모든 변수에 접근 권한이 주어지기 때문에 안전성의 문제가 있었다.
이제 클로저를 이용한 대안인 모듈에 대해 알아보자.
function someModule() {
var someData = "뭐시기";
function processData() {
console.log(someData);
}
return {
processData: processData,
};
}
var woobuntu = someModule();
woobuntu.processData();
위와 같은 자바스크립트 코드 패턴을 모듈이라고 한다.
앞선 '글로벌 스페이스'처럼 모듈 객체(woobuntu)가 하나의 네임 스페이스 역할을 하기 때문에 다른 모듈에 존재하는 변수들과 someModule내에 존재하는 변수들이 충돌을 일으킬 일이 없다.
나아가 someModule함수가 내부적으로 비공개 변수는 숨기고, 이러한 비공개 변수를 렉시컬 스코프로 가지는 함수만을 반환하기 때문에 '글로벌 스페이스'에서 존재했던 안정성 문제 또한 해결되었다.
모듈에 대해서는 Node.js디자인 패턴과 관련하여 다룰 것이 한가득이라 이후 별도의 포스팅에서 자세히 다룰 예정이다.
많은 도움이 되었습니다.
감사합니다. ^^