[자바스크립트] this의 개념과 활용법
여타 프로그래밍 언어와 마찬가지로 자바스크립트는 this라는 키워드를 제공한다. 사실 자바에서는 this 키워드를 익숙하게 써왔는데, 자바의 this는 단지 현재 객체(에 대한 레퍼런스)를 가리킬뿐이다. 하지만 이와 동일한 이해를 가지고 자바스크립트 코드를 작성했다간 여러가지 문제에 부딪히게 될 수 있다.
자바스크립트에서의 this는 자바처럼 단순하지 않다. 실행 문맥에 따라 다르게 동작한다고 이해하는게 맞겠다. 다음과 같이 4가지 종류의 문맥으로 구분할 수 있다.
- 함수 실행 : alert('Hello world!')
- 메소드(함수 내부의) 실행 : console.log('Hello world!')
- 생성자 실행 : new RegExp('\\d')
- 간접(indirection) 실행 : alert.call(undefined, 'Hello world!');
this가 이 4가지의 문맥에 따라 다르게 동작하므로 자바스크립트 개발자들이 처음에 기대했던 것과는 다른 실행 결과를 확인하게 된다. 자, 그러면 각각의 문맥에 따라 어떻게 동작하는지 예시를 통해 살펴보기로 하자.
1. 함수 실행
들어가기에 앞서 함수와 메소드를 구분할 필요가 있다. 메소드란 객체 내부의 속성이다. 따라서 ["hello","world"].join(',')는 함수의 실행이 아닌, 메소드 호출이다. 반면 다음의 코드는 함수 실행의 예이다.
function hello(name) {
return 'Hello ' + name + '!';
}
// 함수 실행
var message = hello('World');
console.log(message); // => 'Hello World!'
5라인에서 hello 함수를 실행하고 있다. 하지만 6라인에서는 자바스크립트가 제공하는 console 객체의 log 메소드를 호출하고 있는 것이다.
❙ 함수 실행에서의 this
함수 실행에서 this는 전역 객체를 가리킨다.
나는 당연히 함수 내부의 this는 함수 자체를 가리킬 줄로 알았다. 하지만 사실 함수 실행에서의 this는 전역객체를 가리킨다. 다음과 같이 말이다.
여기서 실행 문맥이라는 말을 확실히 짚고 넘어가야 한다. 이는 함수가 작성 됐을때가 아닌 실행될 당시의 문맥을 뜻한다. 따라서 실제로 함수가 실행되는 환경에서 this가 무엇을 가리킬지를 생각해야 하는 것이다. 함수 실행 문맥을 확인할 수 있는 예를 하나 더 살펴보자.
function sum(a, b) {
console.log(this === window); // => true
this.myNumber = 20;
return a + b;
}
// 함수 문맥으로 실행된 sum()
// this in sum() is a global object (window)
sum(15, 16); // => 31
window.myNumber; // => 20
3라인에서 this가 활용되고 있다. sum 함수가 호출되면 자바스크립트는 자동으로 this에 전역 객체를 세팅한다. 3라인에서처럼 말이다. 웹 브라우저 환경에서 전역 객체는 window이므로 9라인에서 이를 확인할 수 있다. 다시 말하지만 여기서 this가 window 객체를 가리키는 것은 어디까지나 실행 문맥이라는 전제를 가지고 있다.
또한, 함수 밖에서 활용되는 this 키워드 역시 전역 객체를 참조한다.
console.log(this === window); // => true
this.myString = 'Hello World!';
console.log(window.myString); // => 'Hello World!'
2. 메소드 실행
위에서 밝혔듯, 메소드는 객체 내부의 속성으로 정의된다. 예를 하나 보자.
var myObject = {
// helloFunction is a method
helloFunction: function() {
return 'Hello World!';
}
};
var message = myObject.helloFunction();
여기서 helloFunction은 myObject 내부의 속성이다. 따라서 7라인에서는 속성 접근자를 통한 메소드 호출을 하고 있는 것이다. 이렇듯 메소드를 실행하기 위해서는 반드시 속성 접근자를 사용해야한다. this의 실행 문맥이 달라지게 되므로, 함수와 메소드의 구분은 매우 중요하다. 다음과 예를 보면서 조금 더 익혀보도록 하자.
['Hello', 'World'].join(', '); // 메소드 실행
({ ten: function() { return 10; } }).ten(); // 메소드 실행
var obj = {};
obj.myFunction = function() {
return new Date().toString();
};
obj.myFunction(); // 메소드 실행
var otherFunction = obj.myFunction;
otherFunction(); // 함수 실행
parseFloat('16.60'); // 함수 실행
isNaN(0); // 함수 실행
❙ 메소드 실행에서의 this
메소드 실행에서의 this는 메소드를 소유하고 있는 객체를 가리킨다.
객체 내부의 속성으로 정의된 함수가 실행될때, this는 객체 자신이 되는 것이다.
예를 하나 더 보도록 하자.
var calc = {
num: 0,
increment: function() {
console.log(this === calc); // => true
this.num += 1;
return this.num;
}
};
// method invocation. this is calc
calc.increment(); // => 1
calc.increment(); // => 2
increment는 객체 내부의 메소드로 정의되고 있다. 여기서 this는 calc 객체를 가리키게 된다. 그렇다면 increment가 리턴하고 있는 값은 calc 객체 내부의 속성인 num인 것이다. 따라서 10, 11라인에서 메소드를 실행하면 문제없이 num의 값이 증가하게 된다.
자바스크립트에서 객체는 프로토타입(prototype)에 있는 메소드를 상속받는다. 이렇게 상속받은 메소드를 실행하면 여전히 실행 문맥은 객체 자신이 된다.
var myDog = Object.create({
sayName: function() {
console.log(this === myDog); // => true
return this.name;
}
});
myDog.name = 'Milo';
// 메소드 실행. 여기서의 this는 myDog.
myDog.sayName(); // => 'Milo'
Object.create()는 새로운 객체를 생성하고 파라미터로 객체를 넘겨서 프로토타입을 세팅한다. myDog 객체는 sayName 메소드를 상속받는다. 그리고 sayName()이 실행될때, 실행 문맥은 myDog 객체가 된다.
es6의 class 문법을 활용할때도 메소드의 실행 문맥은 인스턴스 자신이다.
class Planet {
constructor(name) {
this.name = name;
}
getName() {
console.log(this === earth); // => true
return this.name;
}
}
var earth = new Planet('Earth');
// method invocation. the context is earth
earth.getName(); // => 'Earth'
3. 생성자 실행
생성자 실행은 표현식 앞에 new 키워드가 붙었을때, 함수 객체로써 실행되는 것을 뜻한다. 예를 들자면 new RegExp('\\d')와 같이 말이다.
다음 코드를 보자.
function Country(name, traveled) {
this.name = name ? name : 'United Kingdom';
this.traveled = Boolean(traveled);
}
Country.prototype.travel = function() {
this.traveled = true;
};
// Constructor invocation
var france = new Country('France', false);
// Constructor invocation
var unitedKingdom = new Country;
france.travel(); // Travel to France
여기서 한가지 의문이 들 수 있다. Country나 프로토타입 설정에서 쓰인 this는 분명 함수내에서 쓰였으므로 전역 객체 window를 가리키는 것이 아닐까? 답부터 말하자면 아니다. this가 전역 객체를 가리키는 것은 함수의 실행 문맥이다. 이 예제에서는 생성자의 실행 문맥을 따른다. 따라서 직접 실행해보면 this가 Country 함수 객체를 가리키게 됨을 알 수 있다.
es6에서는 class 문법을 통해 생성자를 정의할 수 있다.
constructor 라는 특수 메소드에서 사용되는 this는 새로 만들어진 객체를 가리키게 된다.
생성자가 실행되면 생성자의 프로토타입으로부터 속성을 상속받은 빈 객체가 만들어진다. 여기서 this는 인스턴스를 가리킨다.
myObject.myFunction과 같은 속성 접근자가 new 키워드 뒤에 오면, 자바스크립트는 이를 메소드 실행이 아닌 생성자 실행으로 본다. 예를 들어 new myObject.myFunction()의 경우, 먼저 extractedFunction = myObject.myFunction과 같이 함수가 추출되고, new extractedFunction()와 같이 생성자가 호출되어 객체가 생성되는 식이다.
❙ 생성자 실행에서의 this
생성자 실행에서의 this는 새롭게 만들어진 객체를 가리킨다.
생성자는 객체의 초기값 세팅을 위해 사용된다. 생성자 실행 문맥에서 this는 새롭게 만들어진 객체를 가리킨다.
다음의 예시를 보자.
function Foo () {
console.log(this instanceof Foo); // => true
this.property = 'Default Value';
}
// 생성자 실행
var fooInstance = new Foo();
fooInstance.property; // => 'Default Value'
함수 실행 문맥과는 다른 결과다. 여기서 this의 문맥은 Foo의 인스턴스가 된다. 생성자 실행시 Foo 내부에서는 초기값이 세팅되었고, this.property에는 'Default Value'가 할당된 것이다.
e6의 생성자에서도 this는 마찬가지 문맥을 가지며, 오직 생성자를 통해서만 초기값을 세팅할 수 있다.
class Bar {
constructor() {
console.log(this instanceof Bar); // => true
this.property = 'Default Value';
}
}
// 생성자 실행
var barInstance = new Bar();
barInstance.property; // => 'Default Value'
4. 간접 실행
간접 실행은 함수가 myFuc.call() 이나 myFuc.apply() 등의 메소드 호출을 통해 실행되는 것을 뜻한다.
자바스크립트에서 함수란 일급 객체다. 함수는 객체이며, 그 객체의 타입은 함수인 것이다. 함수가 객체라는 것은 프로퍼티와 메소드를 가질 수 있음을 의미한다. 또, 자바스크립트 함수에는 공통으로 쓰이는 여러가지 프로퍼티와 메소드가 있다. 가장 대표적인 프로퍼티로는 length가 있다. 메소드로는 call, apply, bind 등이 있다.
여기서는 예를 보며 call과 apply 메소드를 살펴보자.
function increment(number) {
return ++number;
}
increment.call(undefined, 10); // => 11
increment.apply(undefined, [10]); // => 11
단순하다. 이런식으로 매개 변수를 넘겨서 함수를 간접 실행할 수 있다. 다만 apply는 두번째 인자를 배열로 넘길 수 있다. 일단 이정도 역할까지만 이해를 하고 넘어가도록 하자.
❙ 간접 호출에서의 this
call(), apply() 호출에서 this는 첫번째 매개변수를 가리킨다.
다음 예제는 간접 실행 문맥의 예를 보여준다.
var rabbit = { name: 'White Rabbit' };
function concatName(string) {
console.log(this === rabbit); // true
return string + this.name;
}
// Indirect invocations
concatName.call(rabbit, 'Hello '); // 'Hello White Rabbit'
concatName.apply(rabbit, ['Bye ']); // 'Bye White Rabbit'
call() 실행을 통해 간접 실행이 수행되면 concatName 내부의 this는 call()의 첫번째 매개변수인 rabbit을 가리키게 된다. 따라서 this.name은 rabbit 객체 내부의 name 프로퍼티를 참조할 수 있게되는 것이다. bind 메소드 실행에서 역시 this의 문맥은 첫번째 매개변수가 된다.
그렇다면 굳이 이렇게 간접 실행을 하는 이유는 무엇일까. this의 활용에 답이 있다. 함수 실행에서 this의 문맥은 전역 객체였다. 따라서 this가 다른 객체를 가리키게끔 하기 위해서 이런식으로 간접 실행을 활용하는 것이다.
5. Arrow Function
❙ Arrow Function에서의 this
es6의 arrow function에서는 this가 또 다른 특징을 가진다.
arrow function에서 this는 arrow function이 정의된 곳의 문맥을 그대로 따른다.
자신을 둘러싼 환경에서 this가 가지는 문맥은 arrow function에서도 그대로 적용된다. 이렇듯 arrow function에서의 this는 한번 bound되면 절대 바뀌지 않는 렉시컬 문맥을 따른다(렉시컬 문맥에 대해서는 이글을 참조하자). this의 참조값이 결정되고나면 실행 문맥이 달라져도 변하지 않는다. 이를 Lexical this라고도 한다.
예시를 확인해보자.
function objFunction() {
console.log('Inside `objFunction`:', this.foo); // 13
return {
foo: 25,
bar: function() {
console.log('Inside `bar`:', this.foo); // 25
},
};
}
objFunction.call({foo: 13}).bar(); // objFunction의 `this`를 오버라이딩한다
11라인 실행시, this는 간접 실행 문맥을 따른다. 따라서 this는 {foo: 13} 객체를 가리키게 되어 2라인에서 객체의 프로퍼티를 참조한다. 당연히 13이 출력된다. 하지만, 6라인에서 실행 문맥은 메소드로 바뀌게 된다. 메소드 실행 문맥에서 this는 메소드를 소유한 객체를 가리키게 되므로 자신을 둘러싼 객체의 foo 프로퍼티를 출력하는 것이다. 이렇듯, this는 실행 문맥에 따라 계속해서 다른 값을 참조한다.
하지만 es6의 arrow function에서 이런일은 벌어지지 않는다.
function objFunction() {
console.log('Inside `objFunction`:', this.foo); // 13
return {
foo: 25,
bar: () => console.log('Inside `bar`:', this.foo) // 13
};
}
objFunction.call({foo: 13}).bar();
출력값은 둘다 13이 된다. arrow function이 자신을 둘러싼 환경의 this 문맥을 그대로 따르기 때문이다. 9라인에서 간접 실행이 일어나며 this의 문맥이 결정되면, arrow function도 이를 그대로 따르는 것이다. 따라서 arrow function은 실행도중 this의 스코프를 바꾸고 싶지 않을때 유용하다.
참고
https://dmitripavlutin.com/gentle-explanation-of-this-in-javascript/
https://beomi.github.io/2017/07/12/understanding_js_scope_function_vs_arrow/