JS - this
this
제목이 너무 간략해서 쓰다 만건가 싶지만 다 쓴게 맞다. 이번에는 this에 대해서 알아보겠다.
우리가 흔히 자바스크립트는 객체지향 프로그래밍의 특성을 가지고 있다고 한다. 하지만 자바스크립트는 객체 지향 언어는 아니다. 그렇지만 그 특성은 갖고 있기 때문에 다른 객체 지향 언어에서도 사용하는 this를 가지고 있다. 그래서 다른 객체 지향 언어에서의 this는 class로 생성한 인스턴스 객체를 의미하기 때문에 class에서만 사용이 가능하다. 하지만 javascript는 실제로 객체 지향 언어가 아니기 때문에 this가 어디서든 사용이 가능하다.
javascrip가 어떻게 생기게 되었고, 왜 객체 지향 언어가 아님에도 그렇다고 알려졌는지 다른 포스트를 작성해보겠다.
그래서 전반적인 this의 정의에 대해서 알아보는 것이 아니라 javascript에서의 this는 어떻게 동작하고, 어떻게 대상을 지정해주는지 알아보겠다.
매번 바뀌는 this
위에서 언급한대로 자바스크립트에서 this는 어디서든 사용이 가능하다. 그럼 this가 바라보는 객체는 언제 결정될까? 기본적으로 실행 컨텍스트가 생성될때 결정된다. 앞의 포스팅에서도 얘기했듯이 실행 컨텍스트는 함수가 실행될때 생성됨으로, this는 함수를 호출할때 결정된다. 그래서 매번 함수가 실행되는 상황에 따라 this의 값이 바뀌기 때문에 각 상황들을 알아보겠다.
전역 공간에서 this
전역 공간에서 this는 전역 객체를 가리킨다. 왜냐하면 코드가 실행되면서 전역 컨텍스트를 생성하는 것은 전역 객체이기 때문이다. 그래서 전역 공간에서 this는 전역 객체를 가리키게 된다.
- 브라우저 환경 : window
- Node.js 환경 : global
console.log(this); console.log(window);
이렇게 전역 공간에서 this와 window를 동시에 호출하게 되면
이렇게 같은 window객체가 출력된다.
전역 공간의 특징
전역 공간을 언급한 김에 간단하게 정리해보겠다. 전역 공간에서 전역 변수를 선언하고 값을 할당하면 어떻게 될까?
var a = 1; console.log(a); // 1 console.log(window.a); // 1 console.log(this.a); // 1
모두 1이 나온다. 우리는 전역 객체에 값을 할당한 적이 없는데 전역 변수의 값이 저장된 것을 볼때, 자바스크립트는 변수를 객체의 프로퍼티로 동작한다는 사실을 알수가 있다.
그럼 어떤 객체에 저장을 하는걸까? 답은 앞의 포스팅에서 얘기했던 LexicalEnviroment이다. L.E는 선언한 변수를 envorimentRecord에 객체와 같은 구조로 저장을 한다. 그래서 변수 호출하면 변수를 조회하고, 일치하는 프로퍼티에 접근에 값을 반환한다. 전역 컨텍스트의 경우는 L.E가 전역 객체를 참조하게 된다. 그래서 전역 변수를 선언하면 자바스크립트 엔진은 이 값을 전역 객체의 프로퍼티로 할당하는 것이 된다.
이런 원리를 적용시켜보면 전역 공간에서 a를 호출하면 전역 컨텍스트에서 검색을 하게 되고, 전역 컨텍스트에 저장된 a는 전역 객체를 발견하고 반환한다. 즉, 우리가 전역변수를 호출할때에는 앞에 window가 생략된 것으로 보면 된다. 그럼 전역 변수를 할당하지않고 전역 객체인 window에 값을 할당하면 어떻게 될까?
window.b = 2; console.log(b, window.b, this.b); // 2, 2, 2
전역 변수를 할당했을때와 같이 나온다. 당연한 결과다. 위의 개념을 그대로 적용시켜보면 쉽게 추론된다. 반대로 삭제하면 어떨까?
var a = 1; delete window.a; // false console.log(a, window.a, this.a); // 1, 1, 1 --- window.b = 2; delete window.b; // true console.log(b, window.b, this.b); // Uncaught ReferenceError: b is not defined
전역 변수로 선언한 경우에는 삭제되지 않지만, 전역 객체의 프로퍼티로 할당한 경우에는 삭제가 된다. 이것은 사용자가 의도치않게 삭제할 가능성을 방지하기 위한 자바스크립트의 방어 전략이다. 전역 변수를 선언하면 자바스크립트 엔진이 이를 자동으로 전역 객체의 프로퍼티로 할당하면서 해당 프로퍼티의 configurable속성을 false로 정의하는 것이다.
함수와 메서드
다시 본론으로 돌아와서 this는 함수를 호출할때 결정된다고 했다. 함수는 함수로서 호출되거나 객체에서 메서드로 호출이 가능하다.
var test = function (num){ console.log(this, num) }; test(1); // Window {...}, 1 var obj = { method: test } obj.method(2) // {method: ƒ}, 2
1번은 test라는 함수를 함수로서 호출한 것이고, 2번은 test라는 함수를 obj의 메서드로 호출한 것이다. 같은 함수를 다르게 호출하면서 생기는 차이는 독립성이다. 함수는 독립적인 기능을 수행하고 메서드는 자신을 호출한 대상 객체에 관한 동작을 한다.
그래서 1번 test 함수는 독립적인 함수로서 전역 객체를 참조해 this가 window가 된것이고 2번 test 메서드는 obj를 참조해 this가 obj가 된것이다.
var obj = { method: test } obj.method(2) // {method: ƒ}, 2 obj[method](2) // {method: ƒ}, 2
객체의 메서드에 접근하는 방법은 점표기법과 대괄호법 두가지가 있는데 두가지 모두 동일한 결과를 만들어낸다.
메서드 내부의 this
만약 중첩 객체로 이뤄졌을때 메서드를 호출하면 this는 어떻게 될지 알아보겠다.
var test = function (){ console.log(this) }; var obj = { method: test, inner : { method2 : test } } obj.method(); // {inner: {…}, method: ƒ} obj.inner.method2(); //{method2: ƒ}
둘다 obj내부에 있어서 this를 호출하면 this가 같을 것 같지만 다른 결과를 나타낸다. method는 obj를 this가 obj가 지정되고 method2는 inner가 지정되었다. 이걸 통해 알수 있는 것은 메서드로 호출할때에는 메서드로 호출한 함수 바로 앞의 객체가 this가 된다.
메서드 내부 함수의 this
지금까진 어려운 부분이 없다. 왜냐하면 this를 추론하는 방법이 간단하게 점 앞에 있는 객체만 찾아가면 됬기 때문이다. 하지만 메서드 내부에서 함수를 호출하게 되면 어떻게 될지 알아보겠다.
우선 일반 함수에서 this는 전역 객체를 바라본다. 왜냐하면 호출하는 객체를 지정하지 않고 개발자가 직접 함수에 관여해 동작시켰기 때문에 자동으로 전역 객체를 바라보는 것이다. 하지만 자바스크립트의 개발자도 이 부분이 설계상 오류라고 얘기한다.
var obj1 = { outer: function () { console.log(this); // (1) {outer: ƒ} var innerFunc = function () { console.log(this); // (2) Window{...} } innerFunc(); var obj2 = { innerMethod : innerFunc }; obj2.innerMethod(); // (3) {innerMethod: ƒ} } } obj1.outer();
지금 obj1 객체의 메서드를 지정해줬고 메서드 내부에서 다른 함수를 정의해 함수로서 호출하고 obj2의 메서드로 지정해 메서드로 실행시켰다. 함수로 실행한 결과와 메서드로 실행한 결과가 달라진 것이다. 1,3번의 경우는 앞에 점을 통해 this를 바인딩할 주체를 정해줬지만 2번은 this의 주체를 정해주지 않았기 때문에 전역 객체가 바인딩된 것이다.
즉, 설계상의 오류라는 것은 함수를 실행하는 당시 주변 환경을 신경쓰지 않고, 함수를 호출할때 대상을 선택했는지에 따라 정해진다는 것이다. 이런 문제로 인해 자바스크립트의 this가 혼란을 야기하는 것이다.
메서드 내부 함수에서 this 사용하기
메서드 내부에서 함수를 실행할때 내가 원하는 환경, 메서드가 실행된 객체를 this로 주고 싶다면 어떻게 해야할까?
var obj1 = { outer: function () { console.log(this); // (1) {outer: ƒ} var innerFunc = function () { console.log(this); // (2) Window{...} } innerFunc(); var self = this; var innerFnc2 = function () { console.log(self); } innerFnc2() // (3) {outer: ƒ} } } obj1.outer();
간단하게 변수를 지정해 바인딩할 요소를 별도로 저장해주면 된다. 결국 this는 상위로 올라가면서 바인딩된 this가 있는지 확인하기 때문이다. 그래서 함수로 실행할때 전역객체를 바라보지 않도록 현재 스코프의 변수에 this의 데이터를 저장하고 그 변수를 불러오면 되는 것이다.
화살표 함수
이런 문제를 해소하고자 ES6에서 나온것이 화살표 함수이다.
var obj = { outer: function () { console.log(this); // (1) {outer: ƒ} var innerFunc = () => { console.log(this); // (2) {outer: ƒ} } innerFunc(); } } obj.outer();
화살표 함수는 실행 컨텍스트를 생성할때 this 바인딩 과정이 빠져 this가 전역 객체를 바라보는 문제를 해결했다. 그래서 화살표 함수는 자신만의 this를 가지지 않고 현재 속한 스코프의 this를 상속받게 되고 위의 예시에서 1,2번 둘다 같은 결과를 확인할 수 있다.
콜백 함수에서 this
콜백 함수는 이 다음에 더 자세히 정리할 예정이지만 간단하게 설명하자면 a함수의 제어권을 b함수에게 넘겨주는 경우를 콜백 함수라고 한다. 그래서 a함수는 b의 내부 로직에 의해 실행되고, this도 b에서 정한 대로 결정된다.
setTimeout(function () {console.log(this);}, 300); --- [1,2,3,4,5].forEach(function(i){ console.log(this,i); }); --- document.body.innerHTML += '<button id="a">클릭</button>'; document.body.querySelector('#a') .addEventListener('click', function(e){ console.log(this, e); });
콜백 함수의 예시 3가지이다. 첫번째 예시는 일정 시간후에 동작하는 콜백함수이지만 콜백 함수를 실행할때 대상이 될 this를 지정하지 않았다. 그래서 결과는 전역 객체이다. 두번째 예시는 배열을 순회하며 각 요소마다 콜백 함수를 실행한다. 이도 마찬가지로 this를 지정하지 않았기 때문에 전역 객체와 각 배열의 요소가 출력된다. 세번째 예시는 addEventListener의 this를 콜백에 전달하기 때문에 버튼을 클릭하면 이벤트를 등록한 엘리먼트와 이벤트와 관련된 정보가 출력된다.
정확하게 콜백 함수에서 this가 어떤 값인지 정하기는 어려우나 제어권을 가지는 함수가 결정하다는 사실을 알고 있어야 한다.
생성자 함수에서 this
무슨 종류가 이렇게 많나 싶지만 함수가 이만큼 많이 쓰인다는 거시다... 생성자는 나중에 더 자세히 정리해볼 예정이라 간단하게 정리해보겠다.
생성자 함수는 공통된 성질을 지니는 객체를 생성하는데 사용하는 함수다. 그래서 생성자를 class, class를 통해 만든 객체를 instance라고 한다. 좀더 쉽게 설명하면 공통적인 속성을 가진 객체를 만들때 하나씩 객체를 만들기 어려우니 생성자를 통해 공통적인 속성을 가진 틀을 만들고, 이 틀을 사용해 각각의 구체적인 인스턴스를 생성하는 것이다.
var Person = function (name, age){ this.gender = 'male'; this.name = name; this.age = age; }; var alex = new Person('alex', 15); console.log(alex); //{gender: 'male', name: 'alex', age: 15}
생성자 함수를 사용해 객체를 생성해봤다. 만약 Person이라는 함수를 그냥 실행했다면 this는 전역 객체가 지정되어서 전역 객체 프로퍼티에 값이 추가되야 한다. 하지만 new 명령어와 함께 사용하게 되면 생성자로서 함수가 실행되게 되고, this는 새로 만들어질 인스턴스를 바라보게 된다. 그래서 결과를 보면 객체가 잘 생성된 것을 볼수 있다.
생성자 함수를 호출(new와 함께)하면 생성자의 prototype 프로퍼티를 참조하는 proto 프로퍼티가 있는 객체를 만들고 준비된 공통 속성 및 개성을 객체(this)에 부여한다. prototype과 __proto__는 나중에 다시 정리하겠다.
명시적으로 this 바인딩하기
매번 this를 활용할때마다 어떤 값이 this에 들어갈지 예상해서 해도 되지만 상당히 규칙이 변칙적이라 예상하기 어려울것이다. 그래서 사용자가 직접 this를 명시할 수가 있다.
call
call메서드는 호출 주체인 함수를 직시 실행하도록 하는 명령어이다. 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
그냥 함수를 실행하면 앞에서 본것처럼 this는 전역 객체, window를 참조한다. 반면에 함수에 call 메서드를 사용하면 첫번째 인자인 {x:1}을 this로 지정한다. 명시한 this를 활용하면
var func = function(b, c){ console.log(this.a, b, c,); } func.call({a : 1}, 2, 3); // 1,2,3
이렇게 this의 프로퍼티로 접근이 가능해진다.
apply
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
call과 apply의 활용
이 두가지 메서드를 활용하면 좀더 다양하게 객체를 다룰수 있게된다.
var obj = { 0 : 'a', 1 : 'b', 2 : 'c', length : 3 } Array.prototype.push.call(obj, 'd'); console.log(obj); // {0: 'a', 1: 'b', 2: 'c', 3: 'd', length: 4}
obj는 객체의 형태이지만 key가 0 또는 양의 정수인 프로퍼티가 존재하고 length 프로터티의 값이 0 또는 양의 정수인 유사 배열 객체이다. Array.prototype.push는 배열에서 사용가능한 메서드이기 때문에 객체에서는 사용할수 없지만 call과 apply는 유사배열객체에 배열 메서드를 적용할 수 있다. 그래서 실제 배열에 push한것처럼 객체에 프로퍼티가 추가된 모습이다.
유사배열객체에 사용 가능하기 때문에 함수 내부 객체인 arguments에도 사용가능하다.
function a (){ var argv = Array.prototype.slice.call(arguments); argv.forEach(function (arg) { console.log(arg); }) } a(1,2,3); // 1, 2, 3
a 함수에 전달한 1,2,3이 arguments에 담기고, arguments를 slice 메서드를 통해 배열로 변환되어 forEach가 적용된 결과이다.
그 외에도 배열처럼 인덱스와 length를 가지는 문자열에도 적용된다. 하지만 문자열에서 length는 읽기 전용이기 때문에 배열의 구조를 수정하는 push,pop과 같은 메서드는 에러가 발생한다.
var string = 'hello'; var newArr = Array.prototype.map.call(string, function(str){ return str + '1' }); console.log(newArr); //['h1', 'e1', 'l1', 'l1', 'o1']
call이나 apply를 통해 배열 메서드를 사용하는 방식들은 형식을 변환하기 위한 수단이다. 그래서 코드를 작성한 개발자가 아니라면 본래의 의도를 바로 파악하기 어려울 것이다. 그래서 ES6에서는
Array.from()를 통해 유사배열을 배열로 전환하는 메서드가 추가되었다.
생성자 내부에서 다른 생성자를 호출할때에도 유용하게 사용 가능하다.
function Person(name, gender){ this.name = name; this.gender = gender; } function Student(name, gender, school){ Person.call(this, name, gender); this.school = school; } function Employee(name, gender, company){ Person.apply(this, [name, gender]); this.company = company; } var fisrt = new Student('상준', 'male', '하버드'); var seconde = new Employee('상순', 'female', 'google'); console.log(fisrt); // {name: '상준', gender: 'male', school: '하버드'} console.log(seconde); // {name: '상순', gender: 'female', company: 'google'}
첫번째 생성자 함수가 호출되면 생성될 인스턴스가 this로 전달되고, 두번째 생성자 함수에 동일한 this를 전달해주는 방식으로 구현되어 있다. 이제 같은 속성을 가진 객체를 쉽게 생성할 수 있다.
bind
call과 apply는 마무리하고 bind메서드를 알아보겠다. call과 비슷하지만 call은 즉시 함수를 호출하는 반면, bind는 새로운 함수를 반환하는 메서드이다.
var func = function (a, b, c, d){ console.log(this, a, b, c, d); }; var bindFunc1 = func.bind({x : 1}); bindFunc1(4, 5, 6, 7); // {x: 1} 4 5 6 7 var bindFunc2 = func.bind({x : 1}, 4, 5) bindFunc2(7, 8); // {x: 1} 4 5 7 8
call과 동일하게 첫번째 값은 this로 적용하고 뒤에 오는 인자들은 호출할 함수에 차례로 등록된다. 즉, bind는 함수에 this를 미리 적용하고 부분 적용 함수를 구현하는 두가지 목적을 가진다.
bind를 통해 만들어진 함수는 특징이 있다. 일반 함수에 name 프로퍼티를 출력하면 해당 함수의 함수명이 그대로 나오지만, bind로 만들어진 함수는
bound 함수명으로 출력된다. 코드를 추적하기에 좋은 기능이다.
call, apply, bind로 상위 컨텍스트 this 전달하기
초중반에 상위 컨텍스트의 this를 전달하기 위해 함수 내부에서 self라는 변수를 선언해 상위 컨텍스트의 this를 전달시키는 방법을 사용해봤다. 하지만 지금까지 학습한 메서드를 활용하면 더 명확하고 깔끔하게 처리가 가능하다.
var obj1 = { outer: function () { console.log(this); var innerFunc = function () { console.log(this); // } innerFunc.call(this); // === innerFunc.apply(this); } } --- var obj1 = { outer: function () { console.log(this); var innerFunc = function () { console.log(this); // }.bind(this); innerFunc(); } }
전의 코드와 비교해보면 코드량도 줄어들고 가독성도 좋아졌다. 콜백함수에 bind를 적용해보겠다.
var obj = { func1 : function(){ console.log(this); }, bindFunc : function(){ setTimeout(this.func1.bind(this), 1000); } } obj.bindFunc(); // {func1: ƒ, bindFunc: ƒ}
call, apply와 다르게 함수를 새로 반환하기 때문에 콜백함수에 전달도 가능하다.
마무리
개념이 어렵다기보단 내용을 정리하고 코드를 작성하다보니 길어지게 되었다 간단하게 정리해보겠다.
- 전역 공간에서 this는 전역 객체를 가리킴(브라우저: window, Node.js: global)
- 함수를 메서드로 호출한 경우, this는 메서드 호출 주체를(점이나 대괄호로 지정한 메서드 앞 객체) 참조함
- 함수를 함수로 호출한 경우 전역 객체를 가리킴
- 콜백 함수 내부에서는 함수의 제어권을 넘겨받은 함수가 정의한 this를 따르고, 정의하지 않은 경우에는 전역 객체
- 생성자 함수에서는 생성될 인스턴스 참조
this라는 기능이 어떤 상황에서든 상위 객체를 바라보면 좋겠지만 위와 같은 복잡한 규칙이 생기는 바람에 헷갈리는 것 같다. this에 대해서 공부하다보니 화살표 함수의 감사함을 느낀다. this를 가지지도 않고 검색하면서 만나는 첫번째 this를 참조하다니 말이다. 코드를 작성하면서 this를 직접적으로 사용해본 적은 없는 것 같다. 하지만 앞으로 메서드를 더 적극적으로 사용하게 된다면 꼭 필요한 지식이라고 생각한다.
다음은 콜백함수다. 코어 자바스크립트의 구성이 확실히 계단형이라는 것을 느낀다. 앞의 내용을 전혀 모른다면 이 내용을 과연 이해할지가 의문이 든다. 앞으로 절반 넘게 남았는데 더 힘내서 학습해보자.
개의 댓글
1
this
매번 바뀌는 this
전역 공간에서 this
전역 공간의 특징
함수와 메서드
메서드 내부의 this
메서드 내부 함수의 this
메서드 내부 함수에서 this 사용하기
화살표 함수
콜백 함수에서 this
생성자 함수에서 this
명시적으로 this 바인딩하기
call
apply
call과 apply의 활용
bind
call, apply, bind로 상위 컨텍스트 this 전달하기
마무리