이번 포스팅에서는 함수 vs 블록 스코프에 대한 비교와 호이스팅의 개념까지 정리를 해 보려고 한다.
함수 기반 스코프
스코프는 컨테이너나 바구니와 같은 구실을 하는 일련의 '버블'이고 변수나 함수 같은 확인자가 그 안에서 선언된다. 자바스크립트는 함수 기반 스코프를 사용하기 때문에 함수는 이러한 버블을 만든다. 많은 개발자들은 각각의 선언된 함수는 버블을 생성하지만, 다른 자료구조는 자체적인 스코프를 생성하지 않는다고 알고 있다.
위 예제 코드에서 foo()의 스코프 버블은 a,b,c와 bar를 포함한다. 따라서 foo() 바깥에서는 이들에게 접근할 수 없다.(접근 시 ReferenceError 발생) 하지만 foo() 안에서는 이 모든 확인자(a, b, c, foo, bar 등)에 접근이 가능하고, bar() 안에서도 섀도 확인자가 선언되지 않은 경우 접근이 가능하다. 이렇게 함수 스코프는 모든 변수가 함수에 속하고 함수 전체(중첩 스코프 포함)에 걸쳐 사용되며 재사용된다는 개념을 확고하게 한다. 이러한 디자인 접근법은 자바스크립트 변수의 '동적' 특성을 완전히 살려 다른 타입의 값을 필요에 따라 가져올 수 있지만, 스코프 전체에서 변수가 살아있다는 점에서 예상치 못한 문제가 발생할 수 있다.
일반 스코프에 숨기
작성한 코드를 함수 선언문으로 '숨기는' 방식은 스코프 버블을 만듦으로써 그 안에서 선언된 변수나 함수 선언문이 이렇게 만들어진 스코프에 묶이게 한다. 왜 이렇게 만들었을까? 여기에는 소프트웨어 디자인 원칙인 '최소 권한의 원칙'과 관련이 있다. 이 원칙은 모듈/객체의 API와 같은 소프트웨어를 설계할 때 필요한 것만 최소한으로 남기고 나머지는 '숨겨야' 한다는 것이다. 이 원칙에 따르면 아래에 코드를 어떻게 리팩토링 해야 하는지를 알 수 있다.
변수와 함수를 스코프 안에 숨기는 코드의 또 다른 장점은 같은 이름을 가졌지만 다른 용도를 가진 두 확인자가 충돌하는 것을 피할 수 있다는 점이다. 충돌 방지를 위해 사용할 수 있는 옵션 중 하나는 '모듈' 접근법이다. 확인자를 글로벌 스코프에 추가할 필요 없이, 특정 스코프로부터 의존성 관리자를 이용한 다양한 명시적인 방법으로 확인자를 가져와 사용할 수 있다. 물론 이런 도구를 쓴다고 모든 렉시컬 스코프 규칙에서 벗어날 수 있는 것은 아니다. 의존성 관리자는 그저 여기서 설명한 스코프 규칙을 적용해 모든 확인자가 공유 스코프에 누출되는 것을 방지하고, 우발적인 스코프 충돌을 예방하기 위해 충돌 위험이 없는 비공개 스코프에 확인자를 보관한다.
여기까지는 코드를 함수로 감싸 내부에 변수나 함수 선언문을 바깥 스코프로부터 함수의 스코프 안에 숨기는 것에 대해서 알아본 내용이다.
스코프 역할을 하는 함수
함수 선언문을 통해서 함수를 선언하면 해당 스코프를 오염시키고(위의 예제에서는 foo라는 이름으로 둘러싸인 글로벌 스코프), 함수를 직접 이름으로 호출(foo())해야만 실제 감싼 코드를 실행할 수 있기 때문에 바람직한 방법은 아니다. 어떻게 바꿀 수 있을까?
함수를 만약 이름 없이 부를 수 있고 또 그 함수가 자동으로 실행이 된다면 위에서 발생한 문제들을 해결할 수 있을 것 같다.
바뀐 코드를 살펴보면 함수 선언문이 아닌 함수 표현식으로 나타내었다. function 키워드가 구문의 시작 위치에 있으면 함수 선언문이고, 그렇지 않으면 함수 표현식이다. 바뀌기 전의 코드에서 함수 이름 foo는 함수를 둘러싼 스코프에 묶이고. foo() 라는 이름을 직접 호출했다. 바뀐 코드는 함수 자신의 내부 스코프에 묶여서 foo는 오직 line 4~5에 해당되는 스코프에서만 찾을 수 있고 바깥 스코프에서는 발견이 되지 않는다. 함수 이름 foo를 자기 내부에 숨기면 함수를 둘러싼 스코프를 불필요하게 오염시키지 않는다.
함수 표현식은 익명으로도 작성이 가능하다. 많이 사용하는 방식이지만 몇 가지 주의해야 할 점이 있다.
- 익명 함수는 스택 추적 시 표시할 이름이 없어서 디버깅이 더 어려울 수 있다.
- 이름은 보통 쉽게 이해하고 읽을 수 있는 코드 작성에 도움이 되는데, 익명 함수는 이런 이름을 생략한다. 기능을 잘 나타내는 이름은 해당 코드를 그 자체로 설명하는데 도움이 된다.
함수 표현식에 이름을 사용하면 특별한 부작용 없이 상당히 효과적으로 앞의 단점을 해결할 수 있기 때문에 함수 표현식을 사용할 때 이름을 쓰는 것이 항상 좋다.
그리고 위에서 살펴본 바뀐 코드는 함수를 즉시 실행할 수 있어서 즉시 호출 함수 표현식(Immediately Invoked Function Expression, IIFE) 라고 불리기도 한다.
위 예제에서는 window 객체 참조를 global 이라 이름 붙인 인자에 넘겨서 글로벌 참조와 비 글로벌 참조 사이에 명확한 차이를 만든다.
스코프 역할을 하는 블록
함수가 가장 일반적인 스코프 단위이긴 하지만 다른 스코프 단위도 존재하고 알아두면 유용하게 사용할 수 있다. 자바스크립트를 제외하고 많은 다른 언어들이 블록 스코프를 지원한다. 블록 스코프의 목적은 변수를 최대한 사용처 가까이에 최대한 작은 유효 범위를 갖도록 선언하는 것이다. 블록 스코프는 앞서 언급한 '최소 권한 노출의 원칙'을 확장하여 정보를 함수 안에 숨기고, 나아가 정보를 코드 블록 안에 숨기기 위한 도구이다. 안타깝게도 겉으로 보기에는 자바스크립트는 이러한 블록 스코프를 지원하지 않는 것처럼 보인다.
자바스크립트 ES6에서 새로운 키워드 let이 채택되었다. 키워드 let은 선언된 변수를 둘러싼 아무 블록({})의 스코프에 붙인다. 명시적이지는 않지만, let은 선언한 변수를 해당 블록 스코프를 이용한다고 말할 수 있다.
조금 뒤에 다루겠지만 호이스팅(끌어올리기)이라는 개념도 여기서 등장한다. 호이스팅은 선언문이 어디에서 선언되었든 속하는 스코프 전체에서 존재하는 것처럼 취급되는 작용을 말한다. 그러나 let을 사용한 선언문은 속하는 스코프에서 호이스팅 효과를 받지 않는다. 따라서 let으로 선언된 변수는 실제 선언문 전에는 명백하게 존재하지 않는다.
let은 앞에서 살펴본 for 반복문에서 특히 유용하게 사용할 수 있다. let 선언문은 둘러싼 함수 스코프가 아니라 가장 가까운 함수 임의의 블록에 변수를 붙인다. 따라서 이전에 var 선언문을 사용해서 작성된 코드는 함수 스코프와 숨겨진 연계가 있을 수 있으므로 코드 리팩토링을 위해서는 단순히 var를 let으로 바꾸는 것 이상의 노력이 필요하다.
블록 스코프가 유용한 또 다른 이유는 메모리를 회수하기 위한 클로저 그리고 가비지 콜렉션과 관련이 있다.
클릭을 처리하는 click 함수는 someReallyBigData 변수가 전혀 필요 없다. 따라서 이론적으로는 process()가 실행된 후 많은 메모리를 먹는 자료구조인 someReallyBigData는 수거할 수도 있다. 그러나 자바스크립트 엔진은 그 데이터를 여전히 남겨둔다. click 함수가 해당 스코프 전체의 클로저를 가지고 있기 때문이다.
호이스팅
많은 개발자들은 자바스크립트 프로그램이 실행되면 코드가 한 줄 한 줄 위에서부터 차례대로 해석이 될 것이라고 생각한다.
콘솔에 찍히는 값은 undefined가 아니라 2이다. var a 선언이 a = 2 뒤에 있어서 해당 변수가 재정의되어 기본값인 undefined를 가질 것이라 추측하는 것은 자연스러운 일이다. 이러한 알쏭달쏭한 결과에 대해서 이해하기 위해서는 컴파일러에 대해서 살펴보아야 한다.
자바스크립트 엔진이 코드를 인터프리팅하기 전에 컴파일 한다는 사실을 기억해보자. 컴파일레이션 단계 중에는 모든 선언문을 찾아 적절한 스코프에 연결해 주는 과정이 있었다. 이 과정은 렉시컬 스코프의 핵심이다. 변수와 함수 선언문 모두 코드가 실제 실행되기 전에 먼저 처리가 된다.
예를 들어 "var a = 2;" 를 하나의 구문이라고 볼 수 있지만 자바스크립트는 이를 두 개의 구문으로 본다. "var a;", "a = 2;" 첫 번째 구문은 컴파일레이션 단계에서 처리된다. 둘째 구문은 대입문으로 실행 단계까지 내버려 둔다. 따라서 첫 번째 코드조각은 다음과 같이 처리가 되는 것이다.
이 과정을 비유적으로 말하면 변수와 함수 선언문은 선언된 위치에서 코드의 꼭대기로 끌어올려진다. 이렇게 선언문을 끌어올리는 동작으로 호이스팅이라고 한다. 즉, 달걀(선언문)이 닭(대입문)보다 먼저다.
함수 foo의 선언문은 끌어올려졌으므로 foo를 첫째 줄에서도 호출할 수 있다. 그리고 호이스팅은 스코프 별로 작동한다. 또 주의할 점은 함수 선언문은 이와 같이 끌어올려지지만 함수 표현식은 다르다는 점이다.
위의 예제에서 변수 확인자 foo는 끌어올려져 둘러싼 글로벌 스코프에 붙으므로 foo() 호출은 실패하지 않고, ReferenceError도 발생하지 않는다. 그러나 foo는 아직 값을 가지고 있지 않는데(마치 foo가 함수 표현식이 아니라 진짜 선언문으로 생성된 것처럼), foo()가 undefined 값을 호출하려 해서 TypeError라는 오작동을 발생시킨다.
함수와 변수 선언문은 모두 끌어올려진다. 그러나 미묘한 차이가 있는데, 먼저 함수가 끌어올려지고 다음으로 변수가 올려진다.
엔진은 위의 코드를 아래와 같이 해석한다.
var foo가 중복 (그래서 무시되었다) 선언문이라는 점을 보자. var foo는 function foo() 선언문보다 앞서 선언되었지만, 함수 선언문이 일반 변수 위로 끌어올려졌다. 중복 선언을 조심하자. 특히 일반 변수 선언과 함수 선언을 섞는 것은 지양해야 한다.
참고자료
- <YOU DON'T KNOW JS (타입과 문법, 스코프와 클로저)> 카일 심슨 저
'Prog. Langs & Tools > JavaScript' 카테고리의 다른 글
[JS] 원시 타입과 객체 타입, 그리고 불변성에 관하여 (0) | 2023.01.31 |
---|---|
JS #17. 클로저(Closure) <The Final> (0) | 2021.03.15 |
JS #15. 이벤트 루프 (Event Loop) (0) | 2020.12.07 |
JS #14. 스코프(Scope) (0) | 2020.09.07 |
JS #13. 네이티브 객체(Native Objects) (0) | 2020.08.04 |