클로저와 실행 컨텍스트에 대해서 알아보자

MountionRiver·2024년 12월 18일

실행 컨텍스트 개념

ECMAScript에서는 실행 컨텍스트를 실행 가능한 코드를 형상화하고 구분하는 추상적인 개념으로 적혀있다.
실행 컨텍스트는 실행 가능한 자바스크립트 코드 블록이 실행되는 환경이다. 이 컨텍스트 안에 실행에 필요한 여러 가지 정보를 담고 있다. 이 정보들은 대부분 함수가 된다.
ECMAScript에서는 실행 컨텍스트가 형성되는 경우를 세가지로 구분한다.
전역코드, eval()함수 코드, 함수 안의 코드이다.
대부분 프로그래머는 함수로 실행 컨텍스트를 만든다. 그리고 이 코드 블록 안에 변수 및 객체, 실행 가능한 코드가 들어있다.
이 코드가 실행되면 실행 컨텍스트가 생성되고, 실행 컨텍스트는 스택 안에 하나씩 차곡차곡 쌓이고, 제일 위에 위치하는 컨텍스트가 현재 실행되고 있는 컨텍스트다.

현재 실행되는 컨텍스트에서 이 컨텍스트와 관련 없는 실행 코드가 실행되면, 새로운 컨텍스트가 생성되어 스택에 들어가고 제어권이 그 컨텍스트로 이동한다.

console.log("This is global context")

function ExContext1() {
  console.log("This is ExContext1");
};

function ExContext2() {
  ExContext1();
  console.log("This is ExContext2");
};

ExContext2();

// This is global context
// This is ExContext1
// This is ExContext2

맨 처음 전역 실행 컨텍스트가 가장 먼저 실행된다.
이 과정에서 새로운 함수 호출이 발생하면 새로운 컨텍스트가 만들어지고 실행되며, 종료되면 반환된다. 이와 같은 과정이 반복된 후, 전역 실행 컨텍스트의 실행이 완료되면 모든 실행이 끝난다.

실행 컨텍스트와 생성 과정

실행 컨텍스트의 생성 과정과 스코프 체인에 대해 알아보자. 다음 예제를 통해, 실행 컨텍스트가 어떻게 만들어지는지 생각해보자.

function execute(param1,param2) {
  var a=1, b=2;
  function func() {
    return a+b
  };
  return param1 + param2 + func();
}

execute(3,4);

자바스크립트 엔진은 다음과 같은 일을 정해진 순서대로 실행한다.

활성 객체 생성
실행 컨텍스트가 생성되면 자바스크립트 엔진은 해당 컨텍스트에서 실행에 필요한 여러가지 정보를 담을 객체를 생성한다. 이 객체는 매개변수나 사용자가 정의한 변수 및 객체를 저장하고, 새로 만들어진 컨텍스트로 엔진 내부에서 접근 가능하다.

arguments 객체 생성
그 다음 단계에서는 arguments 객체를 생성한다.

스코프 체인 생성
현재 컨텍스트의 유효 범위를 나타내는 스코프 체인을 생성한다. 현재 생성된 객체가 스코프 체인의 제일 앞에 추가되며, execute() 함수의 인자나 지역 변수 등에 접근할 수 있다.

변수 생성
현재 실행 컨텍스트 내부에서 사용되는 지역 변수의 생성이 이루어진다. 앞서 생성된 객체가 변수객체로 생성되며, 변수 객체 안에서 호출된 인자는 각각의 프로퍼티가 만들어지고 그 값이 할당된다. 만약 값이 없다면 undefined가 할당된다. 여기서는 변수나 내부 함수를 단지 메모리에 생성만 이루어지며, 초기화는 각 변수나 함수에 해당하는 표현식이 실행되기 전까지는 이루어지지 않는다.

this 바인딩
this 키워드를 사용하는 값이 할당된다. 이 값에 어떤 객체가 들어갈지는 함수 호출에 따른 this 바인딩 규칙에 따라 결정된다.

코드 실행
이렇게 하나의 컨텍스트가 생성되고, 변수 객체가 만들어진 후, 코드에 있는 여러가지 표현식이 실행된다.

정리

함수 객체는 [scope] 프로퍼티로 현재 컨텍스트의 스코프 체인을 참조한다.

한 함수가 실행되면 새로운 실행 컨텍스트가 만들어지는데, 이 새로운 실행 컨텍스트는 자신이 사용할 스코프 체인을 다음과 같은 방법으로 만든다. 현재 실행되는 함수 객체의 [scope] 프로퍼티를 복사하고, 새롭게 생성된 변수객체를 해당 체인의 제일 앞에 추가한다.

스코프 체인 = 현재 실행 컨텍스트의 변수 객체 + 상위 컨텍스트의 스코프 체인

var value = "value1";

function printValue() {
  return value;
}
function printFunc(func) {
  var value = "value2";
  console.log(func());
}
printFunc(printValue);

클로저 개념

스코프는 함수를 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정된다. 이를 렉시컬 스코핑라고 한다. 아래 예제의 함수 innerFunc는 함수 outerFunc의 내부에서 선언되었기 때문에 함수 innerFunc의 상위 스코프는 함수 outerFunc이다. 함수 innerFunc가 전역에 선언되었다면 함수 innerFunc의 상위 스코프는 전역 스코프가 된다.

function outerFunc() {
  var x = 10;
  var innerFunc = function () { console.log(x); };
  return innerFunc
}

var inner = outerFunc();
inner(); // 10

위 그림은 위 코드의 스코프체인을 나타낸 것이다. outerFunc()는 내부함수 innerFunc()를 반환하고 실행 컨텍스트 스택에서 사라졌지만, 여전히 스코프체인에서는 outerFunc()의 변수객체가 존재한다. 이처럼 자신을 포함하고 있는 외부함수보다 내부함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부함수가 호출되더라도 외부함수의 지역 변수에 접근할 수 있는데 이러한 함수를 클로저(Closure)라고 부른다. 또는 이미 생명주기가 끝난 외부 함수의 변수를 참조하는 함수를 클로저라고 한다. 그리고 클로저로 참조되는 외부 변수를 자유 변수라고 한다.

클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다. 클로저는 자신이 생성될 때의 환경을 기억하는 함수다.

다음 예제를 보자.

function otherFunc(arg1,arg2) {
  var local = 8;
  function innerFunc(innerArg) {
    console.log((arg1 + arg2) / (innerArg + local));
  }
  return innerFunc;
}
var exam1 = outerFunc(2,4);
exam1(2);

외부 함수 otherFunc()는 내부함수 innerFunc()를 반환하고 실행컨텍스트에서 사라지고, 내부함수에서 local 변수를 참조한다. outerFunc(2,4)에서 외부함수가 호출되고, 생성된 변수객체가 스코프체인에들어가고, 이는 내부함수의 스코프체인으로 참조된다. outerFunc()함수가 종료되었지만, 스코프체인으로 여전히 접근 가능하게 살아있다. 따라서 이후에 exam1(n)을 호출하여도, 인자로 넘겨진 innerArg와 함께 arg1,arg2,local변수가 참조되어 (2+4)/(2+8) 의 결과를 반환한다. 이 outerFunc 변수 객체의 프로퍼티값은 실행컨텍스트가 끝나도 읽기 및 쓰기가 가능하다.

클로저의 활용

클로저는 대부분의 경우, 내부 함수에서 접근하려는 변수들이 스코프체인의 첫번째에 존재하지 않기 때문에 무분별하게 활용하면 안된다.

function HelloFunc(func){
  this.greeting = 'hello';
}

HelloFunc.prototype.call = function(func){
  func ? func(this.greeting): this.func(this.greeting);
}

var userFunc = function(greeting){
  console.log(greeting);
}

var objHello = new HelloFunc();
objHello.func = userFunc;
objHello.call();
  1. new HelloFunc()으로 새로운 객체가 생성되고, 이 객체에 greeting: 'hello' 프로퍼티가 추가된다.
  2. 생성된 객체 objHello에 func 프로퍼티로 userFunc 함수가 할당된다.
  3. objHello.call()이 실행되면, 프로토타입의 call 메서드가 호출된다. 이때 this는 objHello를 가리킨다.
  4. call 메서드에서 func 인자가 없으므로 this.func(this.greeting)이 실행된다다.
    • this.func는 userFunc를 가리킴
    • this.greeting은 'hello' 값을 가짐
  5. 결과적으로 userFunc('hello')가 실행되어 'hello'가 출력된다.

함수의 캡슐화

"나는 XXX다. 난 XXX에 살고, 나는 XX살이다." 라는 문장을 출력할때, XXX 부분은 사용자에게 인자로 입력 받아 값을 출력하는 함수를 구현해본다고 하자.

const buffAr = ["나는 ", ,"다. 난", ,"에 살고, 나는 ", ,"살이다."];

function getCompletedStr(name,city,age){
  buffAr[1] = name;
  buffAr[3] = city;
  buffAr[5] = age;
  
  return buffAr.join('');
}

const str = getCompletedStr('PSM', 'seoul', 25);
console.log(str)

올바르게 출력하지만, 여기서의 문제점은 buffAr 배열이 전역 변수로서 외부에 노출되어 있다는 점이다. 이렇게되면, 다른 함수에서 이 배열에 쉽게 접근하여 값을 바꿔버릴 수도 있다. 이러한 문제를 해결하기 위해서 클로저 개념을 사용한다.

const getCompletedStr = (function(){
  const buffAr = ["나는 ", ,"다. 난", ,"에 살고, 나는 ", ,"살이다."];
  
  return (function(name,city,age){
    
    buffAr[1] = name;
  	buffAr[3] = city;
  	buffAr[5] = age;
  
  	return buffAr.join('');
  });
})();

const str = getCompletedStr('PSM', 'seoul', 25);
console.log(str);

변수 getCompletedStr에 익명함수를 즉시 실행시켜 반환되는 함수를 할당하였다. 여기서 반환되는 함수가 클로저가 되고, 즉시 실행되는 함수에 buffAr은 자유변수가 된다. 아래와 같이 buffAr에 접근할 수 없는 것을 확인할 수 있다.

클로저 활용시 주의사항

클로저의 프로퍼티값이 쓰기 가능하므로 그 값이 여러 번 호출로 항상 변할 수 있다.

function outerFunc(argNum) {
  var num = argNum;
  return function(x) {
    num += x;
    console.log('num: ',num);
  }
}
var exam = outerFunc(40);
exam(5);
exam(10);

이 처럼, 자유변수 값이 변할 수 있다.

하나의 클로저가 여러 함수 객체의 스코프 체인에 들어갈 수도 있다.

function func(){
  var x = 1;
  return {
    func1 : function(){ console.log(++x) },
    func2 : function(){ console.log(-x) }
  };
};

var exam = func();
exam.func1();
exam.func2();

클로저가 반환하는 객체에는 두개의 함수가 정의되어있고, 두 함수 모두 자유변수 x를 참조하기 때문에, 각 함수가 호출될 때 마다 자유변수 x 값이 변한다.

루프 안에서 클로저를 활용할 때.

function countSeconds(howMany) {
  for (var i =1; i <= howMany; i++) {
    setTimeout(function () {
      console.log(i);
    }, i * 1000 );
  }
};
countSeconds(3);

1, 2, 3을 1초 간격으로 출력하려는 의도의 코드이나 4가 3번 1초 간격으로 출력된다. 그 이유는 setTimeout에 들어가는 함수가 실행되는 시점은 countSeconds 함수의 실행이 종료된 이후이다.

0개의 댓글