최근에 Prototype과 Execution Context에 관한 글을 게시했습니다. Execution Context는 VariableEnvironment와 LexicalEnvironment, 그리고 thisBinding으로 구성됨을 소개했습니다. thisBinding의 핵심인 this에 대한 이야기를 풀고자 합니다.
this는 자바스크립트에서 가장 혼란스러운 개념 중 하나입니다. 상황에 따라서 this가 바라보는 대상이 달라지고, 어떤 이유로 그렇게 되는지를 파악하기 어려운 경우가 많기 때문이죠.
this를 달라지게 하는 상황이란 게 무엇인지, 왜 그렇게 되는지, 어떻게 해결할 수 있는지 살펴보겠습니다.
다양한 객체지향 언어에서 this는, 클래스로 생성한 인스턴스 객체를 의미합니다. 즉, 붕어빵 틀(=클래스)로 구워낸 붕어빵(=인스턴스 객체)이죠. this는 붕어빵입니다.

다른 언어에서는 this를 클래스에서만 사용할 수 있기에, 혼란의 여지가 없거나 많지 않습니다. 팥 붕어빵이라는 인스턴스 객체를 생성했다면 붕어빵 틀에서의 this는 팥 붕어빵이고, 슈크림 붕어빵이라는 인스턴스 객체를 생성했다면 붕어빵 틀에서의 this는 슈크림 붕어빵입니다.
자바스크립트는 this를 어디서든 사용할 수 있습니다. 따라서 자바스크립트에서 this를 이해하기 어려운 이유는, '어디서든 사용할 수 있다는 점'에 있습니다. 내가 지금 읽는 코드에서의 this가 팥붕인지 슈붕인지 피자붕인지 헷갈린다는 것이죠. 쟤가 들고 있는 봉투에 들어있는 붕어빵이 팥붕인지 슈붕인지 피자붕인지 알기가 어렵습니다.
이어지는 글에서는 this가 어디에서 사용되는지, 해당 지점에서의 this는 무엇인지에 관해 살펴보고자 합니다.
전역 공간에서의 this는 전역 객체를 나타냅니다.
실행 컨텍스트(execution context)는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체입니다.
실행 컨텍스트는 함수를 호출할 때 생성됩니다. 이어서 this는 실행 컨텍스트가 생성될 때 함께 '결정'됩니다. 따라서 this는 함수를 호출할 때 결정된다고 이해할 수 있습니다.
전역 공간에서의 this는 전역 객체입니다. 전역 객체는, 브라우저 환경에서는 window고 Node.js 공간에서는 global입니다.
console.log(this); // { alert: f(), atob: f(), blur: f(), btoa: f(), ... }
console.log(window); // { alert: f(), atob: f(), blur: f(), btoa: f(), ... }
console.log(this === window); // true
console.log(this); // { process: { title: 'node', version: 'v10.13.0',... } }
console.log(global); // { process: { title: 'node', version: 'v10.13.0',... } }
console.log(this === global); // true
전역 공간에서 변수 a에 1을 할당해 보죠.
var a = 1;
console.log(a); // 1
console.log(window.a); // 1
console.log(this.a); // 1
window와 this는 모두 전역 객체를 의미하기에, 두 값이 같은 것은 당연합니다. 그런데 변수 a에 1을 할당했을 뿐인데 어째서 전역 객체의 값이 1이 되는 것일까요?
우리가 함수를 호출하면 생성되는 실행 컨텍스트에는 Lexical Environment(이하 L.E)라는 것이 있습니다. 실행 컨텍스트는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체라고 했죠. 실행 컨텍스트는 변수를 수집해서 L.E의 프로퍼티로 저장합니다.
한마디로, 전역 변수를 선언하면 자바스크립트 엔진은 이를 전역 객체의 프로퍼티로 할당합니다.
메서드 내부에서의 this는 자신을 호출한 주체를 나타냅니다.
우선 함수와 메서드의 차이, 용어 정리가 우선이겠습니다. 함수와 메서드는 독립성에 의해 구분됩니다.
함수는 그 자체로 독립적인 기능을 수행하는 반면, 메서드는 자신을 호출한 대상 객체에 관한 동작을 수행합니다. '방 청소하기'라는 함수가 자체적으로 실행되었다면 말 그대로 함수이고, 어머니의 큰 호통소리에 의해 실행되었다면 어머니라는 객체의 메서드로서 동작을 한 것이라고 해석할 수 있죠.
var func = function(x) {
console.log(this, x);
};
func(1); // Window { ... } 1
var obj = {
method: func,
};
obj.method(2); // { method: f } 2
많은 경우 메서드를 '객체의 프로퍼티에 할당된 함수'로 이해하곤 합니다만, 반은 맞고 반은 틀린 얘기입니다. 프로퍼티에 할당했다는 이유만으로는 부족하고, 객체의 메서드로서 호출할 때 비로소 메서드의 지위를 얻을 수 있습니다.
위 코드의 경우 func() 함수를 obj라는 객체의 프로퍼티로 할당했지만, 단순히 함수로서 실행하면 전역 객체를 바라보게 됩니다. 반면 obj라는 객체의 메서드로서 실행하면 obj를 바라보게 되죠.
일반 함수 내부에서의 this는 전역 객체를 나타냅니다.
이미 스포했지만, 함수를 말 그대로 '함수'로서 호출한 경우에는 this가 지정되지 않아 전역 객체를 바라보게 됩니다. 호출 주체를 명시하지 않고 개발자가 직접 코드에 관여해서 실행한 것이기 때문이죠. 그런데 이는 명백한 자바스크립트 언어 설계상의 오류입니다. 저의 의견은 아니고, 자바스크립트 개발에 참여한 더글라스 크락포드 아저씨의 의견입니다. 더글라스 크락포드 아저씨 같은 훌륭한 개발자들의 헤어스타일을 볼수록, 개발 이거 계속해도 괜찮을지 심란해집니다.

var obj1 = {
outer: function() {
console.log(this); // (1)
var innerFunc = function() {
console.log(this); // (2) (3)
};
innerFunc();
var obj2 = {
innerMethod: innerFunc,
};
obj2.innerMethod();
},
};
obj1.outer();
위 코드에서 (2)는 innerFunc를 호출한 결과를, (3)은 obj2.innerMethod를 호출한 결과를 의미합니다.
(1): obj1, (2): window(전역 객체), (3): obj2가 최종 결과로 나옵니다. 결론적으로는 (2)와 (3)이 innerFunc이라는 같은 함수를 호출했지만, 바인딩 되는 this의 대상이 서로 다르다는 점을 확인할 수 있습니다.
그런데 이전에 다루었던 outerEnvironmentReference의 스코프 체이닝에 대한 일관성을 지키기 위해서는, this 역시 현재 컨텍스트에 바인딩 된 대상을 우선적으로 고려하게 설계되었어야 하는 것이 맞습니다. 더글라스 크락포드 아저씨가 "우리가 잘못했소."라고 시인한 부분이 바로 이 지점이죠.
그래서 선배 개발자들은 변수를 통해 이러한 상황을 우회했습니다.
var obj = {
outer: function() {
console.log(this); // (1) { outer: f }
var innerFunc1 = function() {
console.log(this); // (2) Window { ... }
};
innerFunc1();
var self = this;
var innerFunc2 = function() {
console.log(self); // (3) { outer: f }
};
innerFunc2();
},
};
obj.outer();
outer에서의 this는 obj를 나타내기에, 이를 self라는 변수에 할당한 뒤 해당 변수를 내부 함수에 상속하는 '척' 하는 것이죠. 우회라고 하기도 애매할 정도로 허무한 방법이지만, 저는 선배 개발자들을 존경합니다. 저는 이런 생각도 못 했을 테니까요.
ES6에서는 this가 전역 객체를 바라보는 문제를 보완하기 위해, this를 바인딩 하지 않는 Arrow Function을 도입했습니다. 전역 객체로 연결되는 바인딩 자체를 끊어버리면, 상위 스코프의 this를 그대로 활용할 수 있겠죠. 결과적으로 아래와 같은 코드가 작성됩니다.
var obj = {
outer: function() {
console.log(this); // (1) { outer: f }
var innerFunc = () => {
console.log(this); // (2) { outer: f }
};
innerFunc();
},
};
obj.outer();
콜백 함수 내부에서의 this는 저도 모릅니다.
함수 A의 제어권을 다른 함수나 메서드 B에게 넘겨주는 경우, 함수 A를 콜백 함수라고 부릅니다.
콜백 함수는 기본적으로 함수입니다. 치즈 라면은 기본적으로 라면이죠. 따라서 일반 함수와 마찬가지로 this가 전역 객체를 참조하게 됩니다. 다만 제어권을 받은 함수 B에서 콜백 함수에 별도로 this가 될 대상을 지정한 경우에는 해당 대상을 참조하게 됩니다.
setTimeout(function() {
console.log(this);
}, 300); // (1)
[1, 2, 3, 4, 5].forEach(function(x) {
// (2)
console.log(this, x);
});
document.body.innerHTML += '<button id="a">클릭</button>';
document.body.querySelector('#a').addEventListener('click', function(e) {
// (3)
console.log(this, e);
});
setTimeout 함수나 forEach 메서드는 그 내부에서 콜백 함수를 호출할 때 대상이 될 this를 지정하지 않기에, (1)과 (2)의 this는 전역 객체를 바라보게 됩니다.
반면 (3)의 addEventListener 메서드는 콜백 함수를 호출할 때 자신의 this를 상속하도록 정의돼 있습니다. 즉 버튼 요소가 가진 모든 속성(property)과 메서드(method)가 this가 되는 것이죠.
그래서 콜백 함수 내부에서의 this는 저도 모릅니다. 기본적으로는 전역 객체를 바라보지만 반드시 그렇다고도 할 수 없습니다.
생성자 함수 내부에서의 this는 붕어빵입니다.
var Cat = function(name, age) {
this.bark = '야옹';
this.name = name;
this.age = age;
};
var choco = new Cat('초코', 7);
var nabi = new Cat('나비', 5);
console.log(choco, nabi);
/* 결과
Cat { bark: '야옹', name: '초코', age: 7 }
Cat { bark: '야옹', name: '나비', age: 5 }
*/
choco냥이와 nabi냥이의 this가 각각 다른 점을 확인할 수 있습니다.
조금 더 개발자스럽게 표현하면, 생성자 함수로서 호출된 경우 내부에서의 this는 곧 새로 만들 구체적인 인스턴스 자신이 됩니다.
⚠️ 자바스크립트에서 this는 호출 방식에 따라 다르게 결정됩니다.
⚠️ this는 "어떻게 호출되었는가"에 따라 동적으로 결정되는 것이 핵심입니다.
1. 전역 공간에서는 전역 객체(브라우저의 window, Node.js의 global)를 가리킵니다.
2. 메서드로 호출될 때는 메서드를 호출한 객체를 가리킵니다.
3. 일반 함수로 호출되면 전역 객체를 가리키는데 이는 자바스크립트의 설계 오류로 여겨지며, ES6 화살표 함수로 이 문제를 해결할 수 있습니다.
4. 콜백 함수에서는 기본적으로 전역 객체를 가리키지만, 콜백을 실행하는 함수가 별도로 this를 지정하면 해당 객체를 가리킵니다.
5. 생성자 함수에서는 새로 생성되는 인스턴스 객체 자신을 가리킵니다.
우리는 이제 this가 문제가 아니라는 것을 알았습니다. 문제는 this 자체가 아니라 'this가 전역 객체를 바라보는 것'에 있습니다. 그렇다면 해결 방안은 'this가 무지성으로 전역 객체를 바라보지 않도록 바인딩 하는 것'이겠죠. 1장이 문제 상황이었다면 2장에서는 해결 방안에 관해 다룹니다.
call 메서드는 첫 번째 인자 값으로 원하는 this를 바인딩하고, 두 번째 인자 값으로 호출할 함수의 매개변수들을 전달합니다.
var func = function(a, b, c) {
console.log(this, a, b, c);
};
func(1, 2, 3); // Window{ ... } 1 2 3
func.call({ x: 1 }, 4, 5, 6); // { x: 1 } 4 5 6
apply 메서드는 두 번째 인자를 배열의 형태로 받아 그 배열의 요소들을 호출할 함수의 매개변수로 지정한다는 점에서만 call 메서드와 차이가 있습니다.
var func = function(a, b, c) {
console.log(this, a, b, c);
};
func.apply({ x: 1 }, [4, 5, 6]); // { x: 1 } 4 5 6
var obj = {
a: 1,
method: function(x, y) {
console.log(this.a, x, y);
},
};
obj.method.apply({ a: 4 }, [5, 6]); // 4 5 6
그런데 저는 아직도 궁금합니다. 왜 이런 식으로 메서드가 분화되었는지.
답은 유사 배열 객체에 있습니다. 자바스크립트에는 arguments 객체와 같이 실제 배열은 아니지만 배열처럼 동작하는 유사 배열 객체가 존재합니다. arguments 객체는 함수에 전달된 모든 인자를 담고 있으며, 인덱스를 통해 각 인자에 접근할 수 있고 length 프로퍼티도 가지고 있습니다. 하지만 slice, map, forEach와 같은 배열의 프로토타입 메서드는 직접 사용할 수 없습니다.
만약 call 메서드만 존재했다면, arguments 객체와 같은 유사 배열 객체의 값들을 다른 함수로 전달하기가 매우 번거로웠을 것입니다. 예를 들어, func라는 함수에 arguments 객체의 모든 요소를 인자로 전달하려면 다음과 같이 코드를 작성해야 했을 것입니다.
function anotherFunction() {
func.call(this, arguments[0], arguments[1], arguments[2]);
}
apply 메서드를 통해 위 코드를 다음과 같이 개선할 수 있게 되었죠.
function anotherFunction() {
func.apply(this, arguments);
}
call과 apply는 함수를 즉시 호출하지만, bind는 즉시 호출하지는 않고 넘겨받은 this와 인수들을 바탕으로 새로운 함수를 반환하기만 합니다.
var func = function(a, b, c, d) {
console.log(this, a, b, c, d);
};
func(1, 2, 3, 4); // Window{ ... } 1 2 3 4
var bindFunc1 = func.bind({ x: 1 });
bindFunc1(5, 6, 7, 8); // { x: 1 } 5 6 7 8
var bindFunc2 = func.bind({ x: 1 }, 4, 5);
bindFunc2(6, 7); // { x: 1 } 4 5 6 7
bindFunc2(8, 9); // { x: 1 } 4 5 8 9
즉, bind 메서드는 this를 미리 적용하는 것과 부분 적용 함수를 구현하는 두 가지 목적을 가집니다.
⚠️ 자바스크립트에서 this가 의도치 않게 전역 객체를 바라보는 문제를 해결하기 위해 call, apply, bind 메서드를 사용해 명시적으로 this를 바인딩 할 수 있습니다.
1. call 메서드는 첫 번째 인자로 바인딩 할 this를, 나머지 인자들로 함수의 매개변수를 개별적으로 전달하며 함수를 즉시 호출합니다.
2. apply 메서드는 call과 동일하지만 매개변수를 배열 형태로 전달하며, 이는 arguments 객체나 유사 배열 객체를 다룰 때 유용합니다.
3. bind 메서드는 call, apply와 달리 함수를 즉시 호출하지 않고, 지정된 this와 미리 설정된 인자들을 가진 새로운 함수를 반환하여 부분 적용 함수 구현과 this 고정이라는 두 가지 목적을 동시에 달성할 수 있습니다.
AI를 활용하며, 코드를 작성하는 시간보다 읽는 시간이 더 길어짐을 느낍니다. 그런데 세상 일이라는 게 원래 다 그런 것 같습니다. 읽는 시간을 충분히 확보하지 않으면 했던 얘기만 하거나, 남의 얘기를 앵무새처럼 따라 할 수밖에 없는 것 같습니다. 무엇이든 많이 읽고, 동시에 나의 언어로 많이 번역하는 '번역가'가 되어야 오롯이 '나'로서 살 수 있게 될 것 같습니다.
🙋♂️ 최근 작성한 this 관련 글
Execution Context: https://velog.io/@minkwan/Execution-Context-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0
Prototype: https://velog.io/@minkwan/Prototype-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0