클로저
함수와 함수가 선언된 렉시컬 환경의 조합
렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결된다. 그리고 이는 함수 정의가 평가되는 시점에 함수가 정의된 위치에 따라 결정된다.
함수의 값이 반환되어 이미 완료된 외부 함수의 변수를 참조하는 함수
자신을 포함하고 있는 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 외부 함수 밖에서 중첩 함수를 호출하더라도 외부 함수의 지역 변수에 접근할 수 있는 함수
렉시컬 스코프
클로저를 이해하기 위한 핵심은 아래와 같다.
자바스크립트가 어떻게 변수의 유효범위를 지정하는가?
스코프에 대한 이해가 선행되어야 클로저에 대해 이해할 수 있다. 스코프는 식별자가 유효한 범위를 말한다.
동적 스코프
: 함수 정의 시점에는 함수가 어디서 호출될지 알 수 없고, 함수가 호출되는 시점에 동적으로 상위스코프가 결정된다.렉시컬 스코프(정적 스코프)
: 함수 호출 위치와 상관없이 함수 정의 시점에 상위 스코프가 정적으로 결정된다.
자바스크립트는 실행 컨텍스트의 렉시컬 환경(코드가 실행된 곳, 그리고 그 주변의 환경)을 단방향으로 연결한 스코프 체인을 따라 변수를 참조하는 코드의 스코프에서 상위 스코프로 이동하며 변수를 검색한다. 여기서 중요한 점은 상위로 이동하기 때문에 상위 스코프에서 유효한 변수를 하위에서 참조할 수 있지만 그 반대는 불가하다는 것이다.
이를 정리해서 말하자면 자바스크립트는 함수 호출이 아닌 함수 정의 시점에 스코프 체인을 따라 상위 스코프로 이동하면서 변수가 선언된 곳을 찾아서 참조한다.
1 | const x = 1 |
위 함수의 실행 결과는 innerFunc()의 상위 스코프가 무엇이냐에 따라 다른 결과를 갖는다.
함수가 호출된 곳 기준(동적 스코프)
: 이 기준으로 하면 innerFunc()의 상위 스코프는 outerFunc함수의 지역 스코프, 그리고 전역 스코프이다.함수가 정의된 곳 기준(정적 스코프)
: 이 기준에 따르면 innerFunc(), outerFunc() 모두 전역에서 정의되었고, 전역에서 선언된 함수는 전역 코드 실행 이전에 먼저 평가되어 함수 객체를 생성하고, 자신이 정의된 스코프인 전역 스코프를 상위 스코프로 사용한다. 따라서 이 경우 상위 스코프는 전역 스코프이다.
자바스크립트는 정적 스코프를 따르기 때문에 이 경우 console.log에 찍히는 값은 1이다. 왜냐하면 함수가 어디서 호출되었느냐와 상관없이 자신이 정의된 전역 스코프를 상위 스코프로 여기기 때문이다.
하지만 아래와 같은 경우 innerFunc()의 결과값은 1이 아닌 10이다. 그 이유는 innerFunc()가 전역에 선언된 것이 아니라 outerFunc()라는 외부함수 내에 선언된 중첩함수이기 때문이다.
전역 변수는 전역 스코프를 가지고, 어디서나 참조할 수 있지만 함수와 같은 지역 변수는 함수 몸체 내부를 지역 스코프로 가지고, 자신이 선언된 지역과 하위 지역인 중첩함수에서만 참조할 수 있다. 자바스크립트는 스코프 체인을 따라 자신이 정의된 곳에서 상위로 이동하며 변수를 검색하는데 innerFunc()는 중첩함수이므로 이 함수의 상위 스코프는 전역이 아닌 innerFunc()를 감싸고 있는 outerFunc()가 된다. 즉, 이와 같이 전역에 선언된 것이 아닌 중첩함수는 계층적 구조를 가지기 때문에 내부 함수는 외부 함수의 값에 접근할 수 있어서 10이라는 값이 출력된다.
1 | const x = 1 |
함수의 호출과 렉시컬 스코프
자바스크립트는 함수가 정의된 곳을 기준으로 상위 스코프를 결정하는데, 정의된 환경과 호출된 환경을 다를 수 있다. 따라서 반드시 함수는 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다. 이를 통해 함수의 호출이 어디에서 이루어지든 이와 상관없이 자신이 기억하고 있는 상위 스코프의 식별자를 참조하고, 식별자에 바인딩된 값을 변경한다.
실행 컨텍스트: 자바스크립트는 싱글스레드 언어이므로 한 번에 하나의 실행 컨텍스트만 실행한다.
- 전역 코드: global 실행 컨텍스트에서 실행
- 함수 코드: 함수 실행 컨텍스트에서 실행
실행 컨텍스트의 흐름
- global 실행 컨텍스트 생성
- 전역 코드가 평가되는 시점에 함수 객체를 생성
- 함수 객체의 내부 슬롯 [[Environment]]에 전역 코드 평가 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경에 대한 참조를 저장
- 함수 호출
- 함수 실행 컨텍스트 생성
- 함수 렉시컬 환경 생성
- Environment Record
- this 바인딩
- OuterLexicalEnvironment Reference
OuterLexicalEnvironment Reference에 렉시컬 환경에 대한 참조가 할당. 즉 함수의 상위스코프에 대한 참조를 저장.
클로저와 렉시컬 환경
1 | function makeFunc() { |
위 함수에서 makeFunc()가 실행되어 displayName을 리턴하게 되면, 함수의 호출이 종료되어 생명 주기가 다하였기 때문에 더이상 makeFunc()함수 내부의 지역변수에 접근할 수 없을 것으로 생각된다. 그러나 클로저는 이와 같은 생명 주기가 끝난 외부의 변수도 참조할 수 있다. 외부함수인 makeFunc()가 종료되었으나 myFunc에 makeFunc()를 할당하였기 때문에 myFunc()를 호출하면 displayName을 리턴하고, 이미 함수의 실행이 끝나 접근할 수 없을 것이라 생각했던 name 변수의 값이 return 된다.
이처럼 중첩함수가 외부함수보다 더 오래 유지되는 경우 외부 함수 밖에서 중첩 함수를 호출하더라도 외부 함수의 지역 변수에 접근할 수 있고, 이를 클로저라 한다. 단, 중첩 함수가 외부 함수보다 더 오래 유지되는데, 상위 스코프의 어떠한 식별자도 참조하지 않는 경우는 클로저가 아니다.
클로저의 작동 원리
앞서 클로저는 함수와 함수가 선언된 렉시컬 환경의 조합이라고 하였기 때문에 이 점을 다시 생각해보면 클로저의 작동원리가 명확해진다.
다시 말해 함수가 선언(정의)된 위치의 스코프와 이 시점에 정적으로 결정된 상위 스코프(렉시컬 스코프)의 렉시컬 환경을 클로저라 한다.
- makeFunc(): 함수 선언문. 런타임 이전에 먼저 평가되어 함수 객체 생성. 이때 자신이 정의된 스코프인 전역 스코프를 렉시컬 환경에 기록한다.
- name: 전역변수. 런타임 이전에 평가되어 자신의 상위 스코프인 makeFunc()를 렉시컬 환경에 기록한다.
- displayName(): 함수 선언문. 런타임 이전에 평가되어 자신의 상위스코프인 name 변수와 외부함수 makeFunc()를 렉시컬 환경에 기록한다.
- myFunc: 자신의 상위 스코프인 makeFunc()를 렉시컬 환경에 기록한다.
각각의 코드들은 실행될 때 실행 컨텍스트에서 실행(전역 - 전역실행컨텍스트, 함수 - 함수 실행컨텍스트)되고, 코드 실행을 위해 실행 컨텍스트를 생성할 때 렉시컬 환경을 생성한다. 그래야 함수 내부에 있는 변수들을 저장할 수 있기 때문이다. 이 렉시컬 환경은 Environment Record, OuterLexicalEnvironment Reference, this 바인딩으로 구성되어 있는데, OuterLexicalEnvironment Reference에 렉시컬 환경에 대한 참조가 할당된다. 즉 함수의 상위스코프에 대한 참조를 저장하고 있다.
이렇게 렉시컬 환경에 각각의 함수, 변수들에 대한 정보가 저장되어 있는 상태에서 코드를 위에서 부터 아래로 한 줄씩 실행한다. 가장 상단에 있는 makeFunc()는 displayName을 리턴하면서 생명 주기가 끝이 난다. 생명 주기가 끝난다는 것은 실행 컨텍스트 스텍에서 pop된다는 의미이다. 하지만 makeFunc()의 렉시컬 환경은 여전히 남아있다. myFunc가 makeFunc()를 상위 스코프로 기억하고 있기 때문이다. makeFunc()의 중첩함수인 displayName() 역시 상위 스코프인 name 변수를 기억하고 있다. 이렇게 참조되고 있는 경우 가비지 컬렉션의 대상이 되지 않으므로 그대로 참조값이 유지된다.
그리고 myFunc가 makeFunc()를 호출하면 displayName을 리턴하고, myFunc()를 호출하는 시점에는 displayName()이 실행되고, displayName은 name 변수에 접근하여 alert(name)이 성공적으로 실행된다.