자바스크립트의 핵심 (실행컨텍스트, 스코프, 클로져) 정리

이주영·2023년 4월 4일
0

Javascript

목록 보기
2/11
post-thumbnail

💡 Scope

스코프는 식별자 (변수, 함수, 클래스)가 참조될 수 있는 범위, 식별자를 검색하는 규칙을 의미한다. 이 범위는 코드 블록으로 구분되어져, {}로 감싸지는 함수, if나 switch 조건문, for 문 등으로 내부에 선언된 변수는 외부에서는 접근할 수 없는 특징을 가진다.

스코프 내의 변수는 유일해야하지만, 다른 스코프라면 같은 이름의 변수를 가질 수 있어, 이름 충돌을 막을 수 있고, 블록 내부의 사용하지 않는 변수는 이후 GC(Garbage Collecotr) 에 의해 메모리에서 제거되어 메모리를 절약할 수 있는 장점을 갖는다.

스코프

  • 전역 스코프 : 접근이 어디서든 가능, 프로그램이 종료될 때까지 가비지컬렉터가 제거하지 않는다.
  • 지역 스코프 : 블록 내부를 의미하며 지역 스코프에서 선언된 변수는 지역스코프 내부와, 하위 스코프에서만 참조가 가능하다.
const text = 'global'; //전역 변수, 전역 스코프
{
  const text = 'local'; //로컬 변수, 로컬 스코프
  {
    const text = 'depthLocal';
    console.log(text);//depthLocal
  }
}

그럼 어떻게 javascript는 scope들의 관계를 알 수 있을까?

⛓ Scope 체인

스코프들의 계층적인 연결을 스코프 체인이라고 부른다. 변수를 참조할 때 스코프 체인을 이용해 변수를 참조하는 코드에서 시작해서, 변수를 찾을 수 없으면 상위 스코프로 이동해서 변수를 찾는다. 이런 스코프 체인이 가능한 것은 연결리스트와 같이 실제 자료구조인 Lexical 환경로 연결되어 있기 때문이고 이러한 lexical 환경을 이용해 scope들의 관계를 알 수 있다.

Lexical 환경에서 전역 lexical 환경은 코드가 로드되면 바로 생성되며, 함수의 경우 함수가 호출되면 생성된다. 스코프의 변수들을 key에 저장하고, 상위 스코프 정보를 저장한다.

아래의 예제에서 inner내부의 x,y,z를 찾는 과정을 보면 먼저 x는 inner 내부에 존재하기 때문에 바로 찾아 탐색을 정지한다. y는 inner 내부에 없기 때문에 상위 스코프인 outer를 확인하지만 outer에도 없기 때문에 가장 상위인 전역 스코프에서 y를 찾고 탐색을 정지한다.

const x = "global x"
const y = "global y"

function outer() {
  function inner() {
    const x = "inner's local x";
    console.log(x); // inner's local x
    console.log(y); // global y
  }
  inner();
}

outer();

📚 lexical scope(동적 스코프)

함수가 실행될 때 스코프를 어떻게 정의하느냐에 따라 결과가 함수의 결과가 달라질 수 있다. 두가지 방식이 있을 수 있는데, 첫 번째는 동적 스코프로 함수를 어디에 호출하느냐에 따라 스코프가 결정되는 방식이다. 두 번째로는 lexical 스코프, 정적 스코프로 함수를 어디에 정의했느냐에 따라 스코프가 결정된다.

자바스크립트는 lexical scope을 기반으로 작동하기 때문에 어디에 함수를 호출하느냐가 중요한 게 아니라 함수를 정의한 곳이 기준이 되어 스코프가 정해진다.

const x = 1;

function foo() {
  const x = 10;
  bar();
}

function bar() {
  console.log(x);
}

foo(); // ?
bar(); // ?
  • 설명 위 예시를 보면서 처음에 foo()에는 x=10이 나오지 않을까라는 생각을 했지만, foo와 bar 모두 동일하게 1로 콘솔에 찍혔다. 역시 그 이유는 함수가 호출되는 곳이 기준이 아니라 함수가 정의되는 곳이 기준으로 스코프가 결정되기 때문이다. bar는 foo내부에서 호출되었지만 전역에 선언된 함수이기 때문에 전역 스코프를 가진다. 그렇기 때문에 x=1이 탐색되어 호출된다. 스코프는 식별자를 탐색하는 규칙이라는 점과 scope들은 scope chain으로 연결되어 있어, chain을 통해 식별자를 찾을 수 있다. 함수의 경우 스코프가 함수가 정의된 위치를 기준으로 스코프가 결정된다는 점을 알아보았다. 이제는 이러한 스코프가 정의되어있는 실행 컨텍스트에 대해 알아보자.

📦 실행 컨텍스트

실행 컨텍스트는 코드가 실행되기 위한 환경이라고 정리할 수 있다. 자바스크립트 코드는 다음과 같은 두 가지 과정을 거친다. 먼저 실행 컨택스트를 생성한 후에 변수와 함수 선언문을 등록하는 "소스코드의 평가" 과정이 실행된다. 다음으로 변수에 값을 할당하는 등의 일을 하는 "소스코드 실행" 과정이 실행한다.

예로const x=3에서 const x만을 이용해 x를 key로, undefined을 값으로 실행 컨텍스트에 먼저 등록한 후에 평가과정이 끝나고, 실행과정에서 x=3으로 값을 변경한다. 이렇게 과정이 나눠져 있기 때문에 실행 컨텍스트에 존재하지 않는 값이라면 reference error를 던져줄 수 있다. 항상 실행 중인 컨텍스트를 기준으로 탐색을 시작한 후에 상위 스코프로 이어나가는 과정, 스코프 체인을 따라 탐색한다.

  • 전역 코드 평가

소스코드가 로드되면 자바스크립트 엔진은 평가가 이루어진다.

전역 실행 컨텍스트가 생성되고 전역 렉시컬 환경을 바인딩한다. 전역 렉시컬 환경에는 전역 환경 레코드this 바인딩 그리고 외부 렉시컬 환경에 대한 참조로 3가지로 구성되어있다.

여기서 주의 깊게 봐야할 점은 var와 const/let은 실행 컨텍스트에서 다르게 취급되어진다는 점이다. var로 선언된 변수 혹은 함수선언문으로 정의한 전역 함수는 전역 렉시컬 환경의 객체 환경 레코드에 등록되어지는 반면, const/let은 전역 렉시컬 환경의 선언적 환경 레코드에 등록되어진다. 함수 선언문의 경우는 변수들과는 다르게 undefined으로 할당하지 않고 함수 객체를 바로 할당하는 특징을 가진다.

소스코드의 "평가"와 "실행"과정은 모든 스코프에서 동일하게 진행된다. 여기서 중요한 점은 실행하다가 함수나 다른 스코프를 만났을 때 스코프 내부로 들어가서 다시 평가와 실행을 진행한 후에, 원래 읽던 부분부터 소스코드를 읽어나간다. 그렇기 때문에 실행과정을 기억하고 있어야 하고 자바스크립트는 실행컨텍스트 스택을 통해서 기억하고 있다. 실행컨텍스트 스택은 실행 컨텍스트가 생성되는 평가 과정에 해당 실행 컨텍스트를 추가하고, 실행이 다 끝나면 제거한다.

다음 예시 코드로 과정을 정리하면 다음과 같다.

var x = 'something';

function foo () {
  var y = 'ySomething';

  function bar () {
    var z = 'xSomething';
    console.log(x + y + z);
  }
  bar();
}
foo();
  1. 전역 코드:

    1.1 평가: 전역 실행컨텍스트를 생성하고 스택에 넣는다. var x, function foo() 를 전역컨텍스트의 전역스코프에 동록하는데 이때, 전역 객체의 프로퍼티와 메소드로 등록한다.

    1.2 실행: x="something"값을 할당하고 함수를 호출하면서 foo() 함수 내부로 들어간다.

  2. foo 호출:

    2.1 평가: foo 함수 실행컨텍스트를 생성하고 스택에 넣는다. var y, function bar()를 지역 컨텍스트의 지역스코프에 등록한다. 그리고 함수는 arguments 객체가 생성되고 this 바인딩과 함께 지역 스코프에 등록한다.

    2.2 실행: y="ySomething" 값을 할당하고 함수를 호출하면서 bar() 함수 내부로 들어간다.

  3. bar 호출:

    3.1 평가: bar 함수 실행컨텍스트를 생성하고 스택에 넣는다. var z를 지역 컨텍스트의 지역스코프에 등록한다.

    3.2 실행: z="xSomething"값을 할당하고, console, x, y, z 값을 탐색 후에 실행한다.

  4. foo 복귀:

    bar()가 다 실행된 후에 실행컨텍스트 스택에서 제거되면 foo로 돌아온다.

  5. 전역으로 복귀:

    foo()가 다 실행된 후에 실행컨텍스트 스택에서 제거되면 전역으로 돌아온다. 전역이 제거되면서 완료된다.

lexical 환경

lexical 환경은 객체 형태의 스코프를 만들어 키값으로 식별자로 등록하고 값을 관리한다. lexical 환경은 두 가지의 컴포넌트로 구성되는데 하나는 환경 레코드, 다른 하나는 외부 렉시컬환경에 대한 참조이다. 환경레코드는 스코프에 등록된 식별자를 등록하는 곳이고, 외부 lexical 환경에 대한 참조는 상위 스코프를 가리킨다.

lexical 환경이 실행되는 과정을 더 세부적으로 나눠서 보면 실행 컨텍스트를 생성한 후에 lexical 환경을 생성한다.이후에 환경 레코드에 변수들을 등록한 후에, this binding이 이뤄지고 마지막에 외부 렉시컬 환경에 대한 참조가 결정된다. 이 과정들 또한 모든 실행 컨텍스트에서 동일하게 일어난다.

  • 요약

실행 컨텍스트 생성 → lexical 환경 생성 → 내부에 환경 레코드 변수 등록 → this 바인딩 → 외부 참조

디테일하게 정리

var x = 'xSomething';

function foo () {
  var y = 'ySomething';

  function bar () {
    var z = 'zSomething';
    console.log(x + y + z);
  }
  bar();
}
foo();

1. 전역코드 평가와 실행

  1. 평가: 전역 실행컨텍스트를 생성하고 스택에 넣는다.
  2. 전역 lexical 환경 생성: 전역 lexical 환경 생성하고 전역 실행컨텍스트와 바인딩한다.
  3. 변수 등록: 객체환경레코드에는 var로 선언된 변수와 함수선언문이 등록되어지는데, 전역 lexical환경의 경우 BindingObject를 통해 전역객체에 되어진다. const/let으로 선언된 변수들은 선언적 환경레코드에 등록된다.
  4. this binding: [[GlobalThisValue]]를 통해 this가 바인딩 되는데 전역이기 때문에 전역 this가 바인딩된다.
  5. 외부 lexical 환경에 대한 참조: 전역컨텍스트의 상위 스코프는 없기 때문에 null이 할당된다.
  6. 실행: x="xxx"값을 할당하고 foo를 실행한다.

2. foo 함수 평가와 실행

  1. 평가: foo 함수 실행컨텍스트를 생성하고 스택에 넣는다.
  2. lexical 환경 생성:foo 함수 lexical 환경 생성하고 foo 함수 실행 컨텍스트와 바인딩한다.
  3. 변수 등록: 함수에서는 함수환경레코드에 모든 변수가 같이 등록되어지며, 매개변수를 담은 arguments객체를 등록한다.
  4. this binding: [[ThisValue]]에 일반함수이기 때문에 전역객체를 가리킨다. (this는 함수 호출 방식에 따라 결정)
  5. 외부 lexical 환경에 대한 참조: foo의 함수 정의가 이루어진 실행컨텍스트의 lexical환경을 참조하기 때문에 전역 lexical환경 참조가 할당된다. 이때 내부슬롯인 [[Environment]]에 저장되어 항상 상위를 기억하고 있다.
  6. 실행: 스코프 체인을 이용해 y와 bar를 탐색하고 y="ySometing" 값을 할당하고 bar를 실행한다.

3. bar 함수 평가와 실행

  1. 평가: bar 함수 실행컨텍스트를 생성하고 스택에 넣는다.
  2. lexical 환경 생성:bar 함수 lexical 환경 생성하고 bar 함수 실행 컨텍스트와 바인딩한다.
  3. 변수 등록: 함수에서는 함수환경레코드에 모든 변수가 같이 등록되어지며, 매개변수를 담은 arguments객체를 등록한다.
  4. this binding: [[ThisValue]]에 일반함수이기 때문에 전역객체를 가리킨다.
  5. 외부 lexical 환경에 대한 참조: bar의 함수 정의가 이루어진 실행 컨텍스트의 lexical환경을 참조하기 때문에 내부슬롯인 [[Environment]]에 foo를 참조한다.
  6. 실행: 스코프 체인을 이용해 z,x,y, console를 탐색하고 할당, 호출한다.

4. bar 함수 종료

함수종료후 실행컨텍스트 스택에서 제거되어 실행 중인 실행 컨텍스트는 foo가 된다. 이때 중요한 것은 실행컨텍스트 스택에서 bar가 사라진다고 해서 bar의 lexical 환경까지 제거되지는 않는다. 제거되는 것은 아무도 참조하고 있지 않을 때 (클로져와 연관있는 개념) 가비지 컬렉터에 의해 제거되어진다.

5. foo 함수 종료

함수종료후 실행컨텍스트 스택에서 제거되어 실행 중인 실행 컨텍스트는 전역컨텍스트가 된다.

6. 전역 코드 종료

실행할 코드가 더 이상 없으면 전역 코드도 종료되면서 실행컨택스트 스택에서 제거된다.

블록레벨 스코프

if문이나 for문과 같은 블록레벨의 스코프가 있게 되면 새롭게 렉시컬 환경을 생성한 후에, 기존의 전역 lexical 환경과 교체하고, 블록 lexical 환경은 외부 렉시컬 환경참조를 기존 lexical환경을 하는 구조가 된다.

💊 Closure

클로저는 함수와 함수가 정의된 렉시컬 환경과의 조합이라고 MDN에 정의되어있다. 함수가 정의된 렉시컬 환경이란 말은 앞서 설명한 실행컨텍스트에서 함수의 lexical 환경의 외부 lexical환경에 대한 참조가 함수가 정의된 곳(실행 컨택스트)을 기준으로 상위 스코프로 되어있기 때문에 항상 함수는 상위 스코프를 기억하고 있다.

function outer(){
  const name = 'juyoung';
  console.log(name)
}
sayName() //juyoung
console.log(name) //undefined

outer 함수 내부에 선언된 변수인 name을 함수 실행 컨텍스트가 콜스텍에서 제거된 이후 상위 스코프에서 사용하려면 현재는 사용할 수 없다. 사용할 수 있는 방법이 있을까?

  • 당연히 변수를 전역 스코프에 선언할 수 있을 것 같다.
 const name = 'juyoung';
function outer(){
  console.log(name)
}
sayName() //juyoung
console.log(name) //juyoung
  • 클로져를 활용할 수도 있다.
function outer(){
  const name = 'juyoung';
  console.log(name)
  return function inner(){
    const greeting = 'hi!'
    console.log(greeting,name)
  }
}
//1번 시점
const getJuyoung = outer() //juyoung 

//2번 시점
getJuyoung() //hi!juyoung

→ 위의 코드대로 실행됐을때 1번 시점까지 생성될 실행 컨텍스트를 정리해보겠습니다.


"전역 컨텍스트": {
  변수객체: {
    arguments: null,
    variable: [{ outer: Function }, 'getJuyoung'],
  },
  scopeChain: ['전역 변수객체'],
  this: window,
}

"outer 함수 컨텍스트": {
  변수객체: {
    arguments: null,
    variable: [{ name: 'juyoung' }],
  },
  scopeChain: ['outer 변수객체', '전역 변수객체'],
  this: window,
}

→ getJuyoung이라는 변수에 호출된 함수가 선언되어있고 호출된 함수의 리턴값을 함수입니다. 즉 getJuyoung이라는 변수에는 함수가 담겨져있습니다. 그래서 변수 getJuyoung 뒤에 함수와 같이 호출을 해주면 위에서 리턴 함수가 실행이 되고 그 즉시 scope chain은 lexical scoping을 따라서 'outer 변수객체', '전역 변수객체' 를 포함합니다. 따라서 closure을 호출할 때 컨텍스트는 다음과 같습니다.

"closure 컨텍스트":  {
  변수객체: {
    arguments: null,
    variable: null,
  scopeChain: ['closure 변수객체', 'getJuyoung 변수객체', '전역 변수객체'],
  this: window,
}

위의 예시를 통해서 클로저를 보다 더 깊게 이해할 수 있게 됐습니다.

정리해보자면, 클로저로 불리울 수 있는 조건은 첫 번째, 내부함수가 외부함수의 식별자를 참조하고 있어야하고, 두 번째, 외부 함수가 내부 함수보다 먼저 실행 컨텍스트 스택에서 제거되어야 한다.

그렇다면 왜 클로저를 사용할까?

클로저의 활용

클로저는 자바스크립트에서 상태를 안전하게 관리하기 위해서 사용되어진다. 상태를 안전하게 보관하기 위해서는 상태를 외부에서 직접 접근하지 못해야 하며, 상태를 변화시킬 수 있는 함수를 이용해서 상태를 변화시키는 방식이 사용 되어야 한다. 이를 캡슐화, 또는 정보은닉이라고도 부른다.

클로저는 외부 함수에 상태가 존재하고 이를 변경할 수 있는 함수를 내부 함수에 전달해, 외부에서 전달된 함수를 통해서만 상태를 변화시키는 방식이다.

const Counter=(function(){
    let num=0
    return {
        increase(){
            return ++num;
        }
        decrease(){
            return --num;
        }
    }
})

typescript에는 class를 이용해 private, protected, public로 상태를 관리할 수 있지만, javascript에서는 없기 때문에 클로저를 이용해서 사용했다.

이러한 클로저를 보다보면 사용 목적이 리액트의 useState hook과 너무나 닮아있다는 것을 알 수 있다. 상태를 안전하게 관리하기 위해, 상태를 관리하는 함수, setState로만 상태를 변화시킬 수 있다는 점이 너무나 닮아있다. 실제로 usestate hook의 내부 구조를 보면 다음과 같이 되어 있다.

var ReactCurrentDispatcher = {
    /**
     * @internal
     * @type {ReactComponent}
     */
    current: null
  };

function resolveDispatcher() {
    var dispatcher = ReactCurrentDispatcher.current;
    return dispatcher;
  }

function useState(initialState) {
   var dispatcher = resolveDispatcher();
   return dispatcher.useState(initialState);
 }

생각보다 너무 단순하게 구성되어있는 내부 로직을 볼 수 있다. current값으로 받아와 useState값에 받은 값을 객체 안에 보관하고 있는 모습이다. 이것을 조금 더 구체적으로 구현하면 다음과 같다.


const useState = ([]) => {
	let value = [];
	const setValue = ({data:10}) => {
	value = {data:10}
	}

	return [value, setValue]
}

const [userLists, setUserLists] = useState([])
console.log(userLists) //[]
useEffect(() => {
setUserLists(...userList, {data:10})
},[])

console.log(setUserList()) /// {data: 10}

//state로 상태를 전달해주고, setState로 전달받은 새로운 값으로 상태를 변경한다. 클로저를 이용한 간단한 로직이지만, 이것으로 인해 리액트의 함수형 컴포넌트가 주를 이루게 되었다. 왜 hook이 나오기 이전 버전에서는 클래스 컴포넌트만 상태 관리가 가능했는지가 이해되었다.

[참고]

모던 자바스크립트 딥다이브 (http://www.yes24.com/Product/Goods/92742567)

Deep dive: How do React hooks really work? (https://www.netlify.com/blog/2019/03/11/deep-dive-how-do-react-hooks-really-work/)

function outer(){
  const name = 'juyoung';
  console.log(name)
  return function inner(){
    const greeting = 'hi!'
    console.log(greeting,name)
  }
}
//1번 시점
const getJuyoung = outer() //juyoung 

getJuyoung()
//2번 시점
//hi!juyoung
profile
https://danny-blog.vercel.app/ 문제 해결 과정을 정리하는 블로그입니다.

0개의 댓글