티스토리 뷰

CHAPTER 1 this에 대한 오해들

function identify() {
  return this.name.toUpperCase();
}

function speak() {
  let greeting = 'hello, I am' + identify.call(this);
  console.log(greeting);
}

let me = {
  name: 'alex',
};

let you = {
  name: 'rachel',
};
identify.call(me);
identify.call(you); //me & you 의 이름 대문자변환
speak.call(me); //hello, I amALEX
speak.call(you); //hello, I amRACHEL

-> indentify()와 speak() 두 함수는 객체별로 따로따로 함수를 작성할 필요없이 다중 콘택스트 객체인 me와 you 모두에서 재사용이 가능합니다. 

 

this를 쓰지않고 indentify()와 speak() 함수에 콘텍스트 객체를 명시할 수도 있습니다.

function identify(context) {
    return context.name.toUpperCase();
  }

  function speak(context) {
    let greeting = 'hello I am ' + identify(context);
    console.log(greeting);
  }

  let you = {
    name: 'koala',
  };

  identify(you);
  speak(you);

-> 하지만 암시적인 객체 레퍼런스를 함께 넘기는 this 체계가 API 설계상 좀 더 깔끔하고 명확하며 재사용하기 쉽습니다. 

 

사용 패턴이 복잡해질수록 보통 명시적인 인자로 콘텍스트를 넘기는 방법이 this 콘텍스트를 사용하는 것보다 코드가 더 지저분합니다. 객체와 프로토타입을 배우고 나면 여러 함수가 콘텍스트 객체를 자동 참조하는 구조가 얼마나 편리한 지 알 수 있습니다. 

 

1.2 헷갈리는 것들 

this의 의미에 대해서 논의

 

1.2.1 자기 자신

우선 this가 함수 그 자체를 가리킨다는 것은 오해입니다. 함수가 내부에서 자기 자신을 가리킬일이 있을까? 재귀함수 로직이 들어가는 경우도 있고 최초 호출 시 이벤트에 바인딩 된 함수 자신을 언바인딩 할 때도 자기 참조가 필요합니다. 

function foo(num) {
    console.log('foo' + num);
    //foo가 몇번 호출 ?
    this.count++;
  }

  foo.count = 0;
  let i;
  for (i = 0; i < 10; i++) {
    if (i > 5) {
      foo(i);
    }
  }

  // foo6, foo7, foo8, foo9
  console.log(foo.count); //0

-> 분명 콘솔로그에 4번의 foo() 함수 호출횟수가 표시됐는데 foo.count의 값은 0 입니다. 

this.count++에서의 this를 글자 그대로 해석하면 헷갈릴 수 있습니다.

foo.count= 0 을 하면 foo라는 객체에 count 프로퍼티가 추가되는데 this.count에서 this는 함수 객체를 바라보는 것이 아니며, 프로퍼티 명이 똑같아 헷갈리지만 근거지를 둔 "객체" 자체가 다릅니다. 

 

이 부분에서 this의 참조가 왜 이상하게 이루어졌는 지 스스로 답을 차지않고 이슈 자체를 피하거나 count 프로퍼티를 다른 객체로 옮기는 등의 우회책을 사용합니다. 이렇게해서 해결은 가능하지만 this가 무엇이고, 작동원리가 무엇인지 모른 채 문제의 본질을 벗어나 렉시컬 스코프라는 편리한 장치에 편승한 셈입니다. 

함수가 내부에서 자신을 참조할 때 일반적으로 this만으로는 부족하며 렉시컬 식별자(변수)를 거쳐 함수 객체를 참조합니다. 

함수 객체를 직접 가리키도록 강제하는 것도 방법입니다 . (call 함수를 사용)

  • 코드에는 중략되어있지만 this.count++ 가 들어간 위의 로직과 동일합니다. 반복문 내부에 foo.call()을 통해 해당 객체로 바인딩하였습니다. 
  • call()과 apply()는 거의 동일하지만 call은 인수 목록을 받고 apply는 인수 배열 하나를 받는다는 점이 가장 큰 차이점입니다. 
  • call은 이미 할당되어있는 다른 객체의 함수/메소드를 호출하는 해당 객체에 재할당할 때 사용됩니다. this는 현재 객체를 참조합니다. 
for(i=0; i<10; i++){
if(i>5){
	//call() 함수로 호출하므로 this는 확실히 함수 객체 foo 자신을 가리키게 됩니다.
    foo.call(foo, i); //강제로 foo 객체를 가리키게
}
}
console.log(foo.count); //4

1.2.2 자신의 스코프

this가 바로 함수의 스코프를 가리킨다는 말은 아주 흔한 오해입니다. 어떤 면에서는 맞다고 하네요.

분명한 건 this는 어떤 식으로도 함수의 렉시컬 스코프를 참조하지 않습니다. 사실 내부적으로 스코프는 별개의 식별자가 달린 프로퍼티로 구성된 객체의 일종이나 스코프 "객체"는 자바스크립트 구현체인 '엔진'의 내부 부품이기 때문에 일반 자바스크립트로는 접근하지 못합니다.

렉시컬 스코프 안에 있는 뭔가를 this 레퍼런스로 참조하기란 애당초 가능하지 않습니다. 
this와 렉시컬 스코프 참조가 계속 헷갈리는 분들은 "연결 통로 따윈 없어!"를 되뇌기 바란다고 하네요.

1.3 this는 무엇인가? 

this는 작성 시점이 아닌 런타임 시점에 바인딩되며 함수 호출 당시 상황에 따라 콘텍스트가 결정됩니다. 

함수 선언 위치와 상관없이(렉시컬 스코프x) this 바인딩은 오로지 어떻게 함수를 호출했느냐(동적 바인딩)에 따라 정해집니다. 

정리: this가 함수 자신이나 함수의 렉시컬 스코프를 가리키는 레퍼런스가 아니라는 점을 분명히 인지해야 한다. 
this는 실제로 함수 호출 시점에 바인딩되며 무엇을 가리킬지는 전적으로 함수를 호출한 코드에 달렸다. 
-> this는 함수를 호출할 때 바인딩된다.(런타임에)

CHAPTER 2 this가 이런 거로군

2.1 호출부

this 바인딩을 이해하려면 선언부가 아니라 호출부 코드를 보며 this가 가리키는 것이 무엇인지 봐야합니다. 

코딩 패턴에 따라 '진짜'호출 부가 어디인지 모호할 때가 있어 생각만큼 쉽지 않습니다.

중요한 건 호출 스택(현재 실행 지점에 오기까지 호출된 함수의 스택)을 생각해보는 것입니다. 이 중 호출부는 현재 실행중인 함수 '직전'의 호출 코드 '내부'에 있습니다. 

호출 스택을 눈으로 따라가며 체크하는 것은 디버깅시 착오를 일으킬 수 있으므로 개발자툴을 사용하여 중단점을 걸고 호출 스택을 확인하고 맨 위부터 아래로 쭉 내려오면서 실제 호출부를 찾으면 됩니다. 
한 단계씩 실행시키면 [Call Stack] 영역에 호출 슽택이 차례로 쌓이는 모습을 볼 수 있는데 호출 스택 영역에서 한 줄씩 클릭하면서 우측 [Scope Variables] 영역에서 this 바인딩과 변수 스코프를 확인합니다.

2.2 단지 규칙일 뿐 

함수가 실행되는 동안 this 가 무엇을 참조할 지 호출부가 어떻게 결정되는 지 알아보려면 4가지 규칙중 어느 것에 해당하는 지 확인하면 됩니다. 여러 규칙이 중복으로 적용될 경우 우선순위를 살펴봅시다. 

 

2.2.1 기본 바인딩(1번째 규칙)

첫번째 규칙: 가장 평범한 함수 호출인 '단독 함수 실행' 에 관한 규칙으로 나머지 규칙에 해당하지 않을 경우, 적용되는 this의 기본규칙입니다.

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

  var a = 2; //let으로 선언한 변수는 undefined가 뜬다.

  foo(); //2, 함수 래퍼런스로 호출

-> var a =2 처럼 전역 스코프에 변수를 선언하면 변수명과 같은 이름의 전역 객체 프로퍼티가 생성됩니다. 이는 서로의 사본이 아니고 같은 동전의 앞뒷면이라 보면 된다. 그리고 foo()함수 호출 시, this .a는 전역 객체 a입니다. 기본 바인딩이 적용되어 this는 전역 객체를 참조합니다. 

strict mode에서는 전역 객체가 기본 바인딩 대상에서 제외됩니다. 그래서 이 모드에서 this는 undefined 가 됩니다. 

🐱‍👤보통 this 바인딩 규칙은 오로지 호출부에 의해 좌우되지만 비엄격모드에서 foo() 함수의 본문을 실행하면 전역 객체만이 기본 바인딩의 유일한 대상이라는 점을 기억하세요. foo() 호출부의 엄격 모드 여부와는 상관없습니다. 

2.2.2 암시적 바인딩(2번째 규칙)

두 번째 규칙: 호출부에 콘텍스트 객체가 있는지, 즉 객체의 소유/포함 여부를 확인하는 것입니다.

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

  //객체는 어차피 블록 스코프이기 때문에 var이나 let이나 상관없이
  //동일하게 동작합니다.
  let obj = {
    a: 2,
    foo: foo,
  };
  obj.foo(); //2

-> 앞에서 선언한 foo()함수를 obj객체에서 프로퍼티로 참조하고 있습니다. foo()를 처음부터 foo 프로퍼티로 선언하든 이 예제처럼 나중에 레퍼런스로 추가하든 obj객체가 이 함수를 정말로 '소유'하거나 '포함'한 것은 아닙니다. 그러나 호출부는 obj 콘텍스트로 foo()를 참조하므로 obj객체는 함수 호출 시점에 함수의 레퍼런스를 소유하거나 포함한다고 볼 수 있습니다. 

//객체 프로퍼티 참조가 체이닝된 형태 
  let obj2 = {
    a: 42,
    foo, // foo: foo 와 같음
  };
  let obj3 = {
    a: 1,
    obj2,
  };

  obj3.obj2.foo(); //42

-> 위처럼 객체 프로퍼티 참조가 체이닝된 형태라면 최상위/최하위 수준의 정보만 호출부와 연관된다. 

즉, obj3.a 는 중간단계이므로 무시되고 obj2.a 의 값인 42가 출력된 것입니다. 

암시적 소실

암시적으로 바인딩(implicitly bound)된 함수에서 바인딩이 소실되는 경우가 있는데 this 바인딩이 뜻밖에 헷갈리기 쉬운 경우입니다. 엄격 모드 여부에 따라 전역 객체나 undefined 중 한 가지로 기본 바인딩됩니다

function fooTest() {
    console.log(this.test1);
  }

  let objTest = {
    test1: 2,
    fooTest,
  };

  var bar = objTest.fooTest; //함수 레퍼런스/별명
  var test1 = '전역이란다.'; 
  bar(); //전역이란다

-> bar는 objTest의 fooTest를 참조하는 변수처럼 보이지만 실은 fooTest를 직접 가리키는 또 다른 레퍼런스입니다. 게다가 호출부에서 그냥 평범하게 bar()를 호출하므로 기본 바인딩이 적용된 것입니다. 

 

인자로 전달하는 건 일종의 암시적인 할당입니다. 함수를 인자로 넘기면(콜백함수) 암시적으로 레퍼런스가 할당되어 결과는 마찬가지로 전역을 가리키게 됩니다. 

콜백을 받아 처리하는 함수가 내장 함수라도(e.g. setTimeout(콜백, 타이머)) 마찬가지입니다. obj.foo()시 obj에 a라는 변수가 있더라도 foo의 실행부 console.log(this.a); 에서 a는 obj의 a를 가리키는 것이 아니라 전역에있는 a의 값을 출력합니다.

 

이처럼 콜백 과정에서 this 바인딩의 행방이 묘연해지는 경우가 많다고 합니다. 어떤 까닭으로 예기치 않게 this가 바뀌게 됐든 콜백함수의 레퍼런스를 마음대로 통제할 수 없으니 호출부를 조정할 수도 없습니다. 뒷 부분에서 this를 고정하여 문제를 해결해봅시다. 

 

2.2.3 명시적 바인딩(Explicit Binding)

암시적 바인딩에서 함수 래퍼런스를 객체에 넣기 위해 객체 자신을 변형해야 했고 함수 레퍼런스 프로퍼티를 이용하여 this를 간접적으로(암시적으로) 바인딩 했습니다. 함수 레퍼런스 프로퍼티에 객체를 더하지 않고 어떤 객체 this 바인딩에 이용하겠다는 의지를 코드에 명확히 밝힐 방도에 대해 알아봅시다. 

 

이럴 때 모든 자바스크립트 함수가 함꼐 사용할 수 있는 적당한 유틸리티 ([[Prototype]]을 거친다)가 바로 call()과 apply()메서드입니다. 극히 드물지만 일부 자바스크립트 호스트 환경은 두 메서드 대신 자신만의 특수한 함수를 제공하기도 합니다. 여튼 우리가 작성한 모든 함수를 포함하여 대부분의 함수가 call과 apply 메서드를 이용할 수 있습니다. 

-> 두 메서드는 this에 바인딩할 객체를 첫째인자로 받아 함수 호출 시 이 객체를 this로 세팅합니다. this를 지정한 객체로 직접 바인딩하므로 이를 명시적 바인딩이라합니다. 

 function foo() {
    console.log(this.a);
  }
  const obj = {
    a: 2,
    b: 'hello',
  };
  foo.call(obj); //2

call() 에서 명시적으로 바인딩하여 함수를 호출하므로 this는 반드시 obj가 됩니다.  

 

객체(참조값) 대신 단순 원시값(문자열, 불리언, 숫자)을 인자로 전달하면 원시 값에 대응되는 객체(new String(), new Boolean..등) 로 래핑되고 이 과정을 박싱(Boxing)이라고 합니다. 

하지만 위의 메서드를 통해 명시적으로 바인딩하여도 앞에서 언급한 this 바인딩이 도중에 소실되거나 프레임워크가 임의로 덮어써 버리는 문제를 해결할 수는 없습니다. (this의 명시적 바인딩이 완벽한 해법은 아님)

 

하드 바인딩(명시적 바인딩을 변형한 꼼수)

  • 하드 바인딩으로 함수를 감싸는 형태의 코드는 인자를 넘기고 반환 값을 돌려받는 창구가 필요할 때 사용합니다. 
function foo() {
    console.log(this.a);
  }
  const obj = {
    a: 2,
  };
  const bar = function(){
    foo.call(obj);
  }
  bar();
  //하드 바인딩된 bar에서 재정의된 this는 의미가 없다.
  bar.call(window); // 여기서 window로 바인딩해도 bar 내부에서 obj로 하드 바인딩하므로 의미없다.
 function foo(something) {
    console.log(this.a, something);
    return this.a + something;
  }
  const obj = {
    a: 2,
  };

  const bar = function () {
    return foo.apply(obj, arguments);
  };

  const b = bar(3); //2 3
  console.log(b); //5

하드 바인딩은 매우 자주 쓰이는 패턴이어서 ES5 내장 유틸리티 Function.prototype.bind 역시 다음과 같이 구현되어 있습니다. 

function foo(something) {
    console.log(this.a, something);
    return this.a + something;
  }

  const obj = {
    a: 2,
  };
  const bar = foo.bind(obj); 
  const b = bar(3); // 2 3 
  console.log(b)//5

2.2.4 new 바인딩

네 번째 바인딩 규칙을 설명하려면 자바스크립트 함수와 객체에 대한 오해를 하나 집고 넘어가야 합니다. 

전통적인 클래스 지향 언어의 생성자는 클래스에 붙은 특별한 메서드로, 다음과 같이 클래스 인스턴스 생성 시 new 연산자로 호출됩니다. 

something = new MyClass();

자바스크립트도 new 연산자가 있고 사용 방법은 겉보기에 다른 클래스 지향 언어와 다를 바가 없지만 사실 자바스크립트에서 new는 의미상 클래스 지향적인 기능과 아무 상관이 없습니다. 

먼저 자바스크립트에서 '생성자'의 정의란 앞에 new 연산자가 있을 때 호출되는 일반 함수에 불과합니다. 

클래스에 붙은 것도 아니고 클래스 인스턴스화 기능도 없습니다. 단지 new키워드를 사용하여 호출할 때 자동으로 붙들려 실행되는 평범한 함수입니다. 

예시로 Number 생성자의 개념에 대해서 살펴보면 

new 표현식의 일부로 호출 시 Number는 생성자이며 새로 만들어진 객체를 초기화한다.

따라서 Number() 같은 부류의 내장 객체 함수는 물론이고 상당수의 옛 함수는 앞에 new를 붙여 호출할 수 있고 결국 생성자 호출이나 다름없습니다. 생성자 함수 자체가 아니라 '함수를 생성하는 호출'이라고 해야 옳다고 합니다. 

 

함수 앞에 new를 붙여 생성자 호출을 하면 다음과 같은 일이 벌어진다.

  1. 새 객체가 만들어진다.
  2. 새로 생성된 객체의 [[Prototype]]이 연결된다.
  3. 새로 생성된 객체는 해당 함수 호출 시 this로 바인딩된다.
  4. 이 함수가 자신의 또 다른 객체를 반환하지 않는 한 new와 함께 호출된 함수는 자동으로 새로 생성된 객체를 반환한다.

1,3,4 과정을 예시코드로 보면 아래와 같다. 

 function foo(a){
    this.a = a;
  }
  const bar = new foo(2);
  console.log(bar.a);

앞에 new를 붙여 foo()를 호출했고 새로 생성된 객체는foo 호출시 this에 바인딩됩니다. 따라서 결국 new는 함수 호출 시 this를 새 객체와 바인딩하는 방법이며 이것이 "new 바인딩"입니다.

 

2.3 바인딩 우선순위

우선 기본바인딩은 가장 마지막입니다. 명시적 바인딩이 암시적 바인딩보다 우선순위가 높으며 new바인딩 역시 암시적 바인딩보다 우선순위가 높습니다. 이전에 살펴보았던 bind() 메서드는 어떤 종류든 자체 this 바인딩을 무시하고 주어진 바인딩을 적용하여 하드 코딩된 새 래퍼 함수를 생성합니다. 따라서 명시적 바인딩의 한 형태인 하드 코딩이 new바인딩보다 우선운위가 높고 new로 오버라이드할 수 없습니다. 

명시적 바인딩 > new 바인딩 > 암시적 바인딩> 기본 바인딩

2.3.1 this 확정 규칙

  1. new 함수로 호출(new 바인딩)? ->맞으면 새로 생성된 객체가 this이다.
  2. call과 apply로 함수를 호출(명시적 바인딩), 이를테면 bind 하드 바인딩 내부에 숨겨진 형태로 호출됐는가? -> 맞으면 명시적으로 지정된 객체가 this
  3. 함수 콘택스트(암시적 바인딩), 즉 객체를 소유 또는 포함하는 형태로 호출했는가? -> 맞으면 이 콘택스트 객체가 this다.
  4. 그 외의 경우는 this는 기본값(엄격모드는 undefined, 비엄격은 전역객체)으로 세팅된다.

💥ES6 화살표 함수표준 바인딩 규칙을 무시하고 렉시컬 스코프로 this를 바인딩 합니다. 즉, 함수 호출로부터 어떤 값이든 this 바인딩을 상속합니다. 이는 ES6 이전 self = this 구분을 대체한 장치라고 하네요.

렉시컬 스코프 (lexical scope)란, 함수를 어디서 선언하였는지에 따라 상위 스코프를 결정하는 것을 뜻한다. 화살표함수는 자신의 this를 가질 수 없기 때문에 자신의 상위의 렉시컬 범위(lexical scope)에서 this를 검색해서 사용한다.
댓글