Programming/javascript

자바스크립트 기초부터 모던 자바스크립트까지 - this편

KOOCCI 2022. 8. 3. 01:49

목표 : 자바스크립트의 this를 사용할 수 있다.


우리가 객체를 공부할 때 크게 2가지로 나누어진다는 것을 알았다.

  • 프로퍼티 : 객체의 상태를 나타내는 값(Data)
  • 메서드 : 프로퍼티(상태 데이터)를 참조하고 조작할 수 있는 동작(Behavior)

위 두가지를 하나의 논리적인 단위로 묶은 복합적인 자료구조객체이다.

 

이 때, 메서드에서는 자신이 속한 객체의 상태(프로퍼티)를 알 수 있어야 한다.

그 말은, 자신이 속한 곳이 어디인지 지칭할 수 있어야 한다는 말이며, 자신이 속한 객체를 가리키는 식별자를 참조할 수 있어야 한다는 말로 귀결된다.

 

그 역할을 this 가 하고 있다.

 

this 키워드

this자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 자기 참조 변수다.

주의해야 할 점은 this는 상황에 따라서 지칭하는 것이 달라진다.

다음 예시를 보자.

// 전역에서 this는 Window를 가리킨다.
console.log(this); // Window

// 일반 함수에서 this는 Window를 가리킨다. (전역과 동일)
function square(number) {
  console.log(this); // Window
  return number * number;
};
square(2);

// 객체 리터럴
const person = {
  name: 'Lee',
  getName() {
    // 메서드 내부에서 this는 메서드를 호출할 때 사용된 객체를 가리킨다.
    console.log(this); // {name: "Lee", getName: f}
    return this.name;
  }
}
console.log(person.getName());

// 생성자 함수
function Person(name) {
  console.log(this); // Person {name: "Lee"}
  this.name = name;
}

const me = new Person("Lee");

또한 strict mode에서는 일반 함수 내부의 this는 undefined 처리된다.

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

조금 더 자세히 정리해보자.

this가 가리키는 값, 즉 this 바인딩 함수 호출 방식에 의해 동적으로 결정된다.

더보기

바인딩(binding) : 식별자와 값을 연결하는 과정

예시> 변수 선언은 변수 이름(식별자)과 확보된 메모리 공간 주소를 바인딩하는 것.

예시> this 바인딩은 this와 this가 가리킬 객체를 연결하는 과정

함수 호출 방식, 즉 함수가 어떻게 호출되었는지에 따라 결정된다는 것이다.

참고로 함수의 Scope를 공부할 때 렉시컬 스코프를 설명했었다.

이 때, 렉시컬 스코프 함수가 정의가 평가되어 함수 객체가 생성하는 시점에 상위 스코프를 결정했다.

그러나, this 함수 호출 시점에 결정된다.

 

위와 같은 설명을 하는 것에는 이유가 있다.

 

this가 함수 호출 시점에 결정된다는 것은, 같은 함수더라도 어떻게 호출되냐에 따라 그 환경이 다를 수 있다는 것이다.

다시 말해, this는 선언된 위치에 따라 다르게 지칭하며, 그 선언된 위치는 함수가 어떻게 호출되냐에 따라 다르다.

 

먼저 함수의 호출 방식에 어떤 것들이 있는지부터 알 필요가 있다.

  1. 일반 함수 호출
  2. 메서드 호출
  3. 생성자 호출
  4. Function.prototype.apply/call/bind 메서드에 의한 간접 호출

그리고 바로 예시를 보자.

function square(number) {
  console.log(this);
  return number * number;
};

일반적인 함수를 선언하였다.

아마 앞서 본 예시와 같이, square(2);를 호출한다면, this는 Window 혹은 strict mode일 때 undefined를 출력할 것이다.

 

그런데 다음과 같이 바꾸어 보면, 함수 호출 방식이라는 것이 무엇인지 감이 온다.

// 호출 방식에 따라 this 바인딩이 동적으로 결정된다.
function foo() {
  console.log(this);
};

// 1. 일반 함수 호출
// foo 함수를 일반적인 방식으로 호출
foo(); // Window

// 2. 메서드 호출
// 프로퍼티 값으로 square을 할당
// 메서드를 호출한 객체를 출력
const obj = { foo };
obj.foo(); // {foo: f}

// 3. 생성자 호출
// foo 함수가 생성한 인스턴스를 출력
new foo(); // foo {}

// 4. Function.prototype.call/appliy/bind
const bar = { name: 'bar' };
foo.call(bar); // {name: 'bar'}
foo.apply(bar); // {name: 'bar'}
foo.bind(bar); // f foo() {console.log(this);}

 

일반 함수 호출

가장 기본적으로 this는 전역 객체가 바인딩된다.

그리고 일반 함수는 중첩이더라도 this에는 전역 객체가 바인딩된다. 설령, 메서드 내에 있더라도, 콜백 함수더라도 말이다.

단, 앞서 말했듯이, strict mode에서는 undefined이다.

var value = 1;
const obj = {
  value: 100,
  foo() {
    // 메서드에서의 this
    console.log("foo`s this: ", this); // {value: 100, foo: f}
    console.log("foo`s this.value: ", this.value); // 100

    // 일반함수에서의 this는 전역이므로, value는 1
    function bar1() {
      console.log("bar1`s this: ", this); // Window
      console.log("bar1`s this.value: ", this.value); // 1
    };

    // strict mode에서는 undefined
    function bar2() {
      'use strict';
      console.log("bar2`s this: ", this); // undefined
      // console.log("bar2`s this.value: ", this.value); // Error
    };
    bar1();
    bar2();
  },
  // callback 내에 일반함수 역시 전역
  callback() {
    setTimeout(function() {
      console.log("callback`s this: ", this);
      console.log("callback`s this.value: ", this.value);
    }, 100);
  }
}
obj.foo();
obj.callback();

즉, 일반 함수로 호출된 모든 함수(중첩, 콜백 포함) 내부의 this는 전역 객체가 바인딩된다.

근데 중첩함수, 콜백함수에서까지, 전역 객체로 바인딩되는 것은 좀 문제가 있어보인다.

보통, 중첩함수나 콜백함수의 목적은 헬퍼 함수로서가 크다. 즉, 외부 함수의 로직을 대체할 경우가 많다.

 

따라서, 이에 대한 적절한 절차가 필요해 보인다.

var value = 1;
const obj = {
  value: 100,
  foo() {
    const that = this;
    setTimeout(function() {
      console.log("callback`s this: ", this);
      console.log("callback`s that: ", that);
    }, 100);
  }
}
obj.foo();

약간의 변조를 주었다.

사실 앞서 잠깐 다뤘던 apply, call, bind 메서드 혹은 화살표 함수를 사용하면 해결할 수 있다.

이에 대해서는 잠시 후에(화살표 함수는 나중에) 다루어 보도록 하자.

 

메서드 호출

앞서 계속해서 메서드 호출을 확인해왔다.

주의할 점은 메서드 내부의 this는 메서드를 소유한 객체가 아니라 메서드를 호출한 객체에 바인딩된다는 것이다.

 

소유와 호출의 개념을 정리해보자.

const person1 = {
  name: 'Lee',
  getName() {
    // 메서드 내부의 this는 메서드를 호출한 객체에 바인딩
    return this.name;
  }
};

// 메서드 getName을 호출한 객체는 person이다.
console.log(person1.getName()); // Lee

const person2 = {
	name: 'Kim'
}

// person2의 getName 메서드를 person1에게서 가져와 할당
person2.getName = person1.getName;
// getName을 호출한 객체는 person2이다.
console.log(person2.getName()); // Kim

// getName 변수에 person1.getName 메서드를 할당
const getName = person1.getName;
console.log(getName()); // ''
// Window.name이 호출되어, ''이 출력된다.

분명, getName 메서드는 처음에 person1에서 소유하고 있었다.

그런데 person2의 메서드나 변수에서 getName을 가져왔더니, this의 지칭이 바뀐 것을 알 수 있다.

 

이 부분은 메서드가 단지 프로퍼티에 바인딩된 함수라는 개념을 알아야 한다.

즉, getName 메서드는 독립적인 함수 객체이고, person1 객체에서 프로퍼티로 해당 함수를 지칭하고 있을 뿐이다.

 

위와 같이 테스트 하면 조금 헤깔릴 수 있다.

prototype으로 알아보면, 왜 호출한 객체라고 표현하는지 더 명확해진다.

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

Person.prototype.getName = function() {
  console.log(this);
  return this.name;
}

const person1 = new Person('Lee');
const person2 = new Person('Kim');
const person3 = new Person('Koo');
person1.getName(); // Person {name: 'Lee'}
person2.getName(); // Person {name: 'Kim'}
person3.getName(); // Person {name: 'Koo'}

 

생성자 함수 호출

사실 이전 생성자를 공부하면서도 다루었기에 이해가 더 쉬울 것으로 보인다.

생성자 함수 내부의 this는 생성자 함수가 미래에 생성할 인스턴스가 바인딩된다.

 

function Circle(radius) {
    // 생성자 함수 내부의 this는 생성자 함수가 생성할 인스턴스를 가리킨다.
    this.radius = radius;
    this.getDiameter = function() {
    	return 2 * this.radius;
    };
}

const circle1 = new Circle(5);
const circle2 = new Circle(7);

console.log(circle1.getDiameter()); // 10
console.log(circle2.getDiameter()); // 14

또한, new가 아니라 그냥 호출한 경우, 일반 함수로 동작한다는 점을 다시 한번 되새기자.

 

Function.prototype.apply/call/bind

apply,call,bind 메서드를 설명하고자 할 때, 계속 Function.prototype의 메서드임을 붙여주고 있다.

이 의미는 모든 함수는 위 3가지 메서드를 호출할 수 있다는 것이다.

모든 함수 리터럴은 Function을 생성자(constructor)로 가지기 때문이다.

 

일단 해당 메서드들의 사용법을 보도록 하자.

먼저 applycall은 다음과 같이 설명된다.

@thisArg : this로 사용할 객체
@argsArray : 함수에게 전달할 인수 리스트의 배열 또는 유사 배열 객체
@return : 호출된 함수의 반환값
func.apply(thisArg, [argsArray])

@thisArg : this로 사용할 객체
@argsArray : 함수에게 전달할 인수 리스트
@return : 호출된 함수의 반환값

func.call(thisArg[, arg1[, arg2[, ...]]])
function getThisBinding() {
  console.log(arguments);
  return this;
}

// this로 사용할 객체
const thisArg = {a: 1};
console.log(getThisBinding()); // window

console.log(getThisBinding.apply(thisArg, [1,2,3])); // {a:1}
console.log(getThisBinding.call(thisArg,1,2,3)); // {a:1}

즉, applycall은 둘다 기본적으로 함수를 호출하고 있다.

그 때, 특정 객체를 this로 바인딩한다.

 

bindthis로 사용할 객체만 전달한다.

function getThisBinding() {
  return this;
}

// this로 사용할 객체
const thisArg = {a: 1};

// 함수를 호출하지는 않고, this로 사용할 객체만 전달한다.
console.log(getThisBinding.bind(thisArg)); // getThisBinding
// 명시적으로 호출이 필요하다.
console.log(getThisBinding.bind(thisArg)()); // {a:1}

bind 메서드는 흔히 앞서 알아봤던 중첩 함수 혹은 콜백 함수의 this가 다른 문제를 해결할 때 사용된다.

var value = 1;
const obj = {
  value: 100,
  foo() {
    setTimeout(function() {
      console.log("callback`s this: ", this);
    }.bind(this), 100);
  }
}
obj.foo();

앞서 this와 that으로 처리했던 함수를 위와 같이 처리하니 훨씬 깔끔해졌다.

 

Wrap Up

자바스크립트에서 this를 정리해보았다.

this는 함수 호출 시점에 따라 동적으로 할당되며, 그 종류는 일반 함수, 메서드, 생성자 함수, Function.prototype.call/apply/bind가 있었다.

 

그 차이점들을 잘 정리해보도록 하자.