어서와, 개발은 처음이지?

javascript this의 4가지 동작 방식 본문

[기획] 누구도 알려주지 않은 이야기

javascript this의 4가지 동작 방식

오지고지리고알파고포켓몬고 2020. 1. 29. 22:02
반응형



이번 글에서는 자바스크립트 this가 어떻게 동작하는지 알아보겠습니다.

프로토타입을 먼저 쓸지, this를 먼저 쓸지 고민했는데 아무래도 this가 좀 더 쉬울 것 같네요



1. 오해


흔히(java에서) 클래스 내에서 사용하는 this 문법은 클래스(자세히는 인스턴스화 된 객체) 자기 자신을 뜻합니다.

javascript에서도 this문법이 존재하는데, 하필 'java' script라는 비슷한 이름에, this가 존재하니 클래스 내 this처럼 동작을 할 것 같은 오해를 불러일으킵니다.

(자바 안쓴지 너무 오래되서 용어 혼란이 있을 수 있습니다. 잘못된 부분은 댓글로 남겨주세요!)


물론 javascript에서 function과 this 문법, new 키워드를 사용해서 '클래스와 객체 생성' 형태를 묘사(다음 글에 얘기할 prototype과 연관되는 구현입니다)할 수 있지만, javascript의 this는 더 다양한(통상 네가지) 방법으로 사용될 수 있습니다.


여담으로 you don't know js의 저자 카일 심슨님은 책을 통해서 'javascript에서 클래스처럼 보이는 구문은 개발자들이 클래스 디자인 패턴으로 코딩할 수 있도록 자바스크립트 체계를 억지로 고친 것'이라고 말했습니다.

(또한 클래스 패턴에 익숙해진 우리들에게 js를 js 답게 사용할 수 있는 방법에 대해서 많은 이야기를 했습니다.)


저도 이러한 철학에 공감하며, 굳이 oop 사상에 맞추기보다 js를 js답게 쓸 수 있도록 시도하는 편 입니다.

(얕은 예지만 react에서 클래스형 컴포넌트보다 함수형 컴포넌트를 지향하고, class extends 같은 상속보다는 object에 작동 위임하는 방식으로 사용하는 등)


잠시 딴길로 샜지만 this를 잘 이해하면 재밌고 다양하게 js 프로그래밍을 하는데 도움이 될 것입니다.



2. this에 대한 선행 정보


this는 객체와 연관이 깊습니다.


예를들어 var a = {name: "yuddomack"}이라는 객체가 있고, 특정 상황을 만족할 경우 this.name을 호출하면 "yuddomack"이 출력되는 것이죠.

미리 언지를 드리자면 변수 a를 this의 콘텍스트(context) 객체라고 할 수 있습니다.


아래부터 콘텍스트라는 단어를 사용할텐데, this가 바라보고 있는 어떤 객체 정도로 상상하시면 이해하기 수월할 것 같습니다.


지금부터 this의 4가지 동작 방식에 대해 살펴보도록 하겠습니다.


** 주의 - 파일 생성해서 node 명령으로 실행할경우(예, > node test.js) context가 module.exports를 가르키기 때문에 

원활한 진행을 위해 크롬 콘솔창, 혹은 node REPL 환경(node 쉘)에서 테스트하시길 바랍니다.



3. this의 첫번째 동작 방식 - 기본 바인딩(전역객체)


일단 this를 한번 찍어봅시다.

지금 곧장 크롬 개발자 도구의 콘솔에서 console.log(this)를 쳐봅니다.


<개발자 도구의 this>


위와 같은 화면이 보이시나요?


다음은 node 쉘에서 this를 쳐봅니다.

<node의 this>


저것들의 정체는 무엇일까요?

이들은 javascript 실행 환경의 전역 객체입니다.(크롬 브라우저의 경우 window 객체)


그렇습니다. this의 첫번째 동작 방식은, this가 전역 객체(window)를 context 객체로 갖는 것 입니다.


아래 코드를 실행해보겠습니다.

var g = 20;
console.log(this.g); // 20

function doSomething() {
  this.dummy2 = "가을";
  console.log(this); // window 객체
}

console.log(this.dummy1); // undefined
console.log(this.dummy2); // undefined

this.dummy1 = "겨울";

console.log(this.dummy1); // 겨울
console.log(this.dummy2); // undefined

doSomething();

console.log(this.dummy1); // 겨울
console.log(this.dummy2); // 가을

<크롬 브라우저 콘솔 / nodejs REPL>


잠시 눈여겨 봐야할 사실로, 전역 스코프에서 정의한 변수들은 전역 객체에 등록됩니다.


때문에 var g = 20은 window.g = 20과 같고,

this 객체에 프로퍼티(dummy1, dummy2)를 등록하는 것은 사실상 전역 스코프에서 변수를 선언한 것과 같다는 이야기입니다.


추가로 전역 객체는 우리가 알고있는 일반 객체(object)처럼 아무 제약 없이 동작하기 때문에 this를 무분별하게 사용할 경우, 전역 상태에 영향을 끼칠 수 있으므로 주의해야겠습니다.




4. this의 두번째 동작 방식 - 암시적 바인딩


암시적 바인딩을 간단하게 표현하자면 아래와 같습니다.

function test() {
  console.log(this.a);
}

var obj = {
  a: 20,
  func1: test,
  func2: function() {
    console.log(this.a);
  }
};

obj.func1(); // 20
obj.func2(); // 20

어렵게 생각하면 한없이 어려워질 것 같으니 간단하게 정의해보겠습니다.


어떤 객체를 통해 함수가 호출된다 그 객체가 바로 this의 context 객체가 됩니다.

위 코드의 func1, func2는 obj를 통해 호출되었으므로, obj가 this가 된다는 뜻입니다.


이 이야기는 곧 첫번째 동작 방식(기본 바인딩)과 연관지을 수 있는데, 아래 코드를 보겠습니다.

var b = 100;

function test() {
  console.log(this.b);
}

var obj = {
  a: 20,
  func1: test,
  func2: function() {
    console.log(this.b);
  }
};

obj.func1(); // undefined
obj.func2(); // undefined

var gFunc1 = obj.func1;
gFunc1(); // 100

함수 내 this.a가 this.b로 변경됐습니다.


암시적 바인딩의 첫번째 예제를 보고 온 우리는 obj.func1과 obj.func2는 undefined를 출력하는게 이해되지만, gFunc1이 100이 출력되는건 혼란스럽습니다.


다시 첫번째 동작에서 봤던 내용을 더듬어봅니다....... 전역 스코프에서 생성한 변수는 전역 객체에 등록된다..

느낌이 오시나요?


var gFunc1는 window.gFunc1과 같고, gFunc1에서 this context는 다시 전역객체(window)가 되기 때문에, window.gFunc1()과 같아지는 것 입니다.

즉 아래 코드와 같습니다.

var b = 100;

function test() {
  console.log(this.b);
}

var obj = {
  a: 20,
  func1: test,
  func2: function() {
    console.log(this.b);
  }
};

obj.func1(); // undefined
obj.func2(); // undefined

this.gFunc1 = obj.func1; // 실상 전역객체에 프로퍼티를 등록한 것과 같다 -> window.gFunc1 = obj.func1과 같다
this.gFunc1(); // 100

그리고 헷갈림의 여지를 남기지 않기위해 모종의 객체를 생성한 예를 하나 더 작성하겠습니다.

아래 obj2를 전역객체라고 상상하고 코드를 읽어보시기 바랍니다.

function test() {
  console.log(this.b);
}

var obj1 = {
  b: 10,
  func: test
};

var obj2 = {
  b: 40
};

obj2.func = obj1.func; // var func = obj1.func 와 같다
obj2.func(); // 40

결론적으로 this를 그냥 사용하면 암시적으로 전역 객체에 바인딩이 되는 것 입니다.


이제 얼추 정리가 되었으니 다음으로 넘어가봅니다.(아니라면 댓글로..)



5. this의 세번째 동작 방식 - 명시적 바인딩


명시적 바인딩은 가장 깔끔하게 말씀드릴 수 있습니다.


함수(함수 객체)는 call, apply, bind 메소드를 가지고 있는데, 첫번째 인자로 넘겨주는 것이 this context 객체가 됩니다.

이를 명시적 바인딩이라고 합니다.

function test() {
  console.log(this);
}

var obj = { name: "yuddomack" };
test.call(obj); // { name: 'yuddomack' }
test.call("원시 네이티브 자료들은 wrapping 됩니다"); // [String: '원시 네이티브 자료들은 wrapping 됩니다']


아! 더할나위 없다!

더 설명할게 없습니다.


정말 명시적이죠?



6. this의 네번째 동작 방식 - new 바인딩


대망의 마지막 new 바인딩입니다.


new 바인딩은 초반에 말했던 클래스 디자인 패턴 형태를 띄고있습니다.

function foo(a) {
  this.a = a;
  this.qwer = 20;
}

var bar = new foo(2);
console.log(bar.a); // 2
console.log(bar.qwer); // 20

으아~ 정말 이 코드를 보면 javascript에 클래스라도 있는 듯 보입니다.

하지만 동작 순서를 살펴보면 클래스가 없다는 것을 곧 깨닫게됩니다.


new 바인딩의 동작순서를 글로 표현하면 아래와 같습니다.


1. 새 객체가 만들어짐 

2. 새로 생성된 객체의 Prototype 체인이 호출 함수의 프로토타입과 연결됨 

3. 1에서 생성된 객체를 this context 객체로 사용(명시적으로)하여 함수가 실행됨

4. 이 함수가 객체를 반환하지 않는 한 1에서 생성된객체가 반환됨


이 이야기를 코드로 표현해보겠습니다.

function foo(a) {
  this.a = a;
  this.qwer = 20;
}

var bar1 = new foo(2);
console.log(bar1.a); // 2
console.log(bar1.qwer); // 20

// 1. 새 객체가 만들어짐
var obj = {};
// 2. 새로 생성된 객체의 Prototype 체인이 함수의 프로토타입과 연결됨
Object.setPrototypeOf(obj, foo.prototype); // 프로토타입을 연결합니다. 이 글에서는 무시해도 상관없습니다.
// 3. 1에서 생성된 객체를 context 객체로 사용(명시적으로)하여 함수가 실행됨
foo.call(obj, 2);
// 4. 이 함수가 객체를 반환하지 않는 한 1에서 생성된 객체가 반환됨
var bar2 = obj; // 여기서 foo는 반환(return)이 없으므로 인스턴스가 생성(된 것처럼 동작)

console.log(bar2.a); // 2
console.log(bar2.qwer); // 20

어떠신가요? 이제 눈에 좀 들어오나요?

new로 객체를 생성하(하는 듯 착각을 일으키)면서 실질적으로 위와 같은 과정이 일어나는 것 입니다.


console.log를 추가하고 4번 케이스를 만들어주면 그저 함수 실행일 뿐이라는게 더 명확합니다.

function foo(a) {
  this.a = a;
  this.qwer = 20;

  console.log("js : 클래스는 없다, 그저 함수를 실행할뿐");
  return { dummy: "이보시게 관상가양반! 내가 뭘 뱉겠소?" };
}

var bar1 = new foo(2);
console.log(bar1); // { dummy: "이보시게 관상가양반! 내가 뭘 뱉겠소?" }

위 코드를 실행하면 console.log가 그대로 출력되는데, new foo()의 실행은 그저 foo 함수의 실행과 다를게 없는 것을 볼 수 있습니다.


더불어 우리가 생각하는 클래스 패턴과 다르게 return에 있는 객체가 반환됩니다.


'js에 클래스 따윈 클래스는 없어!'라는 뉘앙스가 아니라 js의 new 키워드는 클래스의 인스턴스 화가 아니라 그와 유사하게 동작하도록 되어있다는 부분을 집고 넘어가기 위함이니 감안해주시기 바랍니다.




7. 여기서 제일 쌘놈이 누구냐?


자 그럼 위 케이스를 섞어쓰(지 않기를..)면 어떻게 될까요?

this 바인딩에 순위를 매긴다면 new 바인딩 >= 명시적 바인딩 >>>>>> 넘사벽 >>>>>> 암시적 바인딩 >= 기본 바인딩으로 볼 수 있겠습니다.


기본 바인딩은 new 바인딩 / 명시적 바인딩 / 암시적 바인딩 케이스가 있을경우 발현되지 않기에 논외로 치겠습니다.(기본 바인딩이 암시적 바인딩의 일부임을 우리는 알고있죠)


우선 명시, new 바인딩 vs 암시를 비교해봅시다.

function foo(something) {
  this.a = something;
}

var obj1 = {
  foo: foo
};

obj1.foo(2); // 암시
console.log(obj1.a); // 2

var obj2 = {};

obj1.foo.call(obj2, 3); // 암시 속에서 obj2라고 명시
console.log(obj1.a); // 2
console.log(obj2.a); // 3

var obj3 = new obj1.foo(10);
console.log(obj1.a); // 2
console.log(obj2.a); // 3
console.log(obj3.a); // 10

obj1.foo에서 암시적 바인딩을 통해 obj1에 a프로퍼티가 생겨난 것을 볼 수 있습니다.


다음으로 암시적 바인딩 되어있는 obj1.foo 함수를 call 메서드로 명시적으로 호출합니다.

그 결과 context 바인딩이 obj1에 적용되는 것이 아니라 obj2에 적용되었습니다. (명시적 > 암시적이 성립합니다.)


다음으로 암시적 바인딩이 되어있는 obj1.foo 함수를 new 키워드로 호출했습니다.

그 결과 context 바인딩이 obj1에 적용되는 것이 아니라 새로 생성된 객체에 적용(obj3)됩니다. (new 바인딩 > 암시적이 성립합니다.)




암시적 바인딩이 3인자임을 알게됐으니 최후의 승자를 가려봅시다.


명시 vs new 바인딩

function foo(something) {
  this.a = something;
}

var obj1 = {};

var bar = foo.bind(obj1); // 명시
bar(2);
console.log(obj1.a); // 2

var baz = new bar(3); // 명시에 new를 시도
console.log(obj1.a); // 2
console.log(baz.a); // 3

foo.bind(obj1)로 foo의 context를 obj1로 명시한 bar가 있습니다.

당연스럽게도 bar를 실행하면 명시된 콘텍스트인 obj1에 a 프로퍼티가 등록됨을 볼 수 있습니다.


다음으로 명시된 bar를 new 키워드로 호출했습니다.

그 결과 context 바인딩이 obj1이 아닌 새로 생성된 객체에 적용(baz)됩니다.


결과론적으로 봤을때 new 바인딩 > 명시적 바인딩 > 암시적 바인딩 > 기본 바인딩이라고 볼 수 있겠으나, new 바인딩은 실질적으로 새로운 객체를 생성하고 그 객체를 (명시적)바인딩하는 것이기 때문에 new 바인딩 >= 명시적 바인딩 이라고 표현해봤습니다.



8. 끝난줄 알았지..? (Arrow Function)


바인딩 케이스는 모두 알아봤지만 꼭 짚고 넘어가야하는 부분이 있습니다.


화살 함수, 화살표 함수, 람다 함수, 에로우 펑션 등으로 표현할 수 있는 이 문법에서의 this는 특별하게 동작합니다.


에로우 펑션이 선언된 부분 스코프의 this context를 this context로 사용합니다.

var a = 10;
var b = 20;
var obj = {
  a: 1,
  func: () => console.log(this.a)
};

obj.func(); // 10

function test() {
  console.log(this);
  return () => console.log(this.b);
}
var f1 = test(); // 전역 객체(window)
f1(); // 20

var context = { b: 999 };
var f2 = test.call(context); // {b: 999}
f2(); // 999

function test2() {
  console.log(this);
  return function() {
    console.log(this.b);
  };
}

var f3 = test2(); // 전역 객체(window)
f3(); // 20
var f4 = test2.call(context); // {b: 999}
f4(); // 20 <- window.f4()

첫째로 obj.func (암시적 바인딩 상황)이지만, 글로벌 스코프에서 생성되어있으므로 1이 아닌 10이 출력됩니다.

둘째로 f2에서 바인딩 된 this context를 에로우 펑션이 가지고 있는 반면, f4에서 바인딩 된 this context를 익명함수가 참조하지 못하는 차이를 볼 수 있습니다.


이러한 이유로 react나 vue에서 하위 컴포넌트로 함수를 전달할 때, 상위 컴포넌트의 컨텍스트를 this 사용하기 위해 에로우 펑션이 사용되는 모습을 자주 볼 수 있습니다. (저는 명시적인걸 좋아해서 bind 메소드를 선호합니다.)



9. 마치며


이로써 this의 4가지 동작방식에 대해 모두 알아봤습니다.


원리도 간단하고 스웩있는 코드를 짤 수 있게 해주지만, this를 부분별하게 사용하면 코드 레벨에서 추노(코드 분석 / 눈버깅)가 굉장히 피곤하기 때문에 this는 최대한 명시적으로 사용하는 것이 좋겠습니다.


다음 글에서는 프로토타입과 작동위임에 대한 내용을 다뤄보겠습니다.

Comments