렉시컬 스코프(ES3)

WooBuntu·2020년 8월 14일
4

  • 이번 장에서는 ES3에서의 스코프 개념인 렉시컬 스코프에 대해 알아본다

1. 스코프란?

  • 특정 장소에 변수를 저장하고 나중에 그 변수를 찾는데 필요한 규칙

  • 식별자 이름으로 변수를 찾기 위한 규칙의 집합

스코프의 종류

스코프는 다음과 같은 기준으로 분류할 수 있다.

  1. 스코프가 결정되는 시점을 기준으로 분류

    • 렉시컬 스코프

      • 코드를 작성할 때 결정된다.

      • 함수가 선언된 위치와 관련있다.
        (중첩 스코프)

    • 동적 스코프

      • 런타임에 결정된다.

      • 함수가 호출된 위치와 관련있다.
        (콜스택)

  2. 스코프의 단위에 관한 분류

    • 함수 스코프 : 함수가 스코프의 단위

    • 블록 스코프 : 블록이 스코프의 단위

  • 이번 포스팅에서는 렉시컬 스코프와 함수, 블록 스코프에 대해 다룬다.

2. 렉시컬 스코프

  • 동적 스코프와 대비되는 개념인 만큼 정적 스코프라고 표현해도 될 것 같은데 굳이 용어를 렉시컬 스코프라고 지은 이유부터 알아보자.

  • 이를 알아보기 위해 다시 스코프의 정의로 돌아가보면, 스코프는 결국 변수를 저장하고 탐색하는 것에 관련된 개념이다.

  • 우리는 자바스크립트 파일에 코드를 작성함으로써 변수를 선언하고
    데이터를 할당한다.

  • 하지말 실제 변수(및 함수)의 선언과 데이터의 할당 작업은 서로 다른 두 개의 주체에 의해 구분되어 수행된다.

    • 변수(및 함수)의 선언은 컴파일러의 몫이고,

    • 데이터의 할당은 자바스크립트 엔진의 몫이다.

  • 그럼 다음의 예제를 통해 스코프가 어떻게 형성되는지 알아보자.

var a = 2;

function b() {}

컴파일 단계

  • 달랑 위의 예제 두 줄로 된 자바스크립트 파일이 있다고 가정해보자.

  • 먼저 컴파일러는 자바스크립트 파일을 읽어내려가면서 문자열 "var a = 2;"는 "var", "a", "=", "2", ";"로, 문자열 "function b() {}"는 "function", "b", "()", "{}"로 의미를 갖는 최소 단위(토큰)로 구분한다.
    (이 과정을 Lexing(렉싱)이라 한다)

  • 컴파일러는 Lexing의 결과물인 토큰들을 프로그램 문법 구조에 맞춰 트리 구조로 변환한다.
    (이 과정을 Parsing이라 한다)

  • 예제가 앞의 두 과정을 거치면 'var a', 'a = 2', "function b() {}" 세 부분으로 나뉘게 된다.

  • 컴파일러는 자바스크립트 엔진이 실행할 수 있는 코드를 만들기 위해 이를 다시 읽어들인다.

  • 'var a'는 변수를 선언하는 부분이다.

    • 컴파일러는 '현재 스코프'에 식별자 a에 해당하는 변수가 있는지 검색한다.

    • '현재 스코프'에 식별자가 이미 있다면 별도의 작업을 하지 않고,

    • '현재 스코프'에 식별자가 없다면 컴파일러가 '현재 스코프'에 새로이 변수를 선언한다.

    • 이때 변수의 초기값으로 undefined의 메모리 주소값이 할당된다.

      (즉, 변수의 선언은 컴파일러의 몫이다)

  • 다음으로 컴파일러가 읽는 구문은 'a = 2'이다.

    • 이러한 할당문은 컴파일 시점이 아니라 코드의 실행 시점에서 처리되어야 할 부분이므로 컴파일러는 엔진이 해당 할당문을 실행할 수 있도록 코드를 생성한다.
  • 마지막으로 컴파일러가 읽는 구문은 'function b() {}'이다.

    • 앞서 변수의 선언문과는 달리 함수 선언문은 선언과 대입이 나누어지지 않는다.

    • 따라서 '현재 스코프'에 식별자 b에 해당하는 변수가 있다면 해당 변수에 함수 b의 메모리 주소값을 덮어쓰고,

    • 만약 '현재 스코프'에 식별자 b에 해당하는 변수가 없다면 '현재 스코프'에 b 변수를 새로 만들어 함수 b의 메모리 주소값을 할당한다.

    • 즉, 함수 선언문에서는 초기값으로 undefined의 메모리 주소값을 할당하는 작업이 없다.

    • 다만, 'var 변수명 = function 함수명() {}'과 같은 형태의 함수 표현식은 일반 변수와 동일하게 취급한다.

  • 만약, 함수 선언문과 변수 선언문의 동일한 식별자를 사용할 경우 변수 선언문은 무시되고 함수 선언문만이 남는다
    (이는 호이스팅에서 다시 언급한다)

  • 참고로, 이런 컴파일의 단위는 함수이다.

  • 이러한 컴파일 작업은 함수를 호출할 때마다 실행되는데, 이는 다음 포스팅에서 더 자세하게 다루겠다
    (콜 스택과 실행 컨텍스트에 대해서도 다뤄야 하기 때문)

실행 단계(데이터의 할당)

  • 앞서 컴파일러가 생성한 코드의 내용은 변수 a에 2의 메모리 주소값을 할당하라는 것이다.

  • 자바스크립트 엔진은 2의 메모리 주소값을 할당할 변수 a를 스코프에서 찾는다.
    (여기서 변수의 탐색은 LHS로 이루어지는데, 아래에서 곧 설명한다)

  • '현재 스코프'에 변수 a가 있다면 사용하고,

  • '현재 스코프'에 변수 a가 없다면 상위의 스코프로 넘어가서 검색을 계속한다
    (이는 중첩 스코프라는 개념으로 곧 설명한다)

  • 여기서는 '현재 스코프'에 앞서 컴파일러가 선언해 둔 변수 a가 있으므로, 바로 2의 메모리 주소값을 할당한다.

렉스타임

  • 앞서 살펴봤듯이 변수의 선언은 컴파일러가, 데이터의 할당은 자바스크립트 엔진이 한다.

  • 즉, 자바스크립트 엔진이 코드를 실행할 때 필요한 변수는 컴파일 시점에서, 보다 구체적으로는 Lexing시점에서 이미 결정된다.

  • 이것이 바로 렉시컬 스코프의 어원이다.

변수의 탐색(실행 단계)

  • 변수의 탐색은 두 종류로 나뉜다.
  1. 데이터를 할당받을 변수의 탐색(LHS)

  2. 할당된 데이터를 참조하기 위한 변수의 탐색(RHS)

데이터를 할당받을 변수의 탐색(LHS)

  • LHS탐색이 수행되는 경우는 두 가지이다.
  1. = 연산자를 이용하여 변수에 데이터를 할당할 때
var a = 2;
  1. 함수에 인자를 넘겨주었을 때
function woobuntu(a) {
  // 아래에서 넘겨준 인자 '닉값'의 주소값이 a에 할당된다.
  // 즉, a = '닉값'; 연산이 일어난다.
}

woobuntu("닉값");

할당된 데이터를 참조하기 위한 변수의 탐색(RHS)

  • LHS의 두 가지 경우를 제외한 모든 경우가 이에 해당한다.
function foo(a) {
  // 3. 아래에서 인자로 넘겨준 2의 주소값을 a에 할당해야 하므로
  // a에 대해 LHS탐색 수행
  
  var b = a;
  // 4. b에 대해서 LHS탐색 수행
  // 5. b에 a가 가리키는 값을 할당하려는 것이므로,
  // a가 가리키는 값을 찾기 위해 a에 대해 RHS탐색 수행
  
  console.log(a + b);
  // 6. console객체에 대해 RHS탐색 수행
  // 7. a와 b에 대해 각각 RHS 탐색 수행
}

var c = foo(2);
// 1. c에 대해서는 LHS 탐색 수행
// 2. foo함수 호출을 위해서 foo에 대해 RHS 탐색 수행
  • 위의 예제6번에서 console 객체를 찾는 것은 언급했지만 log메소드를 찾는 것은 언급하지 않았다.

    • 이는 렉시컬 스코프의 변수 탐색 과정은 '일차 식별자' 탐색에만 적용되기 때문이다.

    • .log와 같은 접근은 property에 대한 접근이므로 객체의 내부 property인 [[Get]]을 호출해서 탐색한다.
      (property descriptor 참조)

중첩 스코프

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log(a, b, c);
  }
  bar(b * 3);
}
foo(2);
  • 곧 설명하겠지만, 기본적으로 자바스크립트는 함수 기반 스코프를 사용한다.

  • 따라서 여기서도 일단 함수 기반 스코프를 바탕으로 중첩 스코프에 대해 설명하고, 블록 스코프는 아래에서 다시 설명한다.

  • 위의 예제는 총 3개의 스코프가 중첩되어 있다.

    1. foo함수가 선언되고 호출된 글로벌 스코프

    2. foo함수 스코프

    3. bar함수 스코프

  • 여기서 bar함수 스코프는 상위의 스코프인 foo함수 스코프에 완전히 포함되고, foo함수 스코프는 상위의 스코프인 글로벌 스코프에 완전히 포함된다.
    (중첩과 교차가 다르다는 것을 명심하자)

  • 이렇듯 중첩 스코프는 함수가 선언된 위치에 따라 결정된다.

  • 앞서 언급한 두 종류의 변수 탐색은 '현재 스코프'부터 시작하여 점차 상위의 스코프로 넘어가면서 수행된다.

  • ex) bar함수가 실행될 때는 bar함수 스코프가 '현재 스코프'이다.

    • console객체에 대해 RHS 탐색을 수행한다.
    • bar함수 내부에는 console객체가 없으므로 상위 스코프인 foo함수 스코프로 넘어간다.
    • 역시나 foo함수 스코프에도 console객체가 없으므로 상위 스코프인 글로벌 스코프로 넘어간다.
    • 글로벌 스코프에는 console객체가 정의되어 있으므로 해당 객체를 참조한다.
    • a에 대해 RHS 탐색을 수행한다.
    • bar함수 스코프에 a가 없으므로 상위 스코프인 foo함수 스코프로 넘어간다.
    • foo함수 스코프에 a가 있으므로 이를 참조한다.
      (더 이상 상위의 스코프로 넘어가서 탐색을 수행하지 않는다)
    • b와 c에 대해서도 같은 방식으로 수행한다.

탐색 실패

  • 앞서 변수 탐색이 LHS와 RHS로 나뉜다고 했다.

  • 이렇게 변수 탐색을 구분해야 하는 이유는 탐색을 실패했을 때의 결과가 다르기 때문이다.

function foo() {
  // "use strict";
  lhs = 1;
  console.log(rhs1);
}

foo();
  • 먼저, lhs에 대해 살펴보자.

    • foo함수 스코프 내에 lhs가 선언된 적이 없으니(var, const, let등의 키워드도 없고, 함수의 파라미터에도 설정되어 있지 않으니) 앞서와 같이 중첩 스코프를 타고 올라가면서 LHS 탐색을 수행한다.

    • 그런데 글로벌 스코프까지 와도 lhs는 보이지 않는다.

      • strict모드일 때 : lhs가 선언되지 않았다며 ReferenceError를 반환한다.
      • strict모드가 아닐 때 : 글로벌 스코프에 변수 lhs를 선언한다.
        (즉, 에러가 나지 않고 이렇게 선언된 lhs에 1의 주소값을 할당한다)
  • 다음으로 rhs에 대해 살펴보자.

    • rhs 역시 마찬가지로 foo함수 스코프 내에 선언된 바가 없으므로 중첩 스코프를 타고 올라가면서 RHS 탐색을 수행한다.

    • 글로벌 스코프에서도 rhs를 찾을 수 없는데 이때는 strict모드와 관계없이 rhs가 선언되지 않았다며 ReferenceError를 반환한다.

3. 함수 스코프와 블록 스코프

  • 앞서 스코프의 분류 기준을 '스코프가 결정되는 시점'과 '스코프의 단위' 두 가지로 나누었다.

  • 그런데, '스코프의 단위'는 렉시컬 스코프에만 해당하는 개념이다.

  • 즉, 함수 스코프와 블록 스코프는 렉시컬 스코프 안에서의 분류이다.

함수 기반 스코프

  • 함수 스코프에서 모든 변수는 함수에 속하고, 이러한 변수는 중첩 스코프 상에서 함수 자신을 포함한 자신의 하위 스코프에서 사용할 수 있다.

  • 즉, 거꾸로 말하면 함수 안에 들어 있는 변수는 함수 밖의(상위의) 스코프에서는 접근할 수 없다는 것이다.

최소 권한의 원칙

  • 앞서 함수 안에 들어 있는 변수는 함수 밖에서는 접근할 수 없다는 것을 확인했다.

  • 이는 소프트웨어 디자인 원칙인 '최소 권한의 원칙'과 밀접한 관련이 있다.

  • '최소 권한의 원칙'은 모듈/객체의 API를 설계할 때 최소한의 접근 권한만을 부여해야 한다는 것이다.

function doSomething() {
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}

function doSomethingElse(a) {
  return a - 1;
}

var b;

doSomething(2);
  • 위의 예제에서 변수 b와 doSomethingElse함수는 오직 doSomething 함수 안에서만 필요로 한다.

  • 하지만 글로벌 스코프에 선언되어 있으므로 doSomething함수 밖에서도 접근 가능하다.

  • doSomething함수에서만 필요로 하는 변수와 함수인데, doSomething함수 밖에서도 접근이 가능하니 변수나 함수의 내용이 바뀐다거나 하면, doSomething함수의 기능에 문제가 생길 여지가 있다.

  • 그러니 다음과 같이 doSomething 함수에서만 사용하는 변수와 함수는 doSomething함수 스코프 안으로 숨기는 것이 좋다.

function doSomething() {
  function doSomethingElse(a) {
    return a - 1;
  }

  var b;

  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}

doSomething(2);
  • 이제 doSomething함수 밖에서 doSomethingElse나 b에 접근할 수 없으므로 doSomething함수의 기능은 보장된다.

  • 즉, 각 변수와 함수에 대한 접근 권한을 최소화함으로써 프로그램의 안전성을 보장하려는 것이다.

충돌 회피

  • 또한 변수를 함수 스코프 내에 숨김으로써, 같은 식별자를 가진 변수들끼리 충돌하지 않게끔 할 수 있다.
function foo() {
  function bar(a) {
    i = 3;
    console.log(a + i);
  }
  for (var i = 0; i < 10; i++) {
    bar(i * 2);
  }
}

foo();
  • foo함수를 호출한 결과 for문이 돌면서 bar함수를 호출하게 되는데,

  • 문제는 변수 i가 foo함수 스코프 내에 속하기 때문에 bar함수에서도 접근 가능하고, for문에서도 사용한다는 것이다.

  • for문에서 처음 선언된 변수 i는 초기값으로 0을 가리킨다.

  • 이제 bar함수를 호출하고 인자로는 0*2(즉, 0)이 넘어간다.

  • bar함수 내부에서 i = 3을 수행하는데, bar함수 스코프에는 변수 i가 선언된 적이 없기 때문에 상위 스코프인 foo함수 스코프로 넘어가 변수 i를 찾는다.

  • 그래서 i는 3을 가리키게 된다.

  • 매번 bar가 호출될 때마다 i는 3을 가리키게 되고, for문의 조건인 i<10에 걸리기 때문에 무한 반복을 돌게 된다.

  • 따라서 bar함수 내부에서만 사용할 변수 i를 따로 만들기 위해,
    'i = 3;'을 'var i = 3'과 같이 변경한다.

  • 이제 bar함수에서 사용하는 변수 i는 bar함수 스코프 내에 존재하기 때문에 foo함수 스코프로 넘어가 foo함수 스코프의 변수 i를 건드릴 일이 없다.

  • 모든 식별자 이름을 고유하게 설정하는 것도 방법이지만, 현실적으로 어렵기 때문에 위와 같이 스코프를 구분하여 변수를 숨기는 것이 최선이다.

글로벌 네임스페이스

  • 변수를 스코프 안에 적절하게 숨기지 않은 라이브러리 여러 개를 한 프로그램에서 불러들이면 라이브러리 간에 변수의 충돌이 발생한다.

  • 이런 변수 충돌을 막기 위한 '권장하지 않는' 방법 중 하나로 '네임스페이스'를 활용하는 방법이 있다.

  • 글로벌 스코프에 고유의 이름을 가진 객체를 선언하여, 이 객체의 이름을 라이브러리의 네임스페이스로 이용하는 방법이다.

var nameSpaceOfLibrary = {
  awesome: "stuff",
  doSomething: function () {
    // ...
  },
  doAnotherThing: function () {
    // ...
  },
};
  • 즉 위와 같은 형태가 된다는 것인데, 문제는 렉시컬 스코프의 변수 탐색은 '일차 식별자'까지만 해당된다는 것이다.

  • '일차 식별자'인 nameSpaceOfLibarary는 글로벌 스코프에 있으니 이는 어디서든 접근 가능하며,

  • awesome이나 doSomething은 객체의 property이기 때문에 이러한 property에 대한 접근은 렉시컬 스코프의 변수 탐색이 아닌 객체 내부 property인 [[Get]]의 호출로 이루어진다.

  • 다시 말하자면, 라이브러리의 모든 변수에 접근 가능하다는 것이다.

  • 변수의 충돌 문제는 막았으나, 프로그램의 안정성 문제로 돌아가게 된다.

모듈 관리

  • 변수 충돌을 방지하는 보다 안정적인 방법으로는 '모듈'을 이용하는 법이 있는데, 이는 클로저에서 개략적으로 다룬 뒤 따로 포스팅을 만들어 자세히 정리할 예정이다.

스코프로서의 함수

  • 위의 예제에서는 글로벌 스코프에 foo라는 식별자로 함수가 선언된다.

  • 따라서 글로벌 스코프에서 foo라는 식별자로 foo함수에 접근할 수 있다.

  • 그리고 foo함수 내부에서도 foo함수 자신을 참조할 수 있다.
    (중첩 스코프를 타고 올라가면 글로벌 스코프에 foo함수가 있기 때문)

  • 이번 예제에서는 글로벌 스코프에 bar라는 식별자로 변수가 선언되고, 코드 실행 시점에서 함수 foo의 메모리 주소값이 bar에 할당된다.

  • bar함수를 실행하면 foo함수의 내용을 출력한다.

  • 그런데 전역 스코프에서 foo를 출력하면 RHS 탐색이 실패하여 foo가 선언되지 않았다고 한다.

  • 즉, 두번째 예제에서 foo함수는 글로벌 스코프가 아닌 foo함수 스코프 내에 선언되어 있는 것이다.

  • 다시 말해 함수 선언문은 자신이 선언된 스코프의 변수가 되고, 함수 표현식은 함수 자신의 스코프의 변수가 된다.
    (다음 포스팅에서 개발자 도구를 사용한 예제가 있으니 이를 참고하면 이해가 더 쉬울 것 같다)

  • 함수 표현식의 이러한 성질을 이용하면 함수의 상위 스코프를 오염시키지 않는 방법이 몇 가지 있는데 이에 대해 알아보자

참고로, 함수 선언문과 함수 표현식은 function키워드가 구문의 시작 위치에 있는지로 구분한다.

콜백 함수로 넘겨주는 함수 표현식

setTimeout(function foo() {
  console.log("1초 후에 출력된다");
}, 1000);

// console.log(foo);
// 당연히 foo의 RHS 탐색은 실패하고 ReferenceError를 반환한다.
  • 앞서 언급했듯이 function키워드가 구문의 시작 위치에 있지 않으므로, 이렇게 콜백에 함수를 넘겨주는 것 또한 함수 표현식이다.

  • 이렇게 foo함수가 setTimeout함수의 콜백 인자로만 사용될 경우, 다른 곳에서 foo함수에 접근할 수 있도록 할 이유가 없다.

  • 그러니 foo함수가 다른 곳에서도 사용될 것이 아니라면 굳이 setTimeout함수 밖에서 선언하여 콜백의 인자로 전달할 필요가 없다.

setTimeout(function () {
  console.log("1초 후에 출력된다");
}, 1000);
  • 아마 대부분은 이렇게 이름 없는 함수를 콜백의 인자로 넘기는 형태에 더 익숙할 것이다.

  • 이렇게 이름이 없는 형태의 함수 표현식을 '익명 함수 표현식'이라 한다.
    (함수 선언문은 이름 없이는 선언이 불가능하다)

  • 그러나 익명 함수의 경우 스택 추적 시 이름이 표시되지 않아 디버깅이 어렵다.

  • 또한, 함수의 이름 자체만으로도 코드에 대한 정보가 되므로 되도록 익명 함수 표현식은 사용하지 않는 편이 좋다.

즉시 호출 함수 표현식(IIFE)

  • 위의 예제에서처럼 함수를 ()로 감싸는 것 또한 함수 표현식이다
    (function키워드가 구문의 시작 위치에 있지 않으므로)

  • 그렇게 감싼 함수를 ()로 바로 호출시키는 것을 '즉시 호출 함수 표현식'이라 부른다.

  • 이처럼, 일회성으로 실행되는 함수의 경우 굳이 다른 곳에서 접근하게 권한을 줄 필요가 없기 때문에 함수 표현식으로 만들어 즉시 실행시키면 된다.

  • 위와 같은 방식으로 스코프 별로 변수의 접근을 달리하는 방법도 가능하다

  • 앞선 예제를 변형한 형태이다.

블록 스코프

for (var i = 0; i < 10; i++) {
  console.log(i);
}
  • 위와 같이 코드를 짰다면, 변수 i를 오직 for문 안에서 유효 범위를 갖게 하기 위한 의도일 것이다.

  • 그러나 지금까지 살펴봤듯이 변수 i는 글로벌 스코프에 속해, 프로그램 어디서나 접근 가능하다.

  • 그렇다고 아래와 같이 코드를 짜는 것은 너무 번거롭다.

  • 이런 문제를 해결하기 위해 ES6부터는 블록 스코프를 지원한다.

let

  • 키워드 let은 선언된 변수를 가장 가까운 블록의 스코프에 엮는다.
if (true) {
  let woobuntu = "닉값하고 싶다";
  console.log(woobuntu);
}
  • 이 경우 woobuntu는 자신을 둘러싼 블록인 if문의 블록을 스코프로 가진다.

  • 대부분 이러한 형태의 블록 스코프가 익숙할 텐데 사실 이는 썩 명시적인 방법은 아니다.

if (true) {
  {
    let woobuntu = "닉값하고 싶다";
    console.log(woobuntu);
  }
}
  • 위와 같이 해당 변수가 블록 스코프에 속함을 명시적으로 표현해주는 것이 좋다고 한다.

  • 이렇게 하면, 리팩토링을 할 때 if문의 위치나 의미를 변화시키지 않고도 전체 블록을 옮기기가 쉬워진다고 한다.

let 반복문

  • let키워드가 i를 for문의 블록 스코프에 엮기 때문에 for문 밖에서는 접근할 수 없다.
for (var i = 0; i < 10; i++) {
  // 여기서 변수 i는 컴파일 시점에 단 한번 선언된다.
  console.log(i);
}

for (let i = 0; i < 10; i++) {
  // 하지만 let을 이용한 변수의 선언은 자바스크립트 엔진이 실행 단계에서
  // 처리하기 때문에 반복문이 돌 때마다 매번 변수를 다시 선언한다.
  // 즉 아래의 변형된 for문이 실행된다고 생각하면 된다.
  console.log(i);
}

for (var i = 0; i < 10; i++) {
  let k = i;
  // 이렇게 매번 변수에 증가된 i값이 할당되는 것이다.
  console.log(k);
}
  • 이렇듯 let과 var는 각각 블록 스코프와 함수 스코프를 이용하는 방법으로, let이 var보다 우월하거나 완전 대체가능한 개념이 아니다.

const

  • const 역시 블록 스코프에 변수를 선언하는 한 방법이지만, 문자 그대로 상수를 의미한다.

  • 따라서 선언만 먼저 해두고 할당을 나중에 한다던가,

  • 나중에 값을 바꾼다던가 하는 행위는 불가능하다.

  • 다만, 값이 객체라면 property의 값을 바꾸는 것은 가능하다.
    (당연히 하위 객체 타입 모두에 해당되는 내용이다)

식별자aa.wooa.buntu
주소@100@101@102@103@104@105
데이터@101{woo:@102, buntu:@103}@104@105"우""분투"
  • const키워드가 상수화시키는 것은 변수 a, 즉 @100이다.
    (=@100에 할당된 메모리 주소값 @101이 더 이상 다른 값으로 바뀔 수 없다)
식별자aa.wooa.buntu
주소@100@101@102@103@104@105@106
데이터@101{woo:@102, buntu:@103}@106@105"우""분투""리눅스"
  • 따라서 a의 property인 woo와 buntu의 데이터값은 얼마든지 다른 메모리 주소를 참조할 수 있다.

  • 이러한 property값도 불변값으로 만들고 싶다면, property descriptor를 이용한 불변 객체를 만드는 것이 타당하다.

try/catch

  • 앞서 ES6부터 블록 스코프를 지원한다고 했지만, 설명의 편의를 위함이었고 사실은 이전에도 블록 스코프가 있기는 했다.

  • 위 예제에서 보다시피 catch문의 err는 catch문 블록 밖에서는 접근이 불가능하다.

  • 이를 활용하여 ES6이전 환경에서도 블록 스코프를 활용할 수 있다.

4. 호이스팅

  • 앞서 var를 이용한 변수의 선언은 컴파일러가 컴파일할 때,

  • 데이터의 할당은 자바스크립트의 엔진이 실행할 때 처리한다는 점을 살펴봤다.

  • 즉, 자바스크립트 엔진이 코드를 실행하는 시점에는 스코프에 이미 컴파일러가 생성해 둔 변수들이 존재한다.

foo();
// 컴파일 시점에서 밑의 함수 선언문을 컴파일러가 읽고, 글로벌 스코프에 함수 foo를 선언해두었기 때문에 여기서 foo의 RHS 탐색은 성공한다.

function foo() {
  console.log(a);
  // 컴파일 시점에서 밑의 var a를 컴파일러가 읽어 foo함수 스코프에 변수 a를 선언해두었기 때문에 역시 여기서 a의 RHS 탐색은 성공한다.
  // 다만, 아직 a = 2;코드가 실행되지 않았기 때문에 foo함수 스코프에 있는 변수 a는 초기값으로 undefined를 가리키고 있는 상태이다.
  
  var a = 2;
}
  • 위와 같은 코드를 아래와 같이 순차적으로 이해하기 위한 비유적인 표현이 바로 '끌어 올린다'는 의미의 '호이스팅'이다.
function foo() {
  var a;
  console.log(a);
  a = 2;
}

foo();
  • 앞서 함수 표현식은 자신의 스코프의 변수가 된다고 했다.
    밑의 예제를 보면 더 이해가 쉬울지도 모르겠다.
foo();
// 컴파일 시점에서 컴파일러가 밑의 'var foo'를 읽고 글로벌 스코프에 변수 foo를 선언해두었다.
// 그러나 아직 foo = function bar(){}가 실행되기 전이므로 foo는 초기값 undefined를 가리킨다.
// undefined는 함수가 아닌데 함수처럼 호출했으니 TypeError를 반환한다.

bar();
// 기명 함수 표현식에서 함수, 즉 함수의 식별자는 함수 자신의 스코프에서 변수가 된다.
// 즉, 해당 함수 밖에서 접근할 수 없다.(ReferenceError 반환)

var foo = function bar() {
  console.log(bar);
  // 비록 앞서 호출한 foo가 잘못된 위치에서 호출되어 이 구문이 실행되지는 않겠지만,
  // 정상적인 호출이었다면, 기명 함수 표현식에서 함수의 이름 bar는 함수 자신의 스코프의 변수로 존재하기 때문에
  // 여기서 bar에 대한 RHS탐색은 성공한다.
};
  • 또 함수 선언문과 변수 선언문이 동일한 식별자를 사용할 경우 변수 선언문은 무시되고, 함수 선언문만이 남는다.
foo();
// 밑에서 변수 선언문과 함수 선언문이 동일한 식별자 foo를 사용하므로,
// 변수 선언문은 무시되고, 함수 선언문만 남아 함수가 호출된다.

var foo;

function foo() {
  console.log(1);
}

foo = function () {
  console.log(2);
};
// 변수 foo의 값을 새로운 함수의 메모리 주소값으로 대체한다.
  • 마지막으로 const와 let에는 호이스팅이 적용되지 않는다.
console.log(a);
// ReferenceError: Cannot access 'a' before initialization
let a = 3;
// 브라우저와 Node.js에서의 error가 다르네;;
  • 아예 선언하지 않은 변수에 접근할 경우 "Uncaught ReferenceError: a is not defined"가 뜨는 것을 고려해보면, 컴파일 시점에서 변수의 선언 자체는 감지하는 것 같은데 정확히 무슨 원리로 안 되는 것인지는 모르겠다.
    (심지어 YDKJS에서도 그냥 호이스팅이 안된다고만 설명되어 있을 뿐 왜 안 되는지에 대한 설명은 없다)

  • 값을 초기화하기 전에 접근하면 안 된다니 렉시컬 스코프의 규칙을 벗어나는 건지 뭔지 모르겠다
    (x같은 자스)

5. 클로저

  • 렉시컬 스코프에 대해 제대로 이해했다면, 사실 클로저는 따로 이해해야 할 부분이 아니다.

  • 클로저라는 명칭을 따로 붙일 것도 없이 클로저가 곧 렉시컬 스코프고, 렉시컬 스코프가 곧 클로저이기 때문이다.

function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  bar(); 
}

foo(); // 2
  • 이것이 지금까지 다뤄온 예제의 형태로, 함수(bar)가 자신이 선언된 스코프에서 호출되는 경우이다.

  • 지금까지 살펴본 것처럼 bar함수에서 a에 대한 RHS탐색을 시작하면, 중첩 스코프를 타고 올라가 a를 찾는 것이 자연스럽게 이해가 된다.

  • 그렇다면 다음의 예제는 어떨까?

function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  return bar;
}

var baz = foo();

baz(); // 2
  • 함수(bar)가 자신이 선언된 스코프 밖에서 호출된 경우이다.

  • 만약, (bar함수를 참조하는) baz함수가 글로벌 스코프에서 호출되었기 때문에 foo함수 스코프에 있는 변수 a에 접근하지 못하는 것이 아닌가 생각된다면 렉시컬 스코프 개념에 대해 완전히 이해하지 못한 것이다.

  • 앞서도 언급한 것처럼, 렉시컬 스코프는 렉스 타임에 결정되는 스코프이다.

  • 작성된 자바스크립트 파일을 컴파일하면서 함수가 어디에 선언되었는지를 바탕으로 중첩 스코프의 구조가 결정된다.

  • 그러니 함수가 어디에서 호출되었는지는 렉시컬 스코프와 하등 관계가 없다.

  • bar함수를 어디에서 호출하던 bar함수의 중첩 스코프는 'bar함수 스코프' -> 'foo함수 스코프' -> '글로벌 스코프'의 구조를 갖는 것이다.

function foo() {
  var a = 2;
  function bar() {
    function baz() {
      console.log(a);
    }
    return baz;
  }
  return bar();
}

var closure = foo();
closure(); // 2
  • foo함수의 호출 결과로 closure변수는 baz함수를 가리키게 된다.

  • baz함수를 호출하면, baz함수가 선언될 당시의 중첩 스코프(baz함수 스코프 -> bar함수 스코프 -> foo함수 스코프 -> 글로벌 스코프)를 따라 변수 a를 탐색한다.

  • foo함수 스코프에 변수 a가 있기 때문에 RHS 탐색은 성공한다.

  • 아마 이 개념이 헷갈리는 사람들은 garbage collector에 대한 개념이 그 원인일 것이다.

  • foo함수가 호출이 '완료'된 이후 foo함수 스코프에 존재하는 변수들을 garbage collector가 수거해서 메모리를 절감한다고 생각하기 때문이다.

  • 물론, 이는 맞다.
    다만, 함수가 자신이 선언된 스코프 밖에서 호출되지 않을 때에 한해서.

  • 위의 예제처럼 함수가 함수를 반환하는 경우나, 아래에서 살펴보는 콜백의 경우처럼 함수가 자신이 선언된 스코프 밖에서 호출되는 경우에는 해당 함수의 중첩 스코프 상에 존재하는 변수들을 메모리에서 수거하지 않는다.

콜백과 클로저

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

wait("Hello, closure");
  • timer함수의 중첩 스코프 구조는 'timer함수 스코프 -> wait함수 스코프 -> 글로벌 스코프'이다.

  • 따라서 wait함수의 호출 결과로 1초 후에 timer함수가 호출되더라도 여전히 wait함수 스코프에 존재하는 message에 접근할 수 있다.

반복문과 클로저

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
  • 1초마다 1, 2, 3, 4, 5와 같이 증가한 숫자를 출력할 목적으로 코드를 작성했지만, 예상과는 달리 6만 1초 간격으로 5번 출력될 뿐이다.

  • 이는 사실 비동기에 대한 이해가 필요하지만, 앞뒤 자르고 얘기하자면 콜백 함수는 반복문이 끝나고 실행된다.

  • 반복문이 끝난 시점에서 글로벌 스코프에 존재하는 i는 6을 가리키고, timer함수는 자신의 중첩 스코프에서 이 i를 찾아 출력한 것이다.

  • 이 문제에 대한 해답은 반복문마다 호출되는 함수가 다 다른 함수라는 것이다.

  • timer함수를 밖에서 선언한 뒤 콜백으로 넘겨주는 것이 아니라, 반복문을 돌때마다 timer함수를 선언했기 때문이다.

  • 이 timer함수들이 모두 같은 중첩 스코프 구조를 가지고 있기 때문에 한 함수가 5번 호출된 것이라 착각하기 쉽지만, 실제로는 5개의 다른 함수가 각각 1번씩 호출된 것이다.

  • 각각이 다른 함수라는 것이 중요한 이유는, 각각이 반복문 안에서 다른 스코프를 가질 수 있기 때문이다.

for (var i = 1; i <= 5; i++) {
  (function (k) {
    setTimeout(function timer() {
      console.log(k);
    }, k * 1000);
  })(i);
}
  • setTimeout함수를 IIFE로 감싸면서, 원래의 중첩 스코프 구조(timer함수 스코프 -> 글로벌 스코프)에서 한 단계가 더 추가되었다(timer함수 스코프 -> IIFE 스코프 -> 글로벌 스코프)

  • 또한, 반복을 돌 때마다 i의 값을 IIFE안에서 k로 고정시킴으로써 모든 timer함수들의 IIFE 스코프는 저마다 다른 k값을 가지게 되었다.

블록 스코프의 활용

  • 결국 위 문제에서는 반복마다 다른 스코프가 필요했던 것인데, 블록 스코프를 사용하면 IIFE를 사용한 해법보다 훨씬 간단하다.
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
  • 앞서 let을 설명할 때도 다루었지만, let키워드가 반복문과 결합되면 반복을 돌때마다 변수를 새로 선언한다.

  • 또한, let 키워드는 블록을 스코프로 간주하기 때문에 for문의 블록이 앞선 해답의 IIFE의 역할을 하게 된다.
    (=스코프를 생성한다)

for (var i = 1; i <= 5; i++) {
  (function () {
    var k = i;
    setTimeout(function timer() {
      console.log(k);
    }, k * 1000);
  })();
}
  • ES5버전으로 바꾸면 이런 형태인데, 앞선 IIFE 방식과 변수 k를 설정하는 방식만 다를 뿐 결국 같은 코드이다.

블록 스코프를 활용한 메모리 절약

  • 그럼 함수가 자신이 선언된 스코프 밖에서 호출될 때는 해당 중첩 스코프 상에 존재하는 모든 변수들은 속절없이 메모리를 잡아먹고 있어야 하는걸까?

  • 꼭 그런 것도 아니다

function foo() {
  var a = 2;

  function someFunction(data) {
    // do something
  }

  {
    const someReallyBigData = {};
    someFunction(someReallyBigData);
  }

  function bar() {
    function baz() {
      console.log(a);
    }
    return baz;
  }
  return bar();
}

var closure = foo();
closure(); // 2
  • 위와 같이 다른 곳에서 호출될 함수가 참조할 필요가 없는 변수는 블록 스코프로 감싸면 의도한 대로 메모리 수거 대상이 된다.

모듈

  • 앞서 '충돌 회피'에 대한 대응책으로 '글로벌 네임스페이스'를 다룬 적이 있다.

  • 이 방법은 변수의 충돌 자체는 막을 수 있지만, 라이브러리의 모든 변수에 접근 권한이 주어지기 때문에 안전성의 문제가 있었다.

  • 이제 클로저를 이용한 대안인 모듈에 대해 알아보자.

function someModule() {
  var someData = "뭐시기";
  function processData() {
    console.log(someData);
  }
  return {
    processData: processData,
  };
}

var woobuntu = someModule();
woobuntu.processData();
  • 위와 같은 자바스크립트 코드 패턴을 모듈이라고 한다.

  • 앞선 '글로벌 스페이스'처럼 모듈 객체(woobuntu)가 하나의 네임 스페이스 역할을 하기 때문에 다른 모듈에 존재하는 변수들과 someModule내에 존재하는 변수들이 충돌을 일으킬 일이 없다.

  • 나아가 someModule함수가 내부적으로 비공개 변수는 숨기고, 이러한 비공개 변수를 렉시컬 스코프로 가지는 함수만을 반환하기 때문에 '글로벌 스페이스'에서 존재했던 안정성 문제 또한 해결되었다.

  • 모듈에 대해서는 Node.js디자인 패턴과 관련하여 다룰 것이 한가득이라 이후 별도의 포스팅에서 자세히 다룰 예정이다.

1개의 댓글

comment-user-thumbnail
2022년 3월 13일

많은 도움이 되었습니다.
감사합니다. ^^

답글 달기