[JS] 변수의 유효범위와 클로저

참고자료

https://ko.javascript.info/closure
위 자료를 공부하고 정리한 내용이다.


변수의 유효범위

아래의 코드는 중첩함수 내에서 외부함수의 변수값을 증가시키는 작업을 하는 함수를 return 하는 외부함수 makeCounter에 대한 코드이다.

function makeCounter() {
  let count = 0;

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

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

makeCounter 함수의 return 값을 counter 변수에 할당하여 counter 함수를 여러번 호출하게 되면, 호출할 때마다 count값이 증가하는 것을 볼 수 있는데..

그렇다면 함수가 생성된 이후에 외부 변수가 변경되면 함수는 새로운 값을 가져올까? 아니면 생성 시점 이전의 값을 가져올까?
함수를 아주 먼 곳에서 호출하게 되면 어떻게 될까? 함수는 호출되는 곳을 기준으로 외부 변수에 접근할까?

변수의 유효범위와 클로저 개념을 이해하면 위 질문에 답을 할 수 있게 된다.!


렉시컬 환경

js에선 실행 중인 함수, 코드 블록{...}, 스크립트 전체는 렉시컬 환경이라 불리는 내부 숨김 연관 객체를 갖는다. 이 렉시컬 환경은 이론상의 객체일 뿐이라서 코드를 통해 얻거나 조작은 불가하다.

렉시컬 환경 객체 구성 2가지

  • 환경 레코드 : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체
  • 외부 렉시컬 환경에 대한 참조 : 외부 코드와 연관됨

단계별로 렉시컬 환경에 대해 접근해보자.

단계 1. 변수

  • 변수는 환경 레코드의 프로퍼티로 저장된다.
  • 변수는 환경 레코드의 프로퍼티일 뿐이다.
  • 변수를 가져오거나 변형하는 것은 환경 레코드의 프로퍼티를 가져오거나 변경하는 것을 의미한다.

코드가 한줄 한줄 실행될 때마다 전역 렉시컬 환경이 어떻게 변하는 지 살펴보자.

  1. 스크립트가 실행되면 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라간다.
    • 이때 변수의 상태는 uninitialized로 js 엔진이 변수를 인지하긴 하지만 참조는 불가함.
  2. let을 만나 phrase는 undefined 값을 갖는다. 이 시점부터 변수를 사용할 수 있다.
  3. 값이 할당됨.
  4. 값이 변경됨.

단계2. 함수 선언문

선언문으로 선언한 함수는 바로 초기화된다(변수와 달리). 그래서 렉시컬 환경에서 만들어 지는 즉시 사용할 수가 있고, 선언되기 전에 함수를 사용할 수 있는 것이 바로 이 이유때문이다.
주의할 것은 함수 표현식은 적용되지 않는다는 점!

그럼 함수를 호출하면 렉시컬 환경이 어떻게 될까?

단계3. 내부와 외부 렉시컬 환경

함수를 호출해 실행하면 새로운 렉시컬 환경이 만들어진다. 이 렉시컬 환경에 저장되는 정보는

  • 함수 호출 시 넘겨받은 매개변수
  • 함수의 지역변수

가 저장된다.

코드와 함께 살펴보면

"John" 이라는 매개변수를 전달하여 say함수를 호출하여 함수 내부로 들어온 상황이다. 이때 say함수의 내부 렉시컬 환경에는 호출 시 넘겨받은 매개변수 name이 저장되어 있는 것을 확인할 수 있다.

함수가 호출중인 동안에는 호출 중인 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경이 가리키는 외부 렉시컬 환경을 갖게 된다. 렉시컬 환경은 환경 레코드 + 외부 렉시컬 환경에 대한 참조이기 때문!

그렇다면 코드에서 변수에 접근할 때 변수를 어떻게 검색하는 지 알아보자.

변수 검색

먼저 내부 렉시컬 환경을 검색 범위로 잡는다. → 내부 렉시컬 환경에서 원하는 변수를 찾지 못했으면 검색 범위를 확장한다. 내부 렉시컬 환경이 참조하는 외부 렉시컬 환경으로! → 검색 범위가 전역 렉시컬 환경으로 확장될 때까지 반복


내부 렉시컬 환경에서 phrase 변수를 찾을 수 없으니 내부 렉시컬 환경이 참조하는 외부렉시컬 환경으로 확장하여 검색한다. phrase 발견!

단계4. 함수를 반환하는 함수

함수를 반환하는 함수의 경우 렉시컬 환경이 어떻게 구성되는 지 살펴보자.

function makeCounter() {
  let count = 0;

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

let counter = makeCounter();

렉시컬 환경은 함수가 호출될 때마다 생성된다.

따라서 makeCounter를 호출할 때마다 아래와 같이 2개의 렉시컬 환경이 생성된다.

그런데 makeCounter를 실행하는 도중에 중첩함수가 만들어지게 되는데, 아직 생성만 되고 실행되지 않은 상태라면 렉시컬 환경이 어떻게 구성될까?

여기서 중요한 사실은

💡 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다. 즉 자신이 태어난 곳을 기억한다.
예외 ) new Function 함수를 이용해 만든 함수는 자신이 생성된 곳의 렉시컬 환경이 아닌 전역 렉시컬 환경을 참조함 → 외부 변수를 참조할 수 없음.

이것이 가능한 이유는 바로 함수가 갖고 있는 [[Environment]]라는 프로퍼티 때문이다.
함수는 [[Environment]] 라 불리는 숨김 프로퍼티를 갖는다. 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다. 즉 위의 예의 경우라면, counter.[[Environment]]에 makeCounter() 함수의 렉시컬 환경에 대한 참조값이 저장된다.


[[Environment]]가 makeCounter 렉시컬 환경을 가리키고 있는 것을 확인할 수 있다.

호출 장소와 상관없이 함수가 자신이 태어난 곳, 자신이 어디서 만들어졌는지를 알 수 있는 건 [[Environment]] 프로퍼티 덕분이고, 이 프로퍼티는 함수가 생성될 때 딱 한 번 값이 세팅되고 영원히 변하지 않는다.

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

이제 실행 흐름이 중첩 함수의 본문으로 넘어오는 경우에 대해 살펴보자.

본문으로 넘어오면 count 변수가 필요하다는 것을 볼 수 있다. 변수 검색 원리에 따라 자체 렉시컬 환경에서 해당 변수를 찾지만 지역변수가 없기 때문에 이 렉시컬 환경은 empty상태이다.

이제 counter() 렉시컬 환경이 참조하고 있는 외부 렉시컬 환경에서 count를 찾아보자. 발견!

count++가 실행되면 makeCounter의 count 변수값이 변화할까? 그렇다.

변숫값 갱신은 변수가 저장된 렉시컬 환경에서 이뤄진다.

따라서 실행이 종료된 후의 상태는 아래와 같다.


클로저

💡 외부 변수를 기억하고 외부 변수에 접근할 수 있는 함수

자바스크립트의 모든 함수는 클로저이다.

Why?

자바스크립트의 함수는 함수가 생성된 곳의 렉시컬 환경에 대한 참조가 저장되어 있는 [[Environment]]라는 숨김 프로퍼티를 갖는다. 따라서 [[Environment]] 프로퍼티를 이용하여 자신이 어디서 만들어졌는지를 기억하고 [[Environment]] 를 사용해 외부 변수에 접근한다.
그러나 상위 스코프의 어떠한 식별자도 참조하지 않는 함수는 브라우저의 최적화로 인해 상위 스코프를 기억하지 않는다. 따라서 일반적으로

중첩함수가 상위 스코프의 식별자를 참조 + 중첩함수가 외부함수보다 더 오래 유지되는 경우의 함수를 클로저라한다.

아래 예는 클로저가 아니다.

function outer() {
  const x = 1;
  
  //외부로 반환되지 못해 중첩함수가 외부함수보다 오래 유지되지 못한 경우다.
  function inner() {
    console.log(x);
  }
  inner(); 
}

outer();

클로저를 사용하는 이유?

특정 함수가 상태를 컨트롤하게 함으로써 의도치 않게 상태가 변경되는 것을 막고 외부로부터 상태를 안전하게 보호할 수 있다. 외부로부터 상태가 변경되고 불변성이 깨지는 것을 막기 위함!

가비지 컬렉션

함수 호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거된다. 함수와 관련된 변수들이 모두 제거되기 때문에 함수 호출이 끝나면 관련 변수를 참조할 수 없게 되는 것이다.

즉 자바스크립트에서 도달가능한 객체다 = 메모리에 유지된다 는 의미이다.

그런데 호출이 끝나도 여전히 도달 가능한 중첩 함수가 있을 수 있다. 이때 이 중첩 함수의 [[Environment]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다. 이는 이 함수가 도달 가능한 상태가 된다는 것을 의미하고 이는 함수 호출이 끝나도 렉시컬 환경이 메모리에 유지되는 이유이다.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

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

이 경우 중첩함수가 사용될 때마다 (즉 외부 함수를 여러번 호출할 때마다) 호출 시 만들어지는 각 렉시컬 환경 모두가 각각 메모리에 유지되게 된다. 중첩 함수가 메모리에서 삭제되고 난 후에야 이를 감싸는 렉시컬 환경(외부 함수의 렉시컬 환경)도 메모리에서 삭제된다.

최적화 프로세스

함수가 살아있는 동안에는 외부 변수 역시 메모리에 유지되지만, 자바스크립트 엔진은 이를 지속적으로 최적화한다. ⇒ 사용되지 않는다고 판단되는 변수는 메모리에서 제거해버린다.


렉시컬 환경과 실행컨텍스트

현재 실행중인 렉시컬 환경은 실행컨텍스트를 통해 관리된다.

자스 엔진이 script 처음 만나면 global 실행 컨텍스트 생성 → 콜 스택에 push → 자스가 함수 호출 찾음 → 함수 실행 컨텍스트 생성 → 콜 스택에 push → 함수 종료 → 콜 스택에서 pop → 메모리에서 실행 컨텍스트 제거


문제로 이해해보기

Q. counter와 counter2는 각각 독립적인가?

function makeCounter() {
  let count = 0;

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

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

counter와 counter2는 각각 다른 makeCounter 호출에 의해 만들어졌다.

함수를 호출한다 → 새로운 렉시컬환경이 생성된다. → 두 함수는 독립적인 렉시컬 환경을 갖게 된다. → 자신만의 count를 갖게 된다.

Q. if문 안의 함수

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

sayHi는 if문 안에서 생성되었기 때문에 if문 밖에서 접근 불가

좋은 웹페이지 즐겨찾기