함수 호출 방식에 따른 this 바인딩 방식

커버 이미지

그동안 최신 React나 Vue 문법으로 페이지를 구축할 때는 this 키워드를 사용할 일이 많지 않았는데, 오래전에 구축된 웹사이트를 유지보수하면서 this를 자주 사용하게 되었습니다. 다만 특정 상황에서 this가 예상과 다르게 동작해 어려움을 겪었고, 이를 계기로 this를 제대로 정리할 필요가 있다고 생각했습니다.

this 키워드의 동작 방식을 정리합니다.

JavaScript에서 this 키워드란?

this는 생성자 혹은 메소드(멤버 함수)에서 객체를 가리킬 때 사용하는 키워드입니다. 새로 생성된 객체에 생성자의 속성을 넣거나 객체의 속성에 접근할 때 주로 사용합니다. JavaScript의 this는 Java와 달리 함수 호출 방식에 따라 바인딩되는 객체가 달라집니다.

함수 호출 방식

JavaScript의 함수 호출 방식은 함수 호출, 메소드 호출, 생성자 호출, apply/call/bind를 통한 호출 4가지로 분류됩니다.

const showThis = function () {
  console.log(this);
};
 
// 1. 함수 호출
showThis(); // window
 
// 2. 메소드 호출
const obj = {
  name: "test-obj",
  func: showThis,
};
obj.func(); // { "name": "test-obj", fuuc: f }
 
// 3. 생성자 함수 호출
const instance = new showThis(); // instance
 
// 4. apply, call, bind 호출
const bar = { name: "bar" };
showThis.apply(bar); // { "name": "bar" }
showThis.call(bar); // { "name": "bar" }
showThis.bind(bar)(); // { "name": "bar" }

1. 함수 호출

기본적인 함수 호출 방식을 말하며, 이때 this는 전역 객체(Global Object)에 바인딩됩니다. 전역 객체는 최상위 객체를 의미하며, 일반적으로 브라우저 환경에서는 window, 서버 환경(Node.js)에서는 global을 뜻합니다.

1) 전역 함수의 this는 전역 객체를 가리킵니다.

// 전역 함수를 선언
function showThis() {
  console.log(this);
}
 
this.showThis(); // window

2) 전역 함수 뿐만 아니라 내부 함수의 this는 전역 객체를 가리킵니다.

// 전역 함수 뿐만 아니라 내부 함수도 this는 window 객체를 가리킵니다.
function showOuter() {
  console.log("outer's this: ", this); // window
  function showInner() {
    console.log("inner's this: ", this); // window
  }
  showInner();
}
showOuter();

3) 메소드의 내부 함수의 this는 전역 객체를 가리킵니다.

// 바깥에서 var로 선언한 변수는 window 객체에 바인딩됨
var value = 1;
 
const obj = {
  value: 50,
  showOuter: function () {
    console.log("show's this: ", this); // obj
    console.log("show's this.value: ", this.value); // 50
 
    function showInner() {
      console.log("showInner's this: ", this); // window
      console.log("showInner's this.value: ", this.value); // 1
    }
    showInner();
  },
};
 
obj.showOuter();

4) 일반적인 콜백 함수의 경우 this는 전역 객체를 가리킵니다.

var value = 1;
 
const obj = {
  value: 50,
  show: function () {
    setTimeout(function () {
      console.log("callback's this: ", this); // window
      console.log("callback's this.value: ", this.value); // 1
    }, 500);
  },
};
 
obj.show();

5) 이벤트 핸들러의 this는 이벤트를 받는 HTML 요소를 가리킵니다.

<button id="btn">click</button>;
 
const btn = document.getElementById("btn");
 
btn.addEventListener("click", function () {
  console.log(this); // btn
});

※ this가 전역객체를 참조하는 것을 회피하는 방법

ES6에 추가된 화살표 함수를 사용하거나, 메소드의 this가 객체를 가리키는 점을 이용해 this를 다른 변수에 할당한 뒤 참조하면 됩니다.

// 바깥에서 var로 선언한 변수는 window 객체에 바인딩됨
var value = 1;
 
const obj = {
  value: 50,
  showOuter: function () {
    const innerThis = this; // 멤버 함수가 가리키는 this(obj)를 innerThis변수에 할당
 
    function showInner1() {
      console.log("showInner1's this: ", this); // window
      console.log("showInner1's this.value: ", this.value); // 1
    }
 
    // innerThis가 가리키는 변수(obj)를 참조한다.
    function showInner2() {
      console.log("showInner2's this: ", innerThis); // obj
      console.log("showInner2's this.value: ", innerThis.value); // 50
    }
 
    // 화살표 함수의 경우 상위 스코프의 this(이 경우에는 obj)를 가리킵니다.
    const showInner3 = () => {
      console.log("showInner3's this: ", this); // obj
      console.log("showInner3's this.value: ", this.value); // 50
    };
 
    showInner1();
    showInner2();
    showInner3();
  },
};
 
obj.showOuter();

이외에도 후술할 apply, call, bind 메소드를 사용하면 this를 명시적으로 바인딩할 수 있다.

2. 메소드 호출

메소드(method)는 객체의 멤버 함수를 의미합니다. 메소드가 호출될 때 메소드 내부의 this는 해당 메소드를 호출한 객체에 바인딩됩니다.

1) 메소드 내부의 this는 메소드를 호출한 객체를 가리킵니다.

const obj1 = {
  name: "Kim",
  showName: function () {
    console.log(this.name);
  },
};
 
const obj2 = {
  name: "Park",
};
 
obj2.showName = obj1.showName;
 
obj1.showName(); // Kim
obj2.showName(); // Park

2) 프로토타입 객체 메소드 내부의 this도 해당 메소드를 호출한 객체를 가리킵니다.

function Person(name) {
  this.name = name;
  console.log(this);
}
 
Person.prototype.getName = function () {
  return this.name;
};
 
const me = new Person("Kim");
console.log(me.getName()); // Kim
 
Person.prototype.name = "Park";
console.log(Person.prototype.getName()); // Park

3. 생성자 함수 호출

생성자 함수의 경우 this 또한 호출한 객체를 가리킵니다. 다만 기존 함수와는 다르게 바인딩 방식이 조금 더 복잡합니다. 아래 코드는 Person 함수를 생성자 함수로 호출하는 예제이며, 해당 코드를 기준으로 생성자 함수를 정리합니다.

※ 생성자 함수란?

생성자 함수는 객체를 생성하는 역할을 하는 함수입니다. 다른 객체 지향 언어와 달리 JavaScript의 생성자 함수는 따로 형식이 정해져 있지 않으며, 기존 함수에 new 연산자를 붙여 호출하면 해당 함수가 생성자 함수로 동작합니다.

따라서 생성자 함수는 일반적으로 첫 문자를 대문자로 작성하여 일반 함수와 구분합니다.

function Person(nm) {
  this.name = nm;
  console.log(this);
  // 암묵적으로 this에 바인딩 된 객체를 반환한다.
}
 
// 생성자 함수이므로 {name : 'Kim'} 객체가 반환됨
const person1 = new Person("Kim"); // { name: 'Kim' }
console.log(person1); // { name: 'Kim' }
 
// 생성자 함수가 아니므로 아무 값도 반환되지 않음.
const person2 = Person("Park"); // window
console.log(person2); // undefined

Person 생성자 함수의 동작은 다음과 같습니다.

  1. Person 함수가 new 키워드로 호출되어 생성자 함수로 동작을 시작하면, 먼저 빈 객체가 생성되고 이 객체가 this에 바인딩됩니다.
  2. 파라미터로 받은 nm을 빈 객체의 name 변수로 할당합니다.
  3. 생성자 종료 시점에서 이 객체를 암묵적으로 반환하며, 이후 메소드에서 호출하는 this는 이 객체를 가리킵니다.

이러한 과정 때문에 생성자 함수에서는 명시적으로 반환문을 사용하지 않는 것이 좋고, this 이외의 다른 값을 반환하면 생성자 함수의 의도와 다르게 동작할 수 있습니다.

4. apply, call, bind 호출

맨 처음 함수 호출 방식에 따라 this에 바인딩되는 객체가 달라진다고 언급했는데, 이러한 암묵적인 규칙 외에도 this를 특정 객체에 명시적으로 바인딩하는 방법이 있습니다. 모든 함수 객체의 프로토타입 객체인 Function.prototype 객체의 메소드인 apply, call, bind 함수를 사용하는 방법이다.

// func : 호출될 함수를 나타내는 함수 객체
// thisArg : this 값으로 설정할 객체
 
// Function.prototype.apply
// [...args] : : 함수에 전달할 파라미터들로 구성된 배열 또는 유사 배열 객체
func.apply(thisArg, [...args]);
 
// Function.prototype.call
// args1, args2, arg3, ... : 함수에 전달할 파라미터들
func.call(thisArg, args1, args2, arg3, ...);
 
// Function.prototype.bind
// args1, args2, arg3, ... : 함수에 전달할 파라미터들
func.bind(thisArg, args1, args2, arg3, ...); // 즉시 호출되지 않음

applycall은 첫 번째 인자를 제외한 나머지 인자를 배열로 받는지 여부 정도만 차이가 있을 뿐, 동작은 유사합니다. bindcall과 인자 형태가 동일하지만, 함수를 즉시 실행하지 않고 바인딩된 새 함수를 반환한다는 차이가 있습니다.

1) apply, call 예제

아래는 생성자 함수로 Person 객체를 각각 생성한 후 person1.showName 내부의 thisapply, call을 이용해 각각 person2, person3 객체에 바인딩하는 예제입니다.

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.showName = function () {
    console.log(`My name is ${this.name}, and I am ${this.age} years old.`);
  };
}
 
const person1 = new Person("Kim", 20);
const person2 = new Person("Park", 30);
const person3 = new Person("Choi", 25);
 
person1.showName(); // My name is Kim, and I am 20 years old.
person1.showName.apply(person2); // My name is Park, and I am 30 years old.
person1.showName.call(person3); // My name is Choi, and I am 25 years old.
person1.showName(); // My name is Kim, and I am 20 years old.

콘솔을 확인해보면 person1.showName 내부의 this가 영구적으로 바인딩된 것이 아니라, apply/call을 호출한 시점에만 person2, person3로 바인딩된 것을 확인할 수 있습니다.

2) bind 예제

만약 this를 바인딩한 뒤 즉시 호출하는 것이 아니라 나중에 사용해야 한다면 bind를 사용하시면 됩니다. 아래는 bindperson1.showName 내부의 thisperson2 객체로 바인딩하는 예제입니다.

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.showName = function () {
    console.log(`My name is ${this.name}, and I am ${this.age} years old.`);
  };
}
 
const person1 = new Person("Kim", 20);
const person2 = new Person("Park", 30);
const bindFunc = person1.showName.bind(person2);
 
person1.showName(); // My name is Kim, and I am 20 years old.
bindFunc(); // My name is Park, and I am 30 years old.

이처럼 bind는 바인딩과 실행 시점을 분리할 수 있습니다.

참고자료