실행 컨텍스트와 클로저

Y·2020년 8월 4일
1

자바스크립트

목록 보기
10/20

실행 컨텍스트 개념


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

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

다음 예제와 그림으로 자세히 이해해보자.

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 객체를 생성한다. 활성객체는 arguments 프로퍼티를 통해 이 arguments 객체를 참조한다. 위 함수 작동과정을 그림으로 표현하면 다음과 같다.

스코프 정보 생성


현재 컨텍스트의 유효 범위를 나타내는 스코프 정보를 생성한다. 이 스코프 정보는 현재 실행 중인 실행 컨텍스트 안에서 연결 리스트와 유사한 형식으로 만들어진다. 현재 컨텍스트에서 특정 변수에 접근하는 경우, 이 리스트를 사용한다. 이 리스트를 스코프 체인이라고 하는데, [[scope]]프로퍼티로 참조된다. 자세한 내용은 이후에 다룬다. 여기서는 현재 생성된 활성 객체가 스코프 체인의 제일 앞에 추가되며, execute() 함수의 인자나 지역 변수 등에 접근할 수 있다는 것만 알면 된다.

변수 생성


현재 실행 컨텍스트 내부에서 사용되는 지역 변수의 생성이 이루어진다. ECMASciprt 에서는 생성되는 변수를 저장하는 변수 객체를 언급하는데, 실제적으로 앞서 생성된 활성 객체가 변수객체로 생성된다. 변수 객체 안에서 호출된 인자는 각각의 프로퍼티가 만들어지고 그 값이 할당된다. 만약 값이 넘겨지지 않았다면 undefined가 할당된다. 여기선 execute()함수 내부에 정의된 변수 a,b와 함수 func()가 생성된다. 여기서는 변수나 내부 함수를 단지 메모리에 생성만 한다. 초기화는 각 변수나 함수에 해당하는 표현식이 실행되기 전까지는 이루어지지 않는다. 따라서 a,b에는 먼저 undefined가 할당되며 표현식의 실행은 변수 객체 생성이 다 이루어진 후 시작된다.

this 바인딩


마지막 단계에서는 this 키워드를 사용하는 값이 할당된다. 이 값에 어떤 객체가 들어갈지는 함수 호출에 따른 this 바인딩 규칙에 따라 결정된다. 여기서 this가 참조하는 객체가 없으면 전역 객체를 참조한다.

코드 실행


이렇게 하나의 컨텍스트가 생성되고, 변수 객체가 만들어진 후, 코드에 있는 여러가지 표현식이 실행된다. 이렇게 실행되면서 변수의 초기화 및 연산, 또 다른 함수 실행 등이 이루어진다. 원래 undefined가 할당되었던 변수 a,b역시 이젠 1,2의 값이 할당된다.

참고로 전역코드가 실행될때 생성되는 전역 실행 컨텍스트는 일반적인 실행 컨텍스트와는 약간 다르다. 전역 실행 컨텍스트는 변수를 초기화하고 이것의 내부 함수는 일반적인 탑 레벨으 함수로 선언된다. 그리고 전역 실행 컨텍스트의 변수 객체가 전역 객체로 사용된다. 즉, 전역 실행 컨텍스트에서는 변수 객체가 곧 전역 객체다. 따라서 전역적으로 선언된 함수와 변수가 전역 객체의 프로퍼티가 된다. 전역 실행 컨텍스트 역시, this를 전역 객체의 참조로 사용한다.

스코프 체인


실행 컨텍스트 생성 과정에서 스코프 체인이 어떻게 만들어지는지 알아보자. 자바스크립트도 다른 언어와 마찬가지로 유효 범위가 존재한다. 이 유효 범위 안에서 변수와 함수가 존재한다. 자바스크립트에서는 for() {} , if {} 와 같은 구문은 유효 범위가 없다. 오직 함수만이 유효 범위의 한 단위가 된다. 이를 함수 레벨 스코프라고 한다. 이 유효 범위를 나타내는 스코프가 [[scope]] 프로퍼티로 각 함수 객체 내에서 연결 리스트 형식으로 관리되는데, 이를 스코프 체인이라고 한다. 이러한 스코프 체인은 다음 그림과 같이 각 실행 컨텍스트의 변수 객체가 구성 요소인 리스트와 같다.

각각의 함수는 [[scope]] 프로퍼티로 자신이 생성된 실행 컨텍스트의 스코프 체인을 참조한다. 함수가 실행되는 순간 실행 컨텍스트가 만들어지고, 이 실행 컨텍스트는 실행된 함수의 [[scope]] 프로퍼티를 기반으로 새로운 스코프 체인을 만든다. 예제를 보자.

전역 실행 컨텍스트의 스코프 체인


var var1 = 1;
var var2 = 2;
console.log(var1); // 1
console.log(var2); // 2

이는 전역 코드이다. 함수가 선언되지 않아 함수 호출이 없는 상태이며, 실행가능한 코드들만 나열되어 있다. 이 코드가 실행되면 먼저 전역 실행 컨텍스트가 생성되고, 변수 객체가 생성된다. 이 변수 객체의 스코프는 현재 단 하나만의 실행이 있기 때문에 참조할 상위 컨텍스트가 없다. 따라서 자신이 최상위에 위치하는 변수 객체인 것이다. 이 변수 객체의 스코프 체인은 자기 자신만을 가진다. 다시 말해서, 변수 객체의 [[scope]]는 변수 객체 자신을 가리킨다. 그 후, 변수가 생성되며 변수 객체에 의해 참조 된다. 전역 실행 컨텍스트이므로 이 변수 객체가 곧 전역 객체이다.

함수를 호출한 경우 생성되는 실행 컨텍스트의 스코프 체인


var var1 = 1;
var var2 = 2;
function func() {
  var var1 = 10;
  var var2 = 20;
  console.log(var1); // 10
  console.log(var2); // 20
}
func();
console.log(var1); // 1
console.log(var2); // 2

이 함수를 실행하면 전역 실행 컨텍스트가 생성되고, func() 함수 객체가 만들어진다. 이 함수 객체의 [[scope]]는 현재 실행되는 컨텍스트의 변수 객체에 있는 [[scope]]를 그대로 가진다. 따라서 func함수 객체의 [[scope]]는 전역 변수 객체가 된다. 다음과 같이 func() 함수를 실행해보자.

func();

함수를 실행하였으므로 새로운 컨텍스트가 만들어진다. 이 컨텍스트를 편의상 func 컨텍스트라고 하면, func 컨텍스트의 스코프 체인은 실행된 함수의 [[scrop]] 프로퍼티를 그대로 복사한 후, 현재 생성된 변수 객체를 복사한 스코프 체인의 맨 앞에 추가한다. func() 함수 객체의 [[scope]] 프로퍼티가 전역 객체 하나만을 가지고 있었으므로, func 실행 컨텍스트의 스코프 체인은 다음과 같다.

정리


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

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

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

var value = "value1";

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

  • 각 함수 객체가 처음 생성될 때 [[scope]]는 전역 객체의 [[scope]]를 참조한다. 따라서 각 함수가 실행될 때 생성되는 실행 컨텍스트의 스코프 체인은 전역 객체와 그 앞에 새롭게 만들어진 변수 객체가 추가된다.

블록 레벨 스코프 체인


let


ES6는 블록 레벨 스코프를 따르는 변수를 선언하기 위해 let키워드를 제공한다.

let foo = 1234; // 전역 변수

{
  let foo = 123; // 지역 변수
  let bar = 321; // 지역 변수
}
console.log(foo); // 123
console.log(bar); // RefenenceError : bar is not defined

let 키워드로 선언된 변수는 블록 레벨 스코프를 따른다.

const


let키워드는 재할당이 자유롭지만, const키워드는 재할당이 금지되어있고 선언과 동시에 할당이 이루어져야 한다. 그렇지 않으면 SyntaxError가 발생한다. const로 선언한 변수 또한 블록 레벨 스코프를 갖는다.

정리


ES6를 사용할 경우, var 키워드는 사용하지 않는 것을 추천한다. 재 할당이 필요한 경우에 한정해 let키워드를 사용하고, 이때 변수의 스코프는 최대한 좁게 만드는 것이 좋다. 변경이 발생하지 않는 원시 값과 객체에는 const키워드를 사용하는 것이 좋다.

클로저


개념


스코프는 함수를 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정된다. 이를 렉시컬 스코핑(Lexical scoping)라 한다. 아래 예제의 함수 innerFunc는 함수 outerFunc의 내부에서 선언되었기 때문에 함수 innerFunc의 상위 스코프는 함수 outerFunc이다. 함수 innerFunc가 전역에 선언되었다면 함수 innerFunc의 상위 스코프는 전역 스코프가 된다.
출처 : https://poiemaweb.com/js-closure

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

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

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

“A closure is the combination of a function and the lexical environment within which that function was declared.”
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

즉, 클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다. 이를 조금 더 간단히 말하면 클로저는 자신이 생성될 때의 환경(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();

내가 이해한 바로 , 위 코드의 실행과정은 다음과 같다.
ObjHello라는 변수에 new 연산자로 생성자함수를 호출한다. 생성자 함수 호출과 동시에 빈 객체가 생성되고, this 인자로 greeting프로퍼티를 동적으로 생성했다.
HelloFunc.call()메서드는, 자신을 호출한 HelloFunc 함수로 생성된 객체에 this를 바인딩한다. 따라서, HelloFunc.call()의 함수는 HelloFunc 실행 컨텍스트 내부의 func 에 참조된 함수를 인자로받으며, 해당 func은 자신이 호출된곳인 HelloFunc의 지역변수인 greeting만을 인자로 받는다. 따라서, 위 코드의 실행결과는 hello가 된다.

잘못 되었을수도있습니다.. 혹시 읽으시는분이있다면 잘못된부분이있다면 알려주시면 감사하겠습니다..!

지금 이 예제에서는 Hellofunc()greeting만을 인자로 넣어 사용자가 인자로 넘긴 함수를 실행시킨다. 따라서 사용자가 정의한 함수도 한 개의 인자만 받을수 밖에 없다. 여기서 사용자가 원하는 인자를 더 넣어서 HelloFunc()를 이용하여 호출해보자.

function saySomething(obj,methodName,name){
  return (function(greeting){
    return obj[methodName](greeting,name);
  });
}

function newObj(obj,name){
  obj.func = saySomething(this,"who",name);
  return obj;
}

newObj.prototype.who = function(greeting,name) {
  console.log(greeting + " " + (name || "everyone") );
}

여기서 새로운 함수 newObj를 선언하였고, 이 함수는 HelloFunc()의 객체를 더 자유롭게 사용하고 정의한 함수이다. 첫 번째로 인자로 받는 objHelloFunc()의 객체가 되고, 두번 째 인자는 사용자가 출력을 원하는 사람 이름이 된다. newObj()함수의 객체를 다음과 같이 만들어보자.

var obj1 = new newObj(objHello,"zzoon");

new연산자로 newObj 생성자 함수를 호출하였다. 이 코드가 실행되면, 위의 정의한 newObj함수에 따라 다음의 코드가 실행된다. 첫번 째 인자 objHellofunc프로퍼티에 saySomething() 함수에서 반환되는 함수를 참조시키고, objHello를 반환한다. saySomething()함수 내부에서는, objHello.funcgreeting을 인자로받아 해당 메서드를 호출한 objHellofunc프로퍼티에 objHello.who 가 참조하는 함수를 반환하는 함수를 참조시킨다. obj1은 인자로 넘겼던 objHello 객체에서 func프로퍼티에 참조된 함수만 바뀐 객체가 된다. 따라서 출력 결과는 hello zzoon이 된다.

obj1.call();

따라서 위 코드가 실행되면, 실질적으로는 newObj.prototype.who가 실행된다. 이와 같은 방식으로, 사용자는 자신의 객체 메서드인 who함수를 HelloFunc에 연결시킬 수 있다. 여기서 클로저saySomething()에서 반환되는 function(greeting){}이 되고, 이 클로저는 자유변수 obj,mehodName,name을 참조한다. 이 방식은 정해진 형식의 함수를 콜백해주는 라이브러리가 있을 경우, 그 형식과는 다른 형식의 사용자 정의 함수를 호출할 때 유용하게 사용된다고 한다. 대표적인 예시로, 브라우저에서 onclick 같은 프로퍼티에 해당 이벤트 핸들러를 사용자가 정의해 놓을 수 있는데, 이 이벤트 핸들러의 형식은 function(event){}이다. 여기서 클로저를 활용하면 event외의 원하는 인자를 더 추가하여 이벤트 핸들러를 사용할 수 있다.

함수의 캡슐화


"나는 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에 접근할 수 없는 것을 확인할 수 있다.

setTimeout()에 지정되는 함수의 사용자 정의ㅊ


이 책에서 소개해주는 클로저 활용 예제이다.
setTimeout()함수는 웹 브라우저에서 제공하는 함수로, 첫 번째 인자로 넘겨지는 함수 실행의 스케쥴링을 할 수 있다. 두 번째 인자인 밀리 초 단위 숫자만큼의 시간 간격으로 해당 함수를 호출한다. setTimeout()으로 자신의 코드를 호출하고 싶다면 첫 번째 인자로 해당 함수 객체의 참조를 넘겨주면 되지만, 이것으로는 실제 함수가 실행될 때 함수에 인자를 넘겨줄 수 없다. 그렇다면 자신이 정의한 함수에 인자를 넣어줄 수 있게 하는 과정을 살펴보자.

function callLater(obj, a, b) {
  return (function(){
    obj["sum"] = a + b;
    console.log(obj["sum"]);
  });
}

const sumObj = {
  sum : 0
}

const func = callLater(sumObj, 1, 2);
setTimeout(func,500);

이와 같은 방식으로 하게되면, setTimeout으로 함수를 호출할때 func변수에 사용자 지정 함수에 인자를 넘겨줄 수 있게 된다.

클로저 활용시 주의사항


  • 클로저의 프로퍼티값이 쓰기 가능하므로 그 값이 여러 번 호출로 항상 변할 수 있다.
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 );
  }
};

이 부분은 추후에 다시 정리해야겠다.

profile
연세대학교 산업공학과 웹개발 JavaScript

0개의 댓글