본문 바로가기

Javascript

[JavaScript] 스코프 클로저, Scope Closure

반응형

 

클로저는 새롭게 문법과 패턴을 배워야할 특별한 도구가 아닌 그저 인식하고 받아들이면된다.

이는 자바스크립트의 모든 곳에 존재하며, 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생한다.

그러므로 이 글을 통해 클로저의 전반을 파악하고, 목적에 따라 확인하고 받아들이고, 이용할 수 있도록 작성할 것이다.

 

예시 )

function test1() {
    var a = 2;
    function test2() {
        console.log(a); // 2
    }
    test2();
}
test1();

 

위 예시와 같이 test2()는 렉시컬 스코프 검색 규칙(RHS 참조 검색)을 통해 바깥 스코프의 변수 a에 접근할 수 있다.

즉, test2()는 test1() 스코프에 대한 클로저를 가진다.

달리 말하면 test2()는 test1() 스코프에서 닫힌다.

 

간단해보이지만 이는 사실 간단하지 않다..ㅠㅠ

 

예시 )

function test1() {
    var a = 2;
    function test2() {
        console.log(a); // 2
    }
    return test2;
}

var test3 = test1();

test3();

 

위 예시와 같이 test2()는 test1()의 렉시컬 스코프에 접근할 수 있고, test2() 함수 자체를 값으로 넘긴다.

즉, test2를 참조하는 함수 객체 자체를 반환하고 있다.

 

test1()를 실행하여 반환한 값을 test3 변수에 대입하고 실제로는 test3() 함수를 호출했다.

이는 당연하게도 다른 확인자 참조로 내부 함수인 test2()를 호출한 것이다.

test2()는 실행됐다.

 

그러나 이 경우에 test2()는 함수가 선언된 렉시컬 스코프 밖에서 실행됐다.

 

일반적으로 test1()이 실행된 후에는 엔진에서 가비지 콜렉터가 실행되 메모리를 해제시키므로

test1()의 내부 스코프가 사라졌을 것이라 생각할 것이다.

 

그러나 클로저는 이를 내버려두지 않는다..ㅠㅠ

 

사실 test1의 내부 스코프는 여전히 '사용중'이므로 해제되지 않는다.

그러면 누가 사용하는가? 바로 test2() 자신이다.

 

선언된 위치로 인해 test2()는 test1() 스코프에 대한 렉시컬 스코프 클로저를 가지고,

test1()는 test2()가 나중에 참조할 수 있도록 스코프를 살려둔다.

즉, test2()는 여전히 해당 스코프에 대한 참조를 갖는데 그 참조를 바로 클로저라고 부른다.

 

test1() 선언이 끝나고 수 밀리 초 후 변수 test3를 호출할 때,

해당 함수는 원래 코드의 렉시컬 스코프에 접근할 수 있고 이는 함수가 변수 a에 접근할 수 있다는 의미이다.

 

클로저는 호출된 함수가 원래 선언된 렉시컬 스코프에 계속해서 접근할 수 있도록 허용한다.

물론, 어떤 방식이든 함수를 값으로 넘겨 다른 위치에서 호출하는 행위 모두 클로저가 작용한 것이다.

 

예시 )

function test1() {
    var a = 2;
    function test2() {
        console.log(a); // 2
    }
    test3(test2);
}

function test3(fn) {
    fn();
}

 

예시 )

var fn;

function test1() {
    var a = 2;
    function test2() {
        console.log(a);
    }
    fn = test2;
}

function test3() {
    fn();
}

test1();
test3(); // 2

 

클로저에 대해 이해가 되었는가..

위 예시들은 다소 학술적이고 인위적으로 작성했다.

이를 좀 더 다듬어서 현실적으로 쓰고 있는 예시는 다음과 같다.

 

현실적인 예시 )

function wait(msg) {
    setTimeout( function timer() {
        console.log(msg);
    }, 1000);
}

wait("Hello"); // Hello

 

내부 함수 timer를 setTimeout()의 인자로 넘겼고, 

timer 함수는 wait()의 스코프에 대한 스코프 클로저를 가지고 있어 변수 msg에 대한 참조를 유지하고 사용할 수 있었다.

즉,wait() 실행 1초 후 wait의 내부 스코프는 사라져야하지만 익명의 함수가 여전히 해당 스코프에 대한 클로저를 가지고 있었다.

따라서 엔진은 해당 함수 참조를 호출하여 내장 함수 timer를 호출하므로 timer의 렉시컬 스코프는 여전히 온전하게 남아있다.

 

마찬가지로 또 다른 내용을 봐보자.

 

현실적인 예시 )

function setup(name, selector) {
    $(selector).click(function activator() {
        console.log("Active" + name);
    });
}

setup("Bot 1", "#bot_1");

 

제이쿼리를 사용한다면 위와 같은 코드를 많이 봤을 것이다.

이처럼 렉시컬 스코프에 접근할 수 있는 함수를 인자로 넘길때 그 함수가 클로저를 사용하는 것을 볼 수 있다.

 

그렇다면 가장 흔하고 표준적으로 사용하고 있는 내용도 봐보자.

 

흔하고 표준적인 예시 )

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

 

위 코드의 목적은 예상대로 '0', '1', '2', ... '5' 까지 한 번에 하나씩 일 초마다 출력해야할 것이다.

그런데 실제로 돌려보면 일 초마다 한 번씩 '6'만 5번 출력된다... :(

 

어떻게 그렇게 나오는 지 알아보자.

먼저 반복문이 끝나는 조건은 i가 0에서 하나씩 증가하다가 5가 아닐때 처음으로 끝나는 조건이 갖춰져 6이 되었다.

즉, 출력된 값은 반복문이 끝났을 때의 i 값을 반영한 것이다.

 

그러나 timeout 함수 콜백은 반복문이 끝나고 나서야 작동한다.

즉, 0부터 5까지 반복되었다 해도 함수 콜백은 확실히 반복문이 끝나고 나면 동작해서 결과로 매번 6을 출력한 것이다 !!

 

그렇다면 원하는 결과대로 값이 나오게 하려면 어떻게 해야하는 걸까?

반복마다 각각의 i 복제본을 '잡아'둬야한다.

즉, 반복할때 마다 새로운 닫힌 스코프가 필요한 것이다.

 

흔하고 표준적인 예시를 바꾼 예시 )

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

 

실행해보면 마찬가지로 결과가 원하는 대로 나오지 않았다... :(

분명히 더 많은 렉시컬 스코프를 생성했는데도 말이다.

 

이는 각각의 timeout 함수 콜백은 확실히 반복마다 각각의 IIFE가 생성한 자신만의 스코프를 가지므로 갇힌 스코프만으로는 부족하다.

즉, 반복마다 i의 값을 저장할 변수가 필요하다.

 

흔하고 표준적인 예시를 제대로 바꾼 예시 )


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

// 0
// 1
// 2
// 3
// 4
// 5

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

// 0
// 1
// 2
// 3
// 4
// 5

 

성공했다 :) 

 

IIFE를 사용하여 반복마다 새로운 스코프를 생성하는 방식을 사용하고,

timeout 함수 콜백은 원하는 값이 제대로 저장된 변수를 사용해 새 닫힌 스코프를 반복마다 생성할 수 있었다.

 

다시 말하자면

클로저를 피하기 위해 실제 필요한 것은 반복별 블록 스코프 이다.

 

그렇다면 키워드 let을 이용해 본질적으로 하나의 블록을 닫을 수 있는 스코프를 만들어 아래와 같이 코드 리팩토링도 가능할 것이다.ㅎㅎ

 

흔하고 표준적인 예시를 리팩토링한 예시 )

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

// 0
// 1
// 2
// 3
// 4
// 5

 


[참고] You Don't Know JS 타입과 문법, 스코프와 클로저 - 카일 심슨

 

반응형

❥ CHATI Github