Skip to content

Kyunghwa Yoo

YOU DON'T KNOW JS - 스코프와 클로저

javascript3 min read

스코프란 무엇인가?

스코프는 특정 장소에 변수를 저장하고 나중에 그 변수를 찾기 위해 정의된 규칙이다. 스코프 규칙은 어디서 어떻게 정의될까? 사실 자바스크립트는 컴파일러 언어다.

컴파일러가 언어를 처리하는 과정을 Compilation 이라고 한다. Compilation은 보통 3단계를 거친다.

  1. Tokenizing / Lexing
    • 문자열을 나누어서 Token 이라 불리는 의미있는 조각으로 만드는 과정이다.
    • Lexing 은 Tokenizer가 상태 유지 파싱 규칙을 적용해 하나의 토큰이 별개의 토큰인짖 다른 토큰의 일부인지를 파악하는 과정이 포함된다.
  2. Parsing
    • 토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형태로 바꾸는 과정
    • 파싱의 결과로 만들어진 트리를 AST(Abstract Syntax Tree) 라고 한다.
  3. Code-Generation
    • AST를 컴퓨터에서 실행코드로 바꾸는 과정.

자바스크립트 엔진은 기존 컴파일러와 다르게 미리 Compilation 을 수행할 수 없어서 최적화할 시간이 많지 않다.

엔진이 컴파일러가 생성한 코드를 실행할 때 변수가 선언된 적이 있는지 스코프에서 검색한다. 변수가 왼쪽에 가깝게 있으면 LHS, 변수가 오른쪽에 가깝게 있으면 RHS이다. 엔진이 아직 선언되지 않은 변수를 찾을 때 RHS로 찾으면 ReferenceError 가 발생한다. 하지만 LHS로 찾을 때 없으면 strict mode 가 아닌 경우에 한하여 새로운 변수를 글로벌로 생성하고 엔진에 넘겨준다.

렉시컬 스코프

  • 렉시컬 스코프는 프로그래머가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초해서 Lexer가 코드를 처리할 때 확정되는 스코프다.
  • 여러 중첩 스코프 층에 걸쳐 같은 확인자 이름을 정의할 수 있다. 이를 Shadowing 이라고 한다. (더 안쪽의 확인자가 더 바깥쪽의 확인자를 가리키는 것)
  • 렉시컬 스코프 검색 과정은 1차 확인자 검색에만 적용된다. (ex. var a = 43)

함수 vs 블록 스코프

자바스크립트는 함수 기반 스코프이므로 일반적으로 함수 단위로 스코프를 가진다. 하지만 블록스코프도 분명히 존재한다.

함수로 감싸는 이유

코드를 함수로 감싸면 새로운 스코프가 생성되어 변수와 함수를 '숨길' 수 있다. 그렇게 하는 여러 목적 중 하나는 소프트웨어 디자인 원칙인 '최소 권한의 원칙'에 따라 필요한 최소만 남겨놓고 숨겨야 안전한 코드가 된다. 또 하나는 같은 이름을 가졌지만 다른 기능을 하는 것들의 충돌을 방지할 수 있다.

익명함수의 단점

  • 익명함수는 스택 추적 시 표시할 이름이 없어서 디버깅이 더 어렵다.
  • 이름 없이 함수 스스로 재귀 호출을 하려면 폐기예정인 argumenst.callee 참조가 필요하다. (자기자신을 해제하는 이벤트함수에서 주로 그렇다.)
  • 이름은 보통 쉽게 이해하고 읽을 수 있는 코드 작성에 도움이 되는데, 익명 함수는 이런 이름을 생략한다.

블록 스코프

  • 자바스크립트에서 공식적으로 블록 스코프를 지원하지 않더라도 불필요하게 다른 스코프를 오염시키지 않도록 블록스코프를 지향해야 한다.

  • try catch에서 catch문의 인수로 사용되는 error 객체는 catch에서만 접근 가능한 블록 스코프다.

  • let, const 같은 ES6 변수 선언 문법은 호이스팅을 따르지 않는다.

    1function variableHoisting() {
    2 console.log(_var);
    3 console.log(_let); // Reference Error
    4 console.log(_const); // Reference Error
    5
    6 var _var = 'var';
    7 let _let = 'let';
    8 const _const = 'const';
    9}
    10
    11variableHoisting();
  • 많은 메모리를 잡아먹는 작업이 한번 수행된 뒤 필요 없는 경우에 따로 블록으로 감싸서 가비지콜렉션이 진행되도록 하는 것이 효과적이다.

호이스팅

자바스크립트 엔진은 컴파일을 먼저 한 다음 코드를 인터프리팅 한다. 선언문은 컴파일 단계에서 처리되고 대입문은 실행단계에서 처리된다. 호이스팅이란 변수와 함수 선언문은 선언된 위치에서 코드의 꼭대기로 '끌어올려' 지는 동작을 말한다. 호이스팅은 스코프별로 작동한다. 함수 선언문은 정상적으로 끌어올려지지만 함수 표현식은 다르다.

1foo(); // not ReferenceError, but TypeError
2var foo = function bar() {}

foo 라는 변수는 존재하기 때문에 ReferenceError 가 호출되지 않지만 foo가 뭔지 모르는데 실행하려고 하기 때문에 TypeError가 난다.

함수가 먼저다.

함수와 변수선언문은 모두 끌어올려진다. 여기서 함수가 먼저 끌어올려지고 그 다음으로 변수가 끌어올려진다. 중복된 함수 선언문은 먼저 선언된 함수를 덮어 쓴다. 이렇듯 같은 스코프 내에서의 중복 정의는 혼란스러운 결과를 초래한다.

스코프 클로저

  • 클로저: 선언이 끝난 스코프여도 해당 스코프에 대한 참조를 가지는 것. 클로저를 통해 스코프에 선언된 변수나 함수에 접근할 수 있다.
1function foo() {
2 var a = 2;
3 function bar() {
4 return a;
5 }
6
7 return bar;
8}
9
10var baz = foo();
11
12console.log(baz()); // baz -> bar -> foo의 a값 에 접근할 수 있다.
  • 원래 코드의 렉시컬 스코프를 완전히 벗어난 곳에서 호출되면 모두 클로저가 작용한 것이다.
  • let 키워드는 하나의 블록 스코프를 만들기 때문에 다음과 같은 것도 가능하다.
1for(var i=0; i<10; i++) {
2 setTimeout(function () {
3 console.log(i); // 10만 10번 찍힌다.
4 }, 0);
5}
6
7for (let i=0; i<10; i++) {
8 setTimeout(function() {
9 console.log(i); // let이 각자의 블록스코프를 10개 만드므로 순서대로 값이 찍힌다.
10 }, 1000);
11}
  • 클로저를 이용해서 자바스크립트에서 자주 사용되는 패턴인 모듈 패턴싱글톤 패턴을 구현할 수 있다.
  • 현재의 많은 모듈 의존성 로더와 관리자는 본질적으로 이 패턴의 모듈 정의를 친숙한 API 형태로 감싸고 있다.
1var MyModules = (function Manager() {
2 var modules = {};
3
4 function define(name, deps, impl) {
5 for (var i=0; i<deps.length; i++) {
6 deps[i] = modules[deps[i]];
7 }
8 modules[name] = impl.apply(impl, deps); // 의존성을 인자로 넘겨 모듈에 대한 정의 래퍼 함수를 호출하여 반환 값인 모듈 API를 이름으로 정리된 내부 모듈리스트에 저장한다.
9 }
10 function get(name) {
11 return modules[name];
12 }
13
14 return {
15 define: define,
16 get: get
17 }
18})();
19
20MyModules.define('bar', [], function () {
21 function hello(who) {
22 return 'Let me introduce: ' + who;
23 }
24
25 return {
26 hello: hello
27 };
28});
29
30MyModules.define('foo', ['bar'], function (bar) {
31 var hungry = 'hippo';
32
33 function awesome() {
34 console.log(bar.hello(hungry).toUpperCase());
35 }
36
37 return {
38 awesome: awesome
39 };
40});
41
42var bar = MyModules.get('bar');
43var foo = MyModules.get('foo');
44
45console.log(bar.hello('hippo'));
46foo.awesome();
  • ES6 에서는 모듈을 지원하는 문법(import, export)이 추가되었다. 기존의 함수 기반 모듈은 동적으로 동작하여 런타임에서 수정할 수 있지만 최신 문법은 정적으로 컴파일러 단계에서 동작한다.

부록

  • 자바스크립트 변수 관련 규칙은 호스트 객체 (내장객체/함수 포함)에서는 예외상황이 발생할 수 있다.
    • document.createElement()를 통해 생성된 객체는 DOM요소를 가리키는 특별한 호스트 객체다.
    • toString() 같은 일반 객체 내장 메서드에 접근할 수 없다.
    • 덮어쓸 수 없다.
    • 미리 정의된, 읽기 전용 프로퍼티를 가진다.
    • 다른 객체로 this를 재정의할 수 없는 메서드가 있다.
    • 대표적으로 console 객체는 브라우저에서는 개발자툴과 연결되어있지만 node같은 서버사이드 환경에서는 표준입출력과 관련있다.
  • id 속성으로 DOM 요소를 생성하면 해당 DOM 객체는 전역 변수로 생성된다.
  • 절대 네이티브 프로토타입을 확장하지 말자
  • 자바스크립트 엔진마다 한계가 있는 것들이 있다.
    • 문자열 값이 아닌 문자열 리터럴의 최대 문자 개수
    • 함수 호출 시 인자로 보낼 수 있는 데이터 사이즈 (바이트) (스택 크기)
    • 함수 선언 시 인자 개수
    • 최적화되지 않은 (재귀) 호출 스택의 최대 깊이: 한 함수가 다른 함수를 호출하는, 함수 호출 사슬의 최대 허용 길이
    • 자바스크립트 프로그램이 연속적으로 브라우저를 블로킹한 채 실행 가능한 시간 (초)
    • 변수명 최대 길이
  • 렉시컬 스코프와 동적 스코프의 차이점
    • 렉시컬 스코프는 작성할 때, 동적 스코프(this도 포함)는 런타임에 결정된다.
    • 렉시컬 스코프는 어디서 함수가 선언됐는지와 관련있지만, 동적 스코프는 어디서 함수가 호출됐는지와 관련있다.
    • 자바스크립트는 동적 스코프를 사용하지 않고 렉시컬 스코프만 사용한다.
  • 렉시컬 this
    • ES6 arrow function을 이용하면 모든 this 바인딩 규칙을 폐기하고 자신 가까이 둘러 싼 렉시컬 스코프에서 this를 가져온다.
© 2020 by Kyunghwa Yoo.