FrontEnd/HTML5 & CSS & JavaScript

[JavaScript] 4가지의 바인딩과 화살표 함수

푸고배 2022. 1. 4. 00:57

JavaScript에서의 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.count를 통해 foo 함수 객체에 count 프로퍼티는 0을 저장하고 있지만, 함수 내부에서 this가 참조하는 count는 foo 함수 객체가 아니다. foo 함수는 기본 바인딩이 되어, 내부에서 참조하는 this는 전역 객체이다. 실제로 전역 객체의 count에 접근해보면, NaN 값을 출력한다.

 

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

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

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

 

this는 함수의 렉시컬 스코프를 참조하지 않는다.

렉시컬 스코프(Lexical Scope) : 어디서 호출하는지가 아니라 어디에 선언하는지에 따라 상위 스코프가 결정되는 스코프

 

아래 예시를 참고하자.

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

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

foo();

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

 

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

 

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

 

this Binding

호출부(함수 호출 코드)

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

this 바인딩은 오직 호출부와 연관되기 때문에 호출 스택에서 호출부를 찾아내는 것이 중요하다. 코드가 길어지다 보면 호출부를 찾는 것이 쉽지 않으므로 크롬의 함수 호출 스택을 이용하는 것이 도움이 된다.

 

Binding 규칙

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

 

기본 바인딩

통상적으로 알고있는 함수 호출 방식이다.

나머지 규칙들이 해당되지 않을 경우에 적용되는 규칙이다.

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

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

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

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

아래 예시의 this는 어떤 컨텍스트와도 바인딩되지 않아 Reference Error가 발생한다.

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

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

 

암시적 바인딩

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

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

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

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

person.foo(); // 철수
function foo() {
    console.log(this.name);
}

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

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

위 예제와 같이, 호출부에서 person 객체가 foo 함수를 참조하고 있다. 따라서, foo 함수의 this는 person 객체로 바인딩되어 참조할 수 있다. 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(); // undefined

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); // undefined

위 예제에서, 아무리 암시적 바인딩이 적용된 콜백 함수를 인자로 넘긴다 해도 callback은 foo 함수 객체에 또 다른 레퍼런스일 뿐이다. 따라서 암시적 소실이 발생해 기본 바인딩이 적용된다. 콜백 함수를 전달해야 할 함수가 내장함수라도 결과는 동일하다.

 

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

 

명시적 바인딩(call, apply)

암시적 바인딩처럼 바인딩할 객체를 변형하는 작업없이, JavaScript에서 제공하는 내장함수인 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]

 

하드 바인딩(bind)

명시적 바인딩을 함수로 한 번 더 래핑하는 기법으로, 암시적 소실처럼 내장 함수가 내부적으로 명시적 바인딩을 이용해 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); // 23 3
console.log(b); // 26



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

 

API 호출 컨텍스트 인자

JavaScript 및 라이브러리에서 내장된 새로운 함수는 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 Binding

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

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

 

Binding 우선 순위

  1. new로 함수를 호출했는가? => 새로 생성된 객체가 this와 바인딩된다. (new 바인딩)
  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)과 같이 텅빈 객체를 전달하는 것이 안전하다.

 

간접 레퍼런스

간접 레퍼런스가 생성되는 경우 함수 호출 시 무조건 기본 바인딩이 적용된다.

 

화살표(Arrow) 함수

일반적인 함수는 4가지 바인딩 규칙을 모두 준수하지만, ES6부터는 규칙을 따르지 않는 화살표 함수가 등장했다.

화살표 함수는 Enclosing Scope(함수, 전역)로부터 어떠한 값이든 간에 this 바인딩을 상속한다.

쉽게 말하자면, 화살표 함수의 this는 항상 선언된 위치에서의 상위 환경과 동일한 this를 가리킨다.

 

렉시컬(lexical) 바인딩 : 화살표 함수를 정의한 시점의 코드 문맥에서 상위와 동일한 this를 바인딩한다.

화살표 함수의 렉시컬 바인딩은 절대로 오버라이드 할 수 없다.

 

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

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

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

foo.call(bar); // 철수

 

하지만 아래 예시와 같이 잘못된 사용으로 혼락을 불러올 수도 있다.

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

foo.bar(); // undefined

bar 프로퍼티의 화살표 함수의 this는 전역 객체를 가리키고 있기 때문에 undefined가 출력된다.

 


Reference

 

This Binding

이 글은 You Don't Know JS 서적을 참고하여 작성하였습니다.Javascript 에서의 this는 다른 언어와 다르게 헷갈리는 구석이 있다.그럼에도 불구하고 this를 사용하는 이유는 무엇일까?물론 함수에 파라미

velog.io

 

반응형