Javascript this

1. What is ‘this’?

마지막으로 JS의 ‘this’에 대해서 이야기 해 보고자 한다. 많은 사람들이 JS에서 this를 어려워하는 것은 기본적으로 다른 class/instance개념 기반 언어들의 개념으로 this를 생각하기 때문이다.

var MyFunction = function() {
    this.a = 1;
}
 
> MyFunction.a
undefined

기존 class/instance형 언어의 개념으로 생각해 보면 MyFunction은 ‘선언’만 된 것이고 MyFunction은 생성이 안된 클래스로 생각된다. (아니라는 것은 첫번째 글에서 설명했다. MyFunction은 엄연한 이 상태로 객체이다.)

앞에서 MyFunction은 저 상태로 객체가 생성은 되지만 함수가 호출은 되지 않는다고 했다. 그렇다면 혹시 MyFunction(); 으로 함수를 호출하면 객체가 생성될지도 모르겠다.

var MyFunction = function() {
    this.a = 1;
}
MyFunction();
 
> MyFunction.a
undefined       // oops.. it is still undefined...

하지만 MyFunction을 호출하더라도 (분명히 호출했다) MyFunction에 a라는 프로퍼티는 존재하지 않는다. 그렇다면 도대체 a는 어디로 간 것일까.

위와 같은 사고의 흐름이 흔히 JS에서 this를 접하는 경우에 겪게되는 사항이다. 일단 사고의 방식을 JS에서는 바꾸어야 한다. 우선 다음의 3가지만 기억하자.

  • JS에서 this는 어떤 객체를 가르키는 것은 맞다.
  • JS에서 this는 함수 안에서만 의미를 가진다.
  • 하지만 this는 어떤 함수에 종속되는 값이 아니며, this가 어떤 값을 가지는지는 그 함수가 ’호출될 때’ 결정된다. (이게 중요한 개념이다!)

2. Call-site

콜스택(call-stack)은 많이 들어보았지만 콜사이트(call-site)는 생소한 사람들도 있을 것이다. call-site는 간단한 개념으로 해당 콜스택에 들어오기 전의 call을 한 곳을 말한다. 다음의 예를 보자

function A() {
    console.log("Hello, I am A");
    B();    // call-site for B() is A()
}

function B() {
    console.log("Hello, I am B");
    C();    // call-site for C() is B()
}

function C() {
    console.log("Hello, I am C");
}

A();    // what is call-site for A(); ?

함수 A는 B를, B는 C를 부른다. B가 호출될 때의 즉, B의 call-stack에 들어와 있을 때의 call-site는 A의 호출지점이 된다. 같은 식으로 C에 들어와 있을 때는 B가 call-site가 될 것이다. 어떤 함수의 this는 다른 조건에 해당하지 않는다면 (하지만 언제나 그렇듯이 이 조건이 매우 복잡해지는게 문제이다) 해당 함수가 호출될 때의 call-site의 값을 가진다. 이것을 기본 바인딩 (Default Binding)이라고 한다. 그렇다면 위에서 A가 호출될 때의 call-site는 무엇일까?

3. Default Binding

위의 질문에 답을 찾기 위해 다음의 코드를 실행해 보자.

function foo() {
    console.log(this.a);
}
foo();      // undefined

var a = 2;
foo();      // 2

처음 foo()함수를 실행시킬 때는 this.a 는 undefined였다. 전역 변수로 a = 2를 선언한 뒤에 다시 foo()를 실행하니 값이 2임을 알 수 있다. foo()는 전역 scope에서 실행시켰기 때문에 call-site는 전역객체가 되는 것이다. 이에 foo()가 실행될 때 this는 전역객체가 된다.

만일 strict mode에서 실행중이라면 this는 전역객체가 되지 않고 TypeError가 발생한다.

이와 같이 다른 조건이 없다면 어떤 함수가 ’호출될 때’ this는 해당 함수 호출의 call-site (함수라면 call-site의 this값이라고도 생각할 수 있다.)의 값으로 셋팅된다. 다시말해 this는 함수에 고정되어 있는 값이 아니라 함수가 호출될 때 마다 그때그때 바뀔 수 있는 값이다.

하지만 this가 결정되는 방법은 이 Default Binding말고도 3가지가 더 있어 다음과 같은 4가지 경우가 있다.

  • Default Binding
  • Implicit Binding
  • Explicit Binding
  • ‘new’ Binding

이 총 4가지의 우선 순위에 따라 최종적으로 this가 어떻게 결정될지가 정해진다.

4. Implicit Binding

그런데 call-site가 어떤 함수의 call 위치가 아니라 객체 자체가 될 수도 있다. 다음의 예를 보자.

function foo() {
    console.log(this.a);
}

var obj = {
    a:3, 
    foo: foo
}

obj.foo();          // 3

위에서 객체 obj는 함수 foo를 그 자신의 foo라는 이름의 프로퍼티로 가지고 있다. 이 때 obj.foo() 호출은 obj라는 객체를 call-site의 context로 가지게 된다. 이 경우 foo의 this는 해당 context가 되며 이를 암묵적 바인딩(Implicit Binding)이라고 한다.

하지만 매우 실수하기 쉬운 함정이 여기에는 하나가 있다. 역시 다음의 코드를 보자.

function foo() {
    console.log(this.a);
}

var obj = {
    a:3, 
    foo: foo
}

var bar = obj.foo;

bar();          // undefined???

위의 코드에서 bar()를 실행하면 기대와 달리 undefined가 나오는 것을 볼 수 있다. 자세히 보면 obj.foo라고 표시를 했지만 이 값은 그냥 함수 foo를 가리키는 것일 뿐이다. 따라서 다른 변수 bar에 obj.foo를 할당하는 것은 그냥 함수 foo의 또 다른 레퍼런스를 할당하는 것 이상도 이하도 아니다. 다시 말하지만 this가 결정되는 것은 해당 함수가 ’호출되는 순간’이다. obj.foo는 그냥 함수의 레퍼런스, obj.foo()는 obj를 컨텍스트로 하는 ‘함수의 호출’로 분명히 둘은 다르다. 이와 같은 실수는 실제로 복잡한 코드를 작성하다 보면 매우 쉽게 나올 수 있다. 이를 Implicit Lost라고도 한다.

Implicit Lost가 발생하는 대표적인 경우가 함수를 callback함수의 인자로 넘겼을 때다. 위의 obj.foo를 다음과 같이 setTimeout에 인자로 넘긴다고 생각해 보자.

setTimeout(obj.foo, 100)

콜백으로 넘긴 obj.foo가 실행될 때 this가 obj로 셋팅될거라 기대하기 쉽지만 콜백으로 넘긴 obj.foo는 그냥 foo함수의 레퍼런스일 뿐이다. 해당 콜백이 불릴때 this는 그냥 전역객체가 된다. 심지어 콜백함수를 받는 많은 함수들 중에는 자기가 맘대로 callback의 this를 강제로 셋팅하는 경우도 많기 때문에 항상 레퍼런스를 잘 체크해야 한다.

5. Explicit Binding

this를 사용할 때 생기는 문제의 대부분은 내가 전혀 기대하지 않은 값으로 this가 셋팅되는 경우에 발생한다. 그래서 아예 함수를 호출할 때 this를 명시적으로 정해주는 방법이 있다. 이는 함수 call()과 apply()를 통해 제공된다.

function foo() {
    console.log(this.a);
}

var obj = {
    a:3, 
}

foo.call(obj);      // 2

위의 예에서 사용하는 것을 보면 알 수 있듯이 call()과 apply()는 Function.prototype에 정의된 함수이다. 둘 다 인자로 받은 객체를 해당 함수의 호출에서 this로 셋팅한다.

call과 apply는 완전히 같은 함수로 인자를 전달하는 방식만 다르다. 둘 다 첫번째 인자로 this로 셋팅할 객체값을 전달하고, call은 두번째 이후 인자를 원래 함수의 인자로 순서대로 전달하고 apply는 원래 함수의 모든 인자를 하나의 배열에 넣어서 전달한다.

foo.call(obj, arg1, arg2, arg3);
foo.apply(obj, args); // args is an Array

하지만 call과 apply만으로는 해결 못하는 문제가 있다. 앞에서 보았던 setTimeout에 콜백으로 함수를 넘길때가 대표적인 예이다. 콜백으로는 함수의 레퍼런스를 넘길 수 있지만 함수의 호출(call과 apply는 함수의 호출이 필요하다.)을 넘길 수는 없다.

이에 아예 함수 자체에 특정 객체를 this로 bind해 버리는 방법이 필요해졌다. 이는 Function.prototype.bind()함수로 가능하다. 이를 Hard Binding이라고 한다. bind() 함수의 동작을 매우 간략하게 (실제로는 훨씬 복잡하지만) 표현하면 다음의 코드와 같다.

function bind(fn, obj) {
   return function() {
        return fn.apply(obj, arguments);
    };
}

bind()함수는 인자로 받은 객체를 call이나 apply를 이용하여 강제로 해당 call의 this로 할당한 ’함수를 리턴한다.’ JS에서 어떤 함수가 다른 ‘함수’를 리턴하는 코드를 많이 볼 수 있다. 이는 대부분 해당 함수의 context를 고정하거나 조작하기 위한 경우가 많다.

Hard Binding을 사용하면 앞에서와 같이 Implicit Lost가 발생하는 경우를 막을 수 있다.

function foo() {
    console.log(this.a);
}

var obj = {
    a:4, 
}

var bar = foo.bind(obj);

bar();  // 4

6. new Binding

이는 이전 글에서 말했던 바와 같이 생성자 함수로 new를 통해 새로운 객체를 만들때의 binding을 이야기 한다. 이 경우 해당 생성자의 call에서 this는 언제나 새로 new로 생성되는 객체로 설정된다. 다음의 코드에서 확인할 수 있다.

function foo(a) {
    this.a = a;
}

var bar = new foo(2);

bar.a;      // 2

7. Binding Order

실제로는 위의 모든 binding의 경우가 뒤섞일 수 있다. 이 경우에는 다음의 순서로 this가 결정된다.

  • 1) 모든 것에 우선해 new Binding이 이긴다. 만일 함수가 new로 새로운 객체를 생성했으면 해당 생성자의 call에서 this는 바로 생성된 객체이다.
  • 2) 그 다음 Explicit Binding이 이긴다. 어떤 함수가 call과 apply를 통해 실행되었으면 인자로 넘어간 객체가 this이다.
  • 3) 그 다음은 Implicit Binding이다. (대신 Implicit Lost가 발생하지는 않는지 주의해야 한다.)
  • 4) 위의 모든 경우가 아닐때 Default Binding이 적용된다.

다음의 예를 보면 확인할 수 있다.

function foo(a) {
    this.a = a;
}

var obj1 = {a: 2};

var bar = foo.bind(obj1);
var baz = new bar(3);

console.log(obj1.a);    // 2
console.log(baz.a);     //3

8. this in the Arrow Function

한가지 마지막으로 언급하지 않을 수 없는 것이 ES6에서 새로 도입된 Arrow Function이다. Arrow Function은 일종의 anonymous function을 선언하는 방법으로 생각할 수 있다. 인자를 ()안에 넣고 그 뒤에 => 를 쓴 다음 {}로 코드블럭을 쓸 수 있다. Arrow Function은 콜백함수를 선언할 때 편리하게 쓰일 수 있는데 일반적인 JS함수들과 달리 Arrow Function의 this는 Lexical scope, 즉 Arrow Function이 선언될 때의 scope를 따르기 때문이다. 다음의 예를 보자.

function foo() {
    return (a) => {
        console.log(this.a);
    }
}

var obj1 = { a:2 };
var obj2 = { a:3 };

var bar = foo.call(obj1);
bar.call(obj2);     // 2, not 3!

위에서 보다시피 Explicit Binding 시도(foo.call(obj1))에도 불구하고 bar는 Arrow Function이기 때문에 항상 Lexical scope로 binding됨을 알 수 있다. (foo가 call()함수를 통해 호출될 때의 call-site를 this로 가지고 있다.)

이는 콜백함수에서 매우 편리하다. 다음의 예는 ES6 이전에 callback에서 컨텍스트를 유지하기 위해 쓰던 방법이다.

function foo() {
    var self = this;        // capture this with self
    setTimeout(function() {
        console.log(self.a);
    }, 1000);
}
var obj1 = { a:2 };

foo.call(obj1);         // 2

별도의 변수 self를 선언해 현재의 this를 캡쳐한 후 이를 closure를 이용해 콜백 함수에서 사용하는 것을 볼 수 있다. 이는 Arrow Function을 쓰면 다음과 같이 간단히 쓸 수 있다.

function foo() {
    setTimeout(() => {
        console.log(this.a);        // this is lexically captured
    }, 1000);
}
var obj1 = { a:2 };

foo.call(obj1);         // 2

여기까지가 JS에서 this에 대한 이야기였다. 지금까지 이야기한 JS의 Object, Prototype, this 에 대해 이해를 확실히 해 두면 많은 부분에서 JS의 구조가 명확해 질 것이라고 생각한다. 간만에 기술적인 글을 쓰려니 힘들긴 하지만 나름 즐거운 경험이었다.