This Binding

raccoonback·2020년 6월 18일
1

javascript

목록 보기
4/11
post-thumbnail

이 글은 You Don't Know JS 서적을 참고하여 작성하였습니다.

왜 this를 사용할까

Javascript 에서의 this는 다른 언어와 다르게 헷갈리는 구석이 있다.

그럼에도 불구하고 this를 사용하는 이유는 무엇일까?

물론 함수에 파라미터를 통해 this를 어느 정도 대체할 수 있지만, API 설계상 this 를 사용하는 것이 명확하고 재사용하기 좋다.

아래의 예시를 보자.

function foo() {
    return this.title.toUpperCase();
}

function bar() {
    var content = "Hellworld " + foo.call(this);
    console.log(content);
}

var me = {
    title: "철수"
};

var you = {
    title: "미애"
};

foo.call(me);
foo.call(you);

bar.call(me);
bar.call(you);

여기서 처음 접하는 독자는 헷갈릴 수 있다. foo 함수에서 call() 함수를 호출하는게 가능한 것인가?

Javascript에서의 함수는 모두 객체([[Function]])이기 때문에, 내장된 프로퍼티, 메서드를 가질 수 있는 것이다.

다시 돌아와서 뒷쪽에서 자세하게 설명하겠지만, call() 함수를 이용해 me, your 객체를 명시적으로 this 바인딩하였다.

foo() 함수는 파라미터를 통해서 객체를 전달받을 수도 있지만, 함수 호출자에서 명시적으로 this 설정함으로써, 코드가 명확하고 재사용성하기 쉬어진다.

this 에 대한 오해

다른 언어에서 사용했던 this 방식은 Javascript에서 this를 헷갈리게 하며 오해를 일으킨다.

this가 자기 자신을 가리킨다?

Javascript에서 this는 자기 자신을 가리키지 않는다.

Javascript에서는 함수가 객체이므로 특정 상태값을 유지/접근하기 위해 함수 내부에서 this를 사용할 것이다.

이러한 방법도 가능하지만, this를 이해하지 못하고 사용하면 잘못된 결과를 얻을 수 있다.

아래 예시를 들어보자.

function foo(num) {
    console.log("foo: " + num);
    this.count++;
}

foo.count = 0;

for(let i = 0; i < 10; i++) {
    if(i < 5) {
        foo(i);
    }
}

console.log(foo.count);
console.log(count);
// foo: 0
// foo: 1
// foo: 2
// foo: 3
// foo: 4
// 0
// NaN

foo 함수 객체에 count 프로퍼티를 저장하고, 호출할 때마다 카운티을 하는 코드이다.

foo 함수가 5번 호출이 되었음에도, foo 함수 객체의 count 프로퍼티는 0 을 가지며 전혀 카운팅되지 않았다.

왜 이런 결과가 나왔을까?

foo.count 를 통해 foo 함수객체에 count 프로퍼티는 0을 저장하고 있지만, 함수 내부에서 this가 참조하는 count는 foo 함수객체가 아니기 때문이다.

foo 함수는 기본 바인딩이 되어, 내부에서 참조하는 this는 전역 객체이다. 실제로 전역 객체의 count에 접근해보면, NaN 값을 출력한다.

다른 방법으로 this가 아닌 foo 객체에 직접 접근할 수 있는 방법도 있지만, 만약 다음과 같이 익명 함수라면 this없이 함수객체에 접근할 방법이 없을 것이다.

function foo() {
    foo.count++; // foo 객체에 직접 접근
}

setTimout(function() {
	// 익명 함수에서 자신을 가리킬 방법이 없다.
}, 1000);

자신의 스코프를 가진다?

this가 함수의 스코프를 가리킨다는 말은 잘못된 오해이다. 즉, this는 분명하게 함수의 렉시컬 스코프를 참조하지 않는다.

function foo() {
   var a = 1;
   bar();
}

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

foo();

this가 렉시컬 스코프를 참조한다면, 해당 코드에서는 정상적으로 1이 출력됐어야 한다. 하지만, this는 렉시컬 스코프를 가리키지 않고 있을 뿐만 아니라, 일반적인 자바스크립트 코드에서는 스코프 객체에 접근할 수 없다. 따라서 bar() 함수에서 foo() 함수 스코프에 접근할 수 없기 때문에, undefined 가 출력된다.

그래서 this는 무엇인가?

위 두 주제로 this에 대한 생각이 변했을 것이다.

요약해보면, this는 함수 자신이나 함수의 렉시컬 스코프를 가리키는 레퍼런스가 아니다!

그럼 this는 무엇일까?

this는 작성 시점이 아닌 런타임 시점에 바인딩되며 함수 호출 당시 상황에 따라 참조하는 컨텍스트가 결정된다. 즉, 함수 선언 위치와는 무관하게 this 바인딩은 오로지 어떻게 함수를 호출했느냐에 따라 정해진다.

this 바인딩 개념

호출부

this 바인딩의 개념을 이해하려면 먼저 호출부, 즉 함수 호출 코드부터 확인하고 this가 무엇을 참조하는지 생각해야 한다.

this 바인딩은 오직 호출부와 연관되기 때문에 호출 스택에서 호출부를 찾아내는 것이 중요하다. 하지만, 코드양이 방대해지다 보면 호출부를 찾는 것은 쉬운 일이 아니다. Chrome 과 같은 브라우저를 이용해서 호출 스택을 살펴보면 도움이 된다.

function foo() {
    debugger;
    console.log('foo');
    bar();
}

function bar() {
    debugger;
    console.log('bar');
    baz();
}

function baz() {
    debugger;
    console.log('baz');
}

foo();

아래 그림과 같이, 크롬을 통해서 함수 호출 스택을 통해 각 함수 호출부를 쉽게 찾을 수 있다.

Binding 규칙

이제 this 바인딩의 4가지 규칙을 살펴보고, 호출부와 어떤 연관이 있는지 알아보자.

this는 호출부에서 런타임 시점에 동적으로 바인딩된다.

기본 바인딩

기본 바인딩은 통상적으로 알고있는 함수 호출 방식이다.

기본 바인딩은 나머지 규칙들이 해당되지 않을 경우에 적용되는 규칙이다.

function foo() {
    console.log(this.a); // this는 전역 객체 참조
}

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

foo() 함수 호출하면 기본 바인딩이 적용되어 this는 전역 객체에 바인딩된다.

여기서 strict mode 를 사용하면 전역 객체는 기본 바인딩 대상에서 제외된다.

function foo() {
		'use strict'
    console.log(this.a); // this는 undefined
}

var a = 123;
foo(); 
// TypeError: Cannot read property 'a' of undefined

결과적으로 this는 어떤 컨텍스트와도 바인딩되지 않아 Reference Error 가 발생한다.

즉, 아래와 같이 구분할 수 있을 것이다.

  • strict mode : this 는 undefined
  • non strict mode : this가 전역 객체로 바인딩된다.(기본 바인딩의 대상은 전역 객체가 유일)
function foo() {
    'use strict';
    bar();
}

function bar() {
    console.log(this.a); // this는 전역 객체 참조
}

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

bar() 함수는 기본 바인딩이 적용되어, this는 전역 객체를 참조한다.

암시적 바인딩

암시적 바인딩은 호출부에 컨텍스트 객체 여부에 따라 결정되고, 해당 컨텍스트 객체가 this에 바인딩된다.

즉, 특정 컨텍스트 객체를 통해 함수를 호출하는 경우 암시적 바인딩이 적용되고 this는 해당 컨텍스트 객체를 참조한다.

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

var person = {
    name: '철수',
    foo: foo
};

person.foo();
// 철수

위 예제에서와 같이, 호출부에서 person 객체가 foo() 함수를 참조하고 있다. 따라서, foo() 함수의 this는 person 객체로 바인딩되어 참조할 수 있다. person 객체에 foo 프로퍼티가 어떤 방식을 할당되었는 지는 중요하지 않다.

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

var person = {
    name: '철수'
};

person.foo = foo;
person.foo();
// 철수

만약 객체 프로퍼티 참조가 체이닝된 형태라면 어떻게 될까?

최상위 수준, 즉 가장 마지막(최하위)으로 참조한 객체가 this로 바인딩된다.

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

var name = {
    text: '미애',
    foo: foo
};

var person = {
    text: '철수',
    name: name
};

person.name.foo(); // 마지막으로 참조한 name 객체가 foo() 함수에 바인딩된다.
// 미애

암시적 소실

암시적으로 바인딩된 함수에서 바인딩이 소실되는 경우가 있다.

strict mode 여부에 따라서 전역 객체또는 undefined가 바인딩된다.

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

var person = {
    text: '철수',
    foo: foo
};

var bar = person.foo;
var name = '미애';
bar();
// 미애

bar는 person 객체의 foo 프로퍼티와 동일한 참조를 하는 것처럼 보인다.

하지만 bar는 foo를 가리키는 또 다른 레퍼런스이기 때문에 bar() 함수는 기본 바인딩이 적용된다.

이와 유사하게, 콜백 함수를 전달하는 경우에도 암시적 소실이 많이 발생한다.

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

function bar(callback) {
    callback();
}

var person = {
    text: '철수',
    foo: foo
};

var name = '미애';
bar(person.foo);
// 미애

위 예제에서, 아무리 암시적 바인딩이 적용된 콜백 함수를 인자로 넘긴다 해도 callback은 foo 함수객체에 대한 또 다른 레퍼런스일 뿐이다. 따라서 암시적 소실이 발생해 기본 바인딩이 적용된다.

그럼 콜백 함수를 전달해야 할 함수가 내장함수라면 어떻게 될까?

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

var person = {
    text: '철수',
    foo: foo
};

var name = '미애';
setTimeout(person.foo, 1000);
// 미애

결과는 동일하다.

이와 같이, 콜백 함수를 전달하는 과정에서 this의 행방을 알 수 없어지는 경우가 발생한다. 이를 해결하기 위해서는 명시적 바인딩을 이용해 this를 고정하는 수 밖에 없다.

명시적 바인딩

명시적 바인딩은 암시적 바인딩처럼 바인딩할 객체를 변형하는 작업없이, 자바스크립트에서 제공하는 내장함수인 call()apply() 를 이용하면 된다.

call()apply() 메서드는 첫 번째로 this 바인딩할 객체를 직접 전달받는다.

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

var person = {
    name: '철수'
};

foo.call(person);
foo.apply(person);
// 철수
// 철수

call()apply() 는 명시적으로 바인딩해 함수를 호출하므로, this는 반드시 person 객체를 참조한다.

  • call() 함수는 첫 번째 인자로 바인딩할 객체를 전달받고, 두 번째 인자부터는 기존 함수에 전달할 인자를 전달받는다.
  • apply() 함수는 첫 번째 인자로 바인딩할 객체를 전달받고, 두 번째 인자에 기존 함수에 전달할 인자를 배열로 전달받는다.

만약 첫 번째 인자로 객체가 아닌 primitive value(ex. 숫자)를 전달하면 래퍼 객체로 박싱되어 전달된다.

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

foo.call(1);
// [Number: 1]

만약 암시적 소실처럼 내장 함수가 내부적으로 명시적 바인딩 을 이용해 this를 덮어쓰면 어떻게 될까?

이러한 상황을 방지하기 위해 하드 바인딩을 할 수 있다.

하드 바인딩

하드 바인딩명시적 바인딩 을 함수로 한 번 더 래핑하는 기법으로 다시 명시적 바인딩 한다해도 this 참조가 변경되지 않게 하는 기법이다.

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

var person = {
    name: '철수'
};

// 하드 바인딩
var bar = function () {
    foo.call(person);
};

bar(); // 철수
setTimeout(bar, 1000); // 철수
bar.call(window); // 철수, foo에는 명시적 바인딩이 적용되지 않는다.

bar() 함수는 foo() 함수를 person 객체로 강제 바인딩함으로써, bar() 함수가 어떤식으로 호출됐던 간에 foo() 함수는 반드시 person 객체에 바인딩해 실행된다.

따라서, 하드 바인딩된 함수를 사용해 다른 함수에서 this가 변경되는 일을 미연에 방지할 수 있다.

ES5에서는 하드 바인딩을 위한 내장 함수로 bind() 함수를 제공한다.

bind()은 첫 번째 인자로 전달받은 객체를 this로 하드 바인딩하고, 원본 함수를 호출하는 하드 바인딩된 새 래퍼 함수를 반환한다. 새 래퍼 함수는 모든 this 바인딩을 무시한다.

function foo(something) {
    console.log(this.age, something);
    return this.age + something;
}

// 하드 바인딩한 커스텀 bind() 함수
function bind(fn, obj) {
    return function() {
        return fn.apply(obj, arguments);
    }
}

var person = {
    age: 23
};

const bar = bind(foo, person);
const b = bar(3);
console.log(b); 
// 23 3
// 26

// foo 내장함수 이용
const baz = foo.bind(person);
const ba = baz(4);
console.log(ba);
// 23 4
// 27

API 호출 컨텍스트 인자

자바스크립트 및 라이브러리에서 내장된 새로운 함수는 bind() 를 이용해 콜백 함수의 this를 지정할 수 없는 경우를 대비하여 Context 라는 선택적 인자를 제공한다.

아래 예시와 같이, forEach 함수의 두 번째인 선택적 인자로 person 객체 전달하여, foo 콜백함수의 this가 person 객체로 바인딩된 것을 확인할 수 있다.

function foo(something) {
    console.log(this.age, something);
    return this.age + something;
}

var person = {
    age: 23
};

[1, 2, 3].forEach(foo, person); 
// 23 1
// 23 2
// 23 3

new 바인딩

new 바인딩을 설명하기 앞서, Javascript에서의 new 키워드에 대해 설명하겠다.

자바스크립트에서 new 키워드는 클래스 지향 언어와 다르다. 또한 자바스크립트 생성자는 앞에 new 키워드가 있을 때 호출되는 일반 함수에 불과하다. 일반 함수 앞에 new 키워드가 놓이면, 생성자로서 새로 만들어진 객체를 초기화하는 역할을 한다.

new 키워드가 일반 함수 앞에 놓이면 아래와 같은 과정이 일어난다.

  1. 새로운 객체 생성
  2. 새로 생성된 객체의 [[Prototype]] 이 연결된다.
  3. 새로 생성된 객체는 해당 함수 호출시 this로 바인딩된다.
  4. 함수가 자신의 또 다른 객체를 반환하지 않는 한, new와 함께 호출된 함수는 자동으로 새로 생성된 객체를 반환한다.

결론적으로 중요한 점은 "new 는 함수 호출시 새로운 생성된 객체와 this를 바인딩한다는 것"이다. 이를 new 바인딩 이라 부른다.

function foo() {
   this.name = '철수';
}

const bar = new foo();
console.log(bar.name);
// 철수

위 예시에서는 new 키워드를 붙여서 foo() 함수를 호출했고, 새로 생성된 객체는 this와 바인딩된 것을 확인할 수 있다.

Binding 우선 순위

앞서 이제 4가지 규칙에 대해 살펴보았다.

그럼 각 Binding의 우선 순위는 어떻게 될까?

우선, 기본 바인딩은 가장 마지막 순위라는 것은 알고 있다.

다음으로, 암시적 바인딩명시적 바인딩 중 어느 것이 우선 순위가 높을까?

아래 예시를 통해 알아보자.

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

const bar = {
    name: '철수'
};

bar.foo = foo;

const baz = {
    name: '미애'
};

baz.foo = foo;

bar.foo();  // 철수
baz.foo();  // 미애

bar.foo.apply(baz); // 미애
baz.foo.apply(bar); // 철수

bar 객체의 foo 메서드를 암시적 바인딩명시적 바인딩 을 적용해 본 결과, 명시적 바인딩이 우선순위가 높은 것을 확인할 수 있다.

그럼 마지막으로 new 바인딩명시적 바인딩 중 어느 것이 우선순위가 높을까?

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

const bar = {
   foo: foo
};

const baz = {};

// 암시적 바인딩
bar.foo('철수');
console.log(bar.name);  // 철수

// 명시적 바인딩
bar.foo.call(baz, '미애');
console.log(baz.name);  // 미애

// 암시적 바인딩 vs new 바인딩
var bag = new bar.foo('짱구');
console.log(bar.name);  // 철수
console.log(bag.name);  // 짱구

위 예시를 통해, new 바인딩암시적 바인딩보다 우선순위가 높은 것은 확인할 수 있다.

하지만, new 바인딩명시적 바인딩을 직접 비교할 수가 없지만, 하드 바인딩을 이용하면 테스트해 볼 수 있다.

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

const bar = {};

// 하드 바인딩
var baz = foo.bind(bar);
baz('철수');
console.log(bar.name);

// 하드 바인딩 vs new 바인딩
// 하드 바인딩된 bar를 new 키워드로 호출
var bag = new baz('미애');
console.log(bar.name);  // 변경없이, '철수' 출력
console.log(bag.name);  // 예상과 다르게 '미애' 출력, new 키워드가 하드 바인딩보다 우선순위가 높다.

아까 설명한 하드 바인딩된 새 래퍼 함수는 모든 this 바인딩을 무시한다고 했으므로 new 바인딩보다 우선순위가 높을 것으로 예상이 됐지만, 결과적으로 new 바인딩하드 바인딩보다 우선 순위가 높은 것을 확인할 수 있다.

bind()은 new 키워드로 호출되었는 지를 내부적으로 검사하고, 하드 바인딩에 의한 this는 버리고 새로 생성된 객체의 this를 사용한다.

근데, 굳이 하드 바인딩된 this를 버리고 새로 this를 교체하는 것일까?

이유는 정확하게 모르지만, bind() 함수를 전달된 인자를 원본 함수의 기본 인자로 고정하는 역할로 사용하여, 함수 인자를 미리 세팅해야 할 때 유용하다.

function foo(a, b) {
   this.value = a + b;
}

// foo 함수의 a 인자를 1로 미리 고정
const bar = foo.bind(null, 1);
// 두 번째 인자로 2 전달
const baz = new bar(2);
console.log(baz.value);
// 3

this 확정 규칙

이제 우선 순위를 정리해보면 아래와 같다.

  1. new로 함수 호출했는가? ⇒ 새로 생성된 객체가 this와 바인딩된다.
  2. call, apply로 함수를 호출하거나 bind 함수로 호출했는가? ⇒ 명시적으로 지정된 객체가 this와 바인딩된다.
  3. 특정 컨텍스트 객체를 통해 함수를 호출했는가? ⇒ 해당 컨텍스트 객체와 this 바인딩한다.
  4. 그 외의 경우 this는 기본값으로 설정된다.

Binding 예외

이번에는 예상한 특정 바인딩의 의도와 다르게 기본 바인딩이 적용되는 사례를 살펴보자.

this 무시

call(), apply(), bind() 메서드의 첫 번째 인자는 바인딩할 객체를 입력받는데, 만약에 null 또는 undefined을 입력하게 되면 명시적 바인딩이 무시되고 기본 바인딩이 이루어진다.

function foo(val) {
   this.value = val;
}

var value = '미애';
console.log(value); // 미애

foo.call(null, '철수');
console.log(value); // 철수

이러한 null 을 인자로 넣는 경우는 주로 apply() 함수와 같이 다수의 인자를 배열로 전달할 때 사용하는 데, this 바인딩을 딱히 고려하지 않는 경우 null 을 인자를 전달한다.

하지만 추후 해당 함수가 this를 참조하게 되면 문제가 될 수 있으므로, Object.create(null) 과 같이 텅빈 객체를 전달하는 것인 안전하다.

간접 레퍼런스

암시적 바인딩에서도 잠깐 설명했지만, 간접 레퍼런스가 생성되는 경우 함수 호출시 무조건 기본 바인딩이 적용된다.

function foo() {
    console.log(this.name)
}

const name = '철수';
const bar = {
    foo: foo
};
const baz = {
    name: '미애'
};

baz.foo = bar.foo;
(baz.foo = bar.foo)();

baz.foo = [bar.foo](http://bar.foo) 결과는 foo 함수객체에 대한 간접 레퍼런스이므로, 즉시 실행 함수는 기본 바인딩이 적용된다.

Arrow 함수

일반적인 함수는 앞에서 말한 4가지 규칙을 모두 준수한다.

하지만, ES6부터는 규칙을 따르지 않는 Arrow 함수가 등장하였다.

그럼 Arrow 함수는 this 바인딩을 어떻게 처리할까?

Arrow 함수는 Enclosing Scope(함수, 전역)로 부터 어떠한 값이든 간에 this 바인딩을 상속한다.
쉽게 말하자면, Arrow 함수의 this는 렉시컬 스코프와 비슷하게 항상 선언된 위치에서의 상위 환경과 동일한 this를 가리킨다.

lexical 바인딩 : Arrow 함수를 정의한 시점의 코드 문맥에서 상위와 동일한 this를 바인딩한다.

function foo() {
    return (name) => {
        // this는 foo 함수에서 상속된다.
        console.log(this.name);
    };
}

const bar = {
    name: '철수'
};

const baz = {
    name: '미애'
};

const foos = foo.call(bar);

// foos는 명시적으로 바인딩하고자한 baz 객체가 아닌,
// foo 가 바인딩한 bar 객체를 상속받는다.
foos.call(baz); // 철수

foo 함수 내부에서 생성된 Arrow 함수는 foo() 함수 호출 당시의 this를 무조건 lexical 바인딩한다.

즉, foo() 함수가 bar 객체로 바인딩됐기 때문에 foos의 this 역시 bar로 바인딩된다.

Arrow 함수의 lexical 바인딩은 절대로 오버라이드할 수 없다.

Arrow 함수는 this를 확실하게 보장하기 때문에 콜백 함수로 많이 사용된다.

function foo() {
    setTimeout(() => {
        // this는 foo() 함수로부터 lexical 바인딩된다.
        console.log(this.name);
    }, 1000);
}

const bar = {
    name: '철수'
};

foo.call(bar); // 철수

Arrow 함수이 콜백 함수로 사용하기 편리하지만, 잘못된 사용으로 혼란을 불러올 수 도 있다.

아래 예시를 통해 살펴보자.

// Bad
const foo = {
    name: '철수',
    bar: () => console.log(this.name)
};

foo.bar(); // undefined

예상해보면, this가 foo 객체를 참조하고 있어서 '철수'가 출력될 것 같지만, undefined가 출력된다.
그 이유는 bar 프로퍼티의 Arrow 함수의 this는 foo 객체가 아닌 전역 객체를 가리키고 있기 때문이다.

Arrow 함수는 this를 확실하게 보장하는 수단이지만, 그로 인한 Side Effect가 많이 발생한다.

Arrow 함수가 선언된 위치에서, 상위 환경의 this를 생각해보자!

profile
한번도 실수하지 않은 사람은, 한번도 새로운 것을 시도하지 않은 사람이다.

0개의 댓글