이번 포스팅에서는 자바스크립트의 클로저에 대해서 정리를 해 보려고 한다. 그리고 이 포스팅을 마지막으로 <You Don't Know JS> 정리 시리즈를 마무리 하고자 한다.
이 포스팅을 본격적으로 시작하기 전에 렉시컬 스코프에 대해서 본인이 이해를 잘 하고 있는지 스스로 한 번 확인을 해 보기를 바란다. 만약 본인이 그렇지 않다면 스코프에 대한 이 포스팅을 한 번 읽고 왔을 때 클로저에 대해 이해를 더 잘 이해할 수 있을 것이라 확신한다.
클로저
클로저는 자바스크립트에 숙련된 개발자도 잘 모르는 경우가 많다. 하지만 아이러니하게(?) 클로저는 자바스크립트의 모든 곳에 존재하고 우리는 그걸 받아들이기만 하면 된다. 클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생하는 것이다.
클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능을 말한다. 아래의 예제 코드를 한 번 보도록 하자.
이 예제에서 함수 bar()는 렉시컬 스코프 검색 규칙을 통해 바깥 스코프의 변수 a에 접근할 수 있다.(RHS 참조 검색) 정확하게 말하면 이 부분은 클로저의 일부이지, 클로저는 아니다. 학술적인 관점에서 보면 함수 bar()는 foo() 스코프에 대한 클로저를 가진다. 달리 말하면 bar()는 foo() 스코프에서 닫힌다. bar()는 중첩되어 foo() 안에 존재하기 때문이다. 하지만 이렇게 작성한 코드는 클로저를 알아보기가 힘들다.
클로저의 정체를 완전히 드러낼 코드를 한 번 살펴보자.
함수 bar()는 foo()의 렉시컬 스코프에 접근할 수 있고, bar() 함수 자체를 값으로 넘긴다. 이 코드는 bar를 참조하는 함수 객체를 반환한다. foo()를 실행하여 반환한 값(bar() 함수)을 baz라 불리는 변수에 대입하고 실제로는 baz() 함수를 호출했다. 이 때 함수 bar는 함수가 선언된 렉시컬 스코프 밖에서 실행이 되었다.
일반적으로 foo()가 실행된 후에는 foo()의 내부 스코프가 사라졌다고 생각한다. 엔진이 GC를 통해 사용하지 않는 메모리를 해제시킴을 알기 때문이다. 하지만 클로저는 이를 그냥 내버려 두지 않는다. foo()의 내부 스코프는 여전히 사용 중이며 bar()가 이를 사용한다. 선언 된 위치 때문에 bar()는 foo() 스코프에 대한 렉시컬 스코프 클로저를 가지고, foo()는 bar()가 나중에 참조할 수 있도록 스코프를 살려둔다. 즉, bar()는 여전히 해당 스코프에 대한 참조를 가지는데, 그 참조를 클로저라고 부른다.
이 예제에서는 내부 함수 timer를 setTimeout()에 인자로 넘겼다. timer 함수는 wait() 함수의 스코프에 대한 스코프 클로저를 가지고 있으므로 변수 message에 대한 참조를 유지하고 사용할 수 있다. wait() 실행 1초 후, wait의 내부 스코프는 사라져야 하지만 익명의 함수가 여전히 해당 스코프에 대한 클로저를 가지고 있다.
엔진 내부 깊숙한 곳의 내장 함수 setTimeout()에는 아마도 fn이나 func 정도로 불릴 인자의 참조가 존재한다. 엔진은 해당 함수 참조를 호출하여 내장 함수 timer를 호출하므로 timer의 렉시컬 스코프는 여전히 온전하게 남아있다.
클로저를 설명하는 가장 흔하고 표준적인 예제는 for문이 아닐까 싶다.
우리는 이 코드가 실행되기 전에 1초마다 하나씩 증가하면서 1,2,3,4,5 이렇게 출력한다고 생각한다. 하지만 실제로 돌려보면 6만 1초에 한 번씩 출력한다. 왜 6이 나오는 것일까?
반복문이 끝나는 조건은 i<=5 를 벗어났을 때이다. timeout 함수 콜백은 반복문이 끝나고 나서 작동한다. 따라서 i가 5를 초과하는 가장 작은 자연수인 6일 때 실행이 되는 것이다.
이 코드를 우리가 예상한 대로 동작하게 하려면 각각의 i에 대한 복제본을 잡아두어야 한다. 현재 반복문 안 5개의 함수들은 반복마다 따로 정의되었음에도 모두 같이 글로벌 스코프 클로저를 공유해 해당 스코프 안에는 하나의 i만이 존재한다. 따라서 모든 함수는 당연하게도 같은 i에 대한 참조를 공유한다. 여기서 필요한 것은 더 많은 닫힌(closed) 스코프이다. 매 반복문이 돌 때마다 하나의 새로운 닫힌 스코프가 필요하다.
이렇게 IIFE를 적용하여 반복문이 한 번 돌 때마다 닫힌 스코프를 만들어 주면 timeout 함수 콜백은 원하는 값이 제대로 저장된 변수를 가진 새 스코프를 생성해 사용할 수 있다.
ES6에서 적용된 let 선언문을 사용해서도 해결할 수 있다. let은 블록 스코프로 변수를 선언한다. 반복문 시작에서 let으로 선언된 변수는 한 번만 선언되는 것이 아니라 반복할 때마다 선언된다. 따라서 해당 변수는 편리하게도 반복마다 이전 반복이 끝난 이후의 값으로 초기화된다.
모듈
아래의 코드는 CoolModule이라는 함수에서 몇 가지 비공개 데이터인 something, another 그리고 내부 함수 doSomething(), doAnother()를 나타낸 코드이다. 이들 모두 foo()의 내부 스코프를 렉시컬 스코프로 가진다.
이 코드와 같은 자바스크립트 패턴을 모듈(Module)이라고 부른다. 가장 흔한 모듈 패턴 구현 방법은 모듈 노출이고 이 코드는 이것의 변형이다. CoolModule()은 그저 하나의 함수이지만, 모듈 인스턴스를 생성하려면 반드시 호출해야 한다. 최외곽 함수가 실행되지 않으면 내부 스코프와 클로저는 생성되지 않는다.
그리고 CoolModule() 함수는 객체를 반환한다. 해당 객체는 내장 함수들에 대한 참조를 가지지만, 내장 데이터 변수에 대한 참조는 가지지 않는다. 내장 데이터 변수는 비공개로 숨어져 있다. 객체의 반환 값은 최종적으로 외부 변수 foo에 대입되고, foo.doSomething()과 같은 방식으로 API의 속성 메서드에 접근할 수 있다.
함수 doSomething()과 doAnother()는 모듈 인스턴스의 내부 스코프에 포함하는 클로저를 가진다. 반환된 객체에 대한 속성 참조 방식으로 이 함수들을 해당 렉시컬 스코프 밖으로 옮길 때 클로저를 확인하고 이용할 수 있는 조건을 하나 세웠다. 이 모듈 패턴을 사용하려면 두 가지 조건이 있는데 다음과 같다.
- 하나의 최외곽 함수가 존재하고, 이 함수가 최소 한 번은 호출되어야 한다.(호출 할 때마다 새로운 모듈 인스턴스가 실행된다.)
- 최외곽 함수는 최소 한 번은 하나의 내부 함수를 반환해야 한다. 그래야 해당 내부 함수가 비공개 스코프에 대한 클로저를 가져 비공개 상태에 접근하고 수정할 수 있다.
하나의 함수 속성만을 가지는 객체는 진정한 모듈이 아니다. 함수 실행 결과로 반환된 객체에 데이터 속성들은 있지만 닫힌 함수가 없다면, 당연히 그 객체는 진정한 모듈이 아니다. 이 코드는 독립된 모듈 생성자 CoolModule()을 가지고, 생성자는 몇 번이든 호출할 수 있고 호출할 때마다 새로운 모듈 인스턴스를 생성한다.
위의 패턴에서 약간 변경된 오직 하나의 인스턴스, 싱글톤(Singleton)만 생성하는 모듈을 살펴보자. 앞의 코드에서 모듈 함수를 IIFE로 바꾸고 즉시 실행시켜 반환 값을 직접 하나의 모듈 인스턴스 확인자 foo에 대입시켰다.
ES6는 모듈 개념을 지원하는 최신 문법을 추가했다. 모듈 시스템을 불러올 때 ES6는 파일을 개별 모듈로 처리한다. 각 모듈은 다른 모듈 또는 특정 API 멤버를 불러오거나 자신의 공개 API 멤버를 내보낼 수도 있다.
정리
클로저는 함수를 렉시컬 스코프 밖에서 호출해도 함수는 자신의 렉시컬 스코프를 기억하고 접근할 수 있는 특성을 의미한다. 반복문을 예로 들면, 클로저를 통해 설령 우리가 기억을 못 했더라도 반복문이 어떻게 작동하는지 추적해 갈 수 있다. 또한, 클로저는 다양한 형태의 모듈 패턴을 가능하게 하는 효과적인 도구이다. 모듈 패턴은 다음 두 가지 특징을 가져야 한다. 1. 최외곽 래퍼 함수를 호출하여 외곽 스코프를 형성 2. 래핑 함수의 반환 값은 반드시 하나 이상의 내부 함수 참조를 가져야 하고, 그 내부 함수는 래퍼의 비공개 내부 스코프에 대한 클로저를 가져야 함
이렇게 총 17개의 자바스크립트에 관련된 포스팅을 마무리 하려고 한다. 물론 여기에 못 담은 개념도 너무나도 많이 있고, 또 깊이가 부족하게 다룬 부분도 있어서 앞으로 계속 업데이트를 해 나갈 생각이다.
참고자료
- <YOU DON'T KNOW JS (타입과 문법, 스코프와 클로저)> 카일 심슨 저
'Prog. Langs & Tools > JavaScript' 카테고리의 다른 글
[JS] 원시 타입과 객체 타입, 그리고 불변성에 관하여 (0) | 2023.01.31 |
---|---|
JS #16. 함수, 블록 스코프와 호이스팅(Hoisting) (0) | 2021.01.13 |
JS #15. 이벤트 루프 (Event Loop) (0) | 2020.12.07 |
JS #14. 스코프(Scope) (0) | 2020.09.07 |
JS #13. 네이티브 객체(Native Objects) (0) | 2020.08.04 |