본문 바로가기
JS&TS

클로저 (부제: 결국은 스코프)

by Luke K 2022. 9. 23.

클로저는 아무도 듣는 사람 없이 쓰러진 숲 속의 나무와 같다. - You Don't Know JS(카일 심슨)

JS 코드 얘기를 할 때 제일 헷갈리는 얘기 중 하나가 클로저였다.
클로저를 공부하다보니 결국 내가 필요한 건 클로저의 정의를 이해하고 어떤 상황을 클로저라고 말하는 지 인지하는 거였다.
공부하며 얻게 된 인사이트를 공유하고 싶어 글을 적는다.

You Don't Know JS에 적힌 클로저의 정의는

함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능

정의를 이해하기 위해 렉시컬 스코프를 알아야한다.

렉시컬 스코프가 렉시컬 스코프인 이유

컴파일러 이론에서 배우는 렉서 가 키워드다.

자바스크립트가 동작할 때 엔진, 컴파일러, 스코프가 서로서로 정보를 공유하며 동작한다고 생각해보자.

엔진은 자바스크립트 프로그램의 실행을 담당한다.

컴파일러는 토크나이징, 렉싱, 파싱을 담당한다.

스코프는 자바스크립트 내에서 변수가 선언되면 리스트화 시키고 선언된 것들이 어떻게 적용 될지를 정한다.

렉시컬 스코프를 이해하기 위한 흐름이 길다.

1. 컴파일 후 실행 과정을 살펴보고

2. 컴파일 과정을 살펴본 다음

3. 실행 과정을 살펴보며 렉시컬 스코프를 한 문장으로 나타내보자.

엔진, 컴파일러, 스코프의 역할을 이해하기 위해 아래 코드가 컴파일링되고 실행 되는 과정을 살펴보자.

function print(message) {
    console.log( message );
}

print('개발나무 심기');

1. 컴파일링 단계에서 컴파일러가 print를 선언한다.
2. 실행 후 엔진은 스코프에 print에 대한 참조를 묻는다. (참조를 묻는다는 표현은 print가 저장돼있는 위치를 알려달라는 말과 같다.)
3. 스코프는 print의 위치를 알려준다.
4. 엔진은 print를 실행하기 위해 message에 대한 참조를 묻는다.
5. 스코프는 컴파일러가 message를 print의 인자로 선언했다고 알려준다.
6. 엔진은 string타입 개발나무 심기를 message에 대입한다.
7. 엔진은 스코프에 console에 대한 참조를 문는다.
8. 스코프는 console의 위치를 알려준다. (console은 글로벌에 내장 돼있음)
9. 엔진은 log를 console객체 내에서 찾는다.
10. 엔진은 message의 참조를 scope에 묻는다.
11. 스코프는 엔진에게 message의 참조를 알려준다.
12. 엔진은 message의 값이 개발나무 심기라는 것을 알고 log()에 넘긴다.

위 내용중에서 1번을 살펴보자.

컴파일링은 토크나이징, 렉싱, 파싱으로 이루어진다.

토크나이징은 문자를 쪼개기만하고 렉싱은 쪼개진 것에 의미를 부여하며 파싱은 토큰들을 렉싱과정에서 부여한 의미에 따라 트리구조로 만들어 기계가 읽기 위한 형태로 바꾼다.

여기서 렉싱과정을 통해 렉시컬 스코프를 이해해보면

스코프는 위 예제에서 보다시피 선언자 이름을 통해 선언돼있는 위치를 관리하고 검색할 수 있게 사용하는 규칙의 집합이다.

이제 렉싱에 따른 렉시컬 스코프를 이해하기 위해 위의 print함수 안에 약간의 코드를 추가해보자.

function print(message) {
    var slicing = message.slice(2);
    function makeNiceMessage(slicedMessage) {
        console.log(slicedMessage + '최고에요!');
    }
    makeNiceMessage(slicing);
}
print('개발나무 심기');

를 렉서가 의미를 부여할 때

1. print가 함수라고 의미를 부여하고 글로벌 스코프 내에 확인자 print가 있는 스코프가 생긴다.

2. print함수의 } 를 만나 print 함수의 문이 끝나기 전에 message, slicing, makeNiceMessage가 선언됐기 때문에 message, slicing, makeNiceMessage를 확인자로 가지는 스코프를 만들고

3. 2의 과정중 makeNiceMessage가 끝나기 전에 내부의 slicedMessage가 선언됐기 때문에 slicedMessage를 확인자로 가지는 또 하나의 스코프를 만든다.

정리하면 토큰의 의미를 부여하는 걸 렉싱이라고 하는데 렉싱할 때 선언을 판별하며 만들어지는 규칙 혹은 단위를 렉시컬 스코프라고 보면 될 것 같다.

그러면 렉시컬 스코프를 기억한 채로 다시 클로저를 살펴보자.

함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능

a함수 내부에 b함수, a함수 외부에 c함수가 있다고 생각해보자.

c함수에서 b함수를 사용해야하는 상황이 있다고 가정해보자.

어떻게 해야할까? "클로저를 이용하자"

function a(){
    const me = 'naamukim';
    function b(){
        console.log(me);
    }
    return b;
}

const c = a();
c(); // naamukim

함수 b는 a의 렉시컬 스코프에 접근할 수 있고, b 함수를 리턴한다.

c()는 b함수를 호출했다.

a가 실행된 후에도 a의 스코프는 사라지지 않는다. 왜냐하면 b는 a에 대한 렉시컬 스코프 클로저를 가지고, b가 나중에 참조할 수 있으니 a에 대한 스코프를 살려두는 것이다.

정리하면 내부함수를 자신이 속한 렉시컬 스코프 밖에서 사용해도 외부함수의 스코프를 참조하는 것이다.

그러면 문제를 하나 살펴보자

문제 출처: YOU DON'T KNOW JS

for (var i =1; i<=5; i++) {
    setTimeout( function timer() {
        console.log(i);
    }, i*1000);
}

콘솔에는 무엇이 찍힐까?

이코드는 1, 2, 3, 4, 5가 아닌 6, 6, 6, 6, 6이 나온다.

왜냐면 타이머에서 나왔을 때 i는 이미 for문이 다 돌아버린 상태이기 때문이다.

그러면 이를 클로저를 사용해서 1, 2, 3, 4, 5가 나오려면 어떻게 해야할까?

사실 정말 여러 방법이 있다. 사실 for문안에 i를 let으로 선언하는 것만으로도 해결이 가능하다.

반복문에서 let은 반복될 때마다 새로 선언되기 때문에 태스크 큐에 갔다가 복귀한 i도 1부터 시작한다.

하지만 내 전략은 setTimeout안에 써먹을 변수를 let으로 선언하여 스코프를 만들어 주는 것이다. (let은 선언할 때 클로저의 블록 스코프를 만든다.)

for (var i =1; i<=5; i++) {
    let j = i;
    setTimeout( function timer() {
        console.log(j);
    }, j*1000);
}

위의 클로져와 같은 방법으로 문제가 해결된다.

결론

클로저는 우리가 클로저인걸 알아차리고 쓸 때 의미가 생긴다.

스코프와 엔진이 코드를 실행 시키는 것을 생각해보면 클로져가 특정 렉시컬 스코프를 참조한다는 것을 알아차릴 수 있다.

일단 글을 마무리하지만 계속 공부하면서 특정 코드나 개념을 학습하다보니 클로져에 대한 이해가 늘었다면 내용들을 추가할 것이다.

댓글