Study JavaScript 0608~0610 - 변수의 유효 범위와 클로저

변승훈·2022년 6월 10일
0

Study JavaScript

목록 보기
28/43

변수의 유효범위와 클로저

변수

  • var: var로 선언한 변수의 스코프는 함수 스코프이거나 전역 스코프다. var의 선언은 함수가 시작되는 시점에서 처리가 된다. 예전에 자주 쓰이지만 현재는 let과 const를 사용한다.
  • let: let으로 선언한 변수는
  • const:

1. 코드 블록

코드 블록 {...}안에서 선언한 변수는 블록 안에서만 사용할 수 있다.
if, for, while등 에서도 동일한 특징이 적용된다.

2. 중첩 함수

함수 내부에서 선언한 함수를 중첩(nested)함수라고 한다.

3. 렉시컬 환경

3-1. 변수

자바스크립트에선 실행 중인 함수, 코드 블록 {...}, 스크립트 전체는 렉시컬 환경(Lexical Environment) 이라 불리는 내부 숨김 연관 객체(internal hidden associated object)를 갖는다.

렉시컬 환경 객체는 두 부분으로 구성된다.

  1. 환경 레코드(Environment Record) – 모든 지역 변수를 프로퍼티로 저장하고 있는 객체다. this 값과 같은 기타 정보도 여기에 저장된다.
  2. 외부 렉시컬 환경(Outer Lexical Environment) 에 대한 참조 – 외부 코드와 연관되어있다.

변수는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐이다. 변수를 가져오거나 변경 하는 것은 환경 레코드의 프로퍼티를 가져오거나 변경함을 의미한다.

아래의 두 줄 짜리 코드엔 렉시컬 환경이 하나만 존재한다.

이렇게 스크립트 전체와 관련된 렉시컬 환경은 전역 렉시컬 환경이라 한다.

위 그림에서 네모 상자는 변수가 저장되는 환경 레코드를 나타내고 붉은 화살표는 외부 렉시컬 환경에 대한 참조를 나타낸다. 전역 렉시컬 환경은 외부 참조를 갖지 않기 때문에 화살표가 null을 가리키는 걸 확인할 수 있다.

코드가 실행되고 실행 흐름이 이어져 나가면서 렉시컬 환경은 변화한다.

우측 네모 상자들은 코드가 한 줄, 한 줄 실행될 때 마다 전역 렉시컬 환경이 어떻게 변화하는지 보여준다.

  1. 스크립트가 시작되면 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라간다(pre-populated).
  2. 이때 변수의 상태는 특수 내부 상태(special internal state)인 'uninitialized’가 된다. javascript 엔진은 uninitialized 상태의 변수를 인지하긴 하지만, let을 만나기 전까진 이 변수를 참조할 수 없다.
  3. let phrase는 아직 값을 할당하기 전이기 때문에 프로퍼티 값은 undefined이다. phrase는 이 시점 이후부터 사용할 수 있습니다.
  4. phrase에 값이 할당되었다.
  5. phrase의 값이 변경되었다.

요약하자면 다음과 같다.

  • 변수는 특수 내부 객체인 환경 레코드의 프로퍼티다. 환경 레코드는 현재 실행 중인 함수와 코드 블록, 스크립트와 연관되어 있다.
  • 변수를 변경하면 환경 레코드의 프로퍼티가 변경된다.
  • 렉시컬 환경은 javascript가 어떻게 동작하는지 설명하는데 쓰이는 이론상의 객체이므로 직접 환경을 얻거나 조작은 불가능하다.

3-2. 함수 선언문

함수는 변수와 마찬가지로 값이다.
차이점은 함수 선언문으로 선언한 함수는 일반 변수와 달리 바로 초기화 된다는 점이다.

함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있지만 변수는 선언이 될 때까지 사용할 수 없다.

이는 선언되기 전에 함수를 사용할 수 있는 이유이다.

아래의 그림은 스크립트에 함수를 추가했을 때 전역 렉시컬 환경 초기 상태가 어떻게 변하는지 보여주며 이러한 동작 방식은 함수 선언문으로 정의한 함수에만 적용된다.

3-3. 내부와 외부 렉시컬 환경

함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어지며, 이 렉시컬 환경엔 함수 호출 시 넘겨받은 매개변수와 함수의 지역 변수가 저장된다.

예를 들어 say("John")을 호출하면 아래와 같은 내부 변화가 일어난다.
현재 실행 흐름은 붉은색 화살표로 나타낸 줄에 멈춰있는 상황이다.

함수가 호출 중인 동안엔 호출 중인 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경이 가리키는 외부 렉시컬 환경을 갖게 된다.

  • 예시의 내부 렉시컬 환경은 현재 실행 중인 함수인 say에 상응한다. 내부 렉시컬 환경엔 함수의 인자인 name으로부터 유래한 프로퍼티 하나만 있네요. say("John")을 호출했기 때문에, name의 값은 "John"이 된다.
  • 예시의 외부 렉시컬 환경은 전역 렉시컬 환경이다. 전역 렉시컬 환경은 phrase와 함수 say를 프로퍼티로 갖는다.

그러나 내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖는다.

코드에서 변수에 접근할 땐, 먼저 내부 렉시컬 환경을 검색 범위로 잡는다. 내부 렉시컬 환경에서 원하는 변수를 찾지 못하면 검색 범위를 내부 렉시컬 환경이 참조하는 외부 렉시컬 환경으로 확장한다. 이 과정은 검색 범위가 전역 렉시컬 환경으로 확장될 때까지 반복된다.

전역 렉시컬 환경에 도달할 때 까지 변수를 찾지 못하면 엄격 모드에서 에러가 발생한다.

예시와 그림을 보면서 변수 검색의 진행을 정리해보자.

  • 함수 say 내부의 alert에서 변수 name에 접근할 땐, 먼저 내부 렉시컬 환경을 살펴본다. 내부 렉시컬 환경에서 변수 name을 찾았다.
  • alert에서 변수 phrase에 접근하려는데, phrase에 상응하는 프로퍼티가 내부 렉시컬 환경엔 없다. 따라서 검색 범위는 외부 렉시컬 환경으로 확장된다. 외부 렉시컬 환경에서 phrase를 찾았다.

3-4. 함수를 반환하는 함수

다음 함수를 보자

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

makeCounter()를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어지고 여기에 makeCounter를 실행하는데 필요한 변수들이 저장된다.

위쪽에서 살펴본 say("John") 예시와 마찬가지로 makeCounter()를 호출할 때도 두 개의 렉시컬 환경이 만들어진다.

그런데 위쪽에서 살펴본 say("John") 예시와 makeCounter() 예시에는 차이점이 하나 있다. makeCounter()가 실행되는 도중엔 본문(return count++)이 한줄 짜리인 중첩 함수가 만들어진다는 점이다. 현재는 중첩함수가 생성되기만 하고 실행은 되지 않은 상태다.

여기서 중요한 사실은 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다는 점이다. 함수는 [[Environment]]라 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다.

따라서 counter.[[Environment]]엔 {count: 0}이 있는 렉시컬 환경에 대한 참조가 저장된다. 호출 장소와 상관없이 함수가 자신이 태어난 곳을 기억할 수 있는 건 바로 이 [[Environment]] 프로퍼티 덕분이다. [[Environment]]는 함수가 생성될 때 딱 한 번 값이 세팅되고 영원히 변하지 않는다.

counter()를 호출하면 각 호출마다 새로운 렉시컬 환경이 생성된다. 그리고 이 렉시컬 환경은 counter.[[Environment]]에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조한다.

실행 흐름이 중첩 함수의 본문으로 넘어오면 count 변수가 필요한데, 먼저 자체 렉시컬 환경에서 변수를 찾는다. 익명 중첩 함수엔 지역변수가 없기 때문에 이 렉시컬 환경은 비어있는 상황입니다(<empty>). 이제 counter()의 렉시컬 환경이 참조하는 외부 렉시컬 환경에서 count를 찾는다.

이제 count++가 실행되면서 count 값이 1 증가해야하는데, 변수 값 갱신은 변수가 저장된 렉시컬 환경에서 이뤄진다.

따라서 실행이 종료된 후의 상태는 다음과 같다.

counter()를 여러 번 호출하면 count 변수가 2, 3으로 증가하는 이유가 바로 여기에 있다.

4. 클로저

클로저는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다. 몇몇 언어에서는 클로저를 구현하는 것이 불가능 하거나 특수한 방식으로 함수를 작성해야 클로저를 만들 수 있다. javascript에서는 new Function을 이용한 함수를 제외하고는 모든 함수가 자연스럽게 클로저가 된다.

5. 가비지 컬렉션

함수 호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거된다. 함수와 관련된 변수들은 이때 모두 사라지며, 함수 호출이 끝나면 관련 변수를 참조할 수 없는 이유가 바로 여기에 있다. javascript에서 모든 객체는 도달 가능한 상태일 때만 메모리에 유지된다.

그런데 호출이 끝난 후에도 여전히 도달 가능한 중첩 함수가 있을 수 있다. 이때는 이 중첩함수의 [[Environment]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다.

함수 호출은 끝났지만 렉시컬 환경이 메모리에 유지되는 이유는 바로 이 때문이다.

function f() {
  let value = 123;

  return function() {
    console.log(value);
  }
}

let g = f(); // g.[[Environment]]에 f() 호출 시 만들어지는
// 렉시컬 환경 정보가 저장된다.

그런데 이렇게 중첩함수를 사용할 때는 주의할 점이 있다. f()를 여러 번 호출하고 그 결과를 어딘가에 저장하는 경우, 호출 시 만들어지는 각 렉시컬 환경 모두가 메모리에 유지된다는 점이다. 아래 예시를 실행하면 3개의 렉시컬 환경이 만들어지는데, 각 렉시컬 환경은 메모리에서 삭제되지 않는다.

function f() {
  let value = Math.random();

  return function() { console.log(value); };
}

// 배열 안의 세 함수는 각각 f()를 호출할 때 생성된
// 렉시컬 환경과 연관 관계를 맺는다.
let arr = [f(), f(), f()];

렉시컬 환경 객체는 다른 객체와 마찬가지로 도달할 수 없을 때 메모리에서 삭제된다. 해당 렉시컬 환경 객체를 참조하는 중첩 함수가 하나라도 있으면 사라지지 않는다.

아래 예시 같이 중첩 함수가 메모리에서 삭제되고 난 후에야, 이를 감싸는 렉시컬 환경(그리고 그 안의 변수인 value)도 메모리에서 제거된다.

function f() {
  let value = 123;

  return function() {
    console.log(value);
  }
}

let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있다.

g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제된다.

6. 최적화 프로세스

앞에서 보았듯이, 함수가 살아있는 동안엔 이론상으론 모든 외부 변수 역시 메모리에 유지된다.

그러나 실제로는 javascript 엔진이 이를 지속해서 최적화한다. javascript 엔진은 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거한다.

디버깅 시, 최적화 과정에서 제거된 변수를 사용할 수 없다는 점은 V8 엔진(Chrome, Opera에서 쓰임)의 주요 부작용이다.

Chrome 브라우저에서 개발자 도구를 열고 아래의 코드를 실행해보자.

그리고 실행이 일시 중지되었을 때 콘솔에 console.log(value)를 입력해 보자.

function f() {
  let value = Math.random();

  function g() {
    debugger; // Uncaught ReferenceError: value is not defined가 출력된다.
  }

  return g;
}

let g = f();
g();

정의되지 않은 변수라는 에러가 출력된다. 이론상으로는 value에 접근할 수 있어야 하지만 최적화 대상이 되어서 이런 에러가 발생했다.

이런 외부 변수 최적화는 디버깅 이슈를 발생시키곤 한다. 발생할 수 있는 상황 중 하나를 예시를 실행해 의도한 변수 대신 같은 이름을 가진 다른 외부 변수가 출력되는 걸 확인해보자.

let value = "이름이 같은 다른 변수";

function f() {
  let value = "가장 가까운 변수";

  function g() {
    debugger; // 콘솔에 alert(value);를 입력하면 '이름이 같은 다른 변수'가 출력된다.
  }

  return g;
}

let g = f();
g();

이런 V8만의 부작용을 미리 알아 놓는 것이 좋다. Chrome이나 Opera에서 디버깅하는 경우라면 이 이슈를 마주칠 일이 있을 것이다.

이 부작용은 버그라기보다는 V8만의 특별한 기능이라고 생각하면 되고, 미래에 이 기능은 변경될 수 있다. 최적화 과정에서 외부 변수가 어떻게 처리되었는지 확인하고 싶다면 이 예시를 실행해 보자.

profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글