본문 바로가기
Development

[21.01.19] YDKJSY - this Or That? / this All Makes Sense Now!

by igy95 2024. 1. 7.

⚠️ 여기서 설명하는 this는 JS 안에서의 개념이다. 다른 언어에서도 this를 사용하지만, 똑같은 개념으로 보는 일이 없도록 하자.

Confusions

this는 그 이름 때문에 개발자들에게 종종 혼동을 가져다 준다. 이 개념에 대해 크게 착각하는 두 가지가 있는데, 그것은 다음과 같다.

 

  • this 는 선언된 함수 자신을 가리킨다.
  • this 는 호출한 함수의 스코프를 가리킨다.

이러한 착각은 주로 this를 렉시컬 스코프의 개념과 연관지어 추론할 때 발생하고는 한다.

What's this?

바인딩 = binding = 묶는, 결속하는 행위

 

this는 동적으로 무언가를 바인딩한다. 즉, 함수 인용 조건에 따른 맥락 의존적인 성격을 가지고 있는 개념으로 보는 게 더 정확하다. 그렇기 때문에 this는 함수가 어디서 선언되었는지가 아닌, 어디서, 어떻게 호출되었는지에 따라 바인딩하는 주체가 바뀐다.

 

함수가 인용되었을 때, '실행 컨텍스트'로 더 잘 알려진 activation record가 생성된다. (직역하면 활성화 기록이라고는 하는데, 정확한 단어인지는 모르겠다) 아무튼 이 기록 상에서는 함수가 호출 스택 상에서 어디서, 어떻게 호출 되었는지, 어떤 매개변수가 통과되었는지 등 각종 런타임 정보를 갖고 있기 때문에, 이 안에서 해당 함수가 실행되는 동안 참조할 this 의 레퍼런스 값을 보존하고 있는 것이다.

Call-site

call-site(호출 지점)란, 코드 상에서 함수가 호출된 지점을 얘기한다. 이것을 제대로 파악하기 위해서는 call-stack(호출 스택)을 먼저 아는 것이 중요하다. 자료구조 중 stack의 개념을 차용해 어떤 함수를 순서대로 호출하게 되면 호출된 함수가 계속 쌓이면서 LIFO의 입출력을 이루게 되는데, 이것을 call-stack 이라고 한다.

 

call-stack 상에서 함수가 연이어 호출될 때는 자연스레 호출을 기준으로 주종 관계가 형성 되고 한 지점에서 함수를 호출했을 때는 호출된 함수 시점에서 그 지점이 call-site가 된다.

Nothing But Rules

Default Binding

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

var a = 2;

foo(); // 2

전역에 선언한 var a = 2;global object의 프로퍼티가 된다. 그리고 foo() 에 대한 call-site가 전역이므로 this는 자연스레 전역을 바인딩하는 것이다.

⚠️ in strict mode

전역 혹은 this 가 선언된 스코프에 'use strict'가 선언되어 엄격 모드가 활성화 될 경우, 일반 함수의 this 는 아무것도 참조할 수 없어 undefined 상태가 된다. 이에 대해 개인적으로 찾아본 이유는 다음과 같다. ECMA5 전까지 생성자 함수 패턴을 사용하는 개발자들이 new 키워드를 쓰지 않을 경우 this 의 예상 값이 적절치 않아 이에 대한 혼동 우려를 방지할 대책이 필요했는데, 그에 대한 방법 중 하나로 엄격 모드에서는 전역을 바인딩하지 않도록 하는 대안을 제시했다.

//non strict mode
function myConstructor(){
    this.a = 2;
      this.b = 3;
}

var myInstance = new myConstructor(); //this는 새로 생성된 인스턴스를 바인딩한다.
var myBadInstace = myConstructor(); //global 객체에 프로퍼티가 생성된다.

Implicit Binding

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

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2

해당 코드에서 foo() 함수는 obj의 메소드로 위치하고 있다. 이러한 경우 objfoo() 에 대한 포함, 소유 객체라고 말할 수 있는데, 사실 실질적으로 obj 가 해당 함수를 포함하거나 소유하는 것은 아니다. 다만 foo()에 대한 call-site가 foo()를 참조하기 위해 obj의 컨텍스트를 사용하고 있는 것이므로 함수가 호출되는 시점만큼은 obj가 함수를 가지고 있다고 표현할 수 있는 것이다.

 

때문에 함수 참조에 대한 컨텍스트 객체가 존재할 때, 함수 안에서 선언된 this가 바인딩하는 주체는 해당 객체가 되고 이러한 방식을 '암시적 바인딩'이라고 한다. 이 암시적 바인딩은 객체 참조 체인이나 콜백으로 함수를 넘겨주게 되는 경우 혼동을 야기할 수 있는데, 이는 해당 함수의 call-site가 어떤 context와 직접적으로 연결되어 있는지만 잘 생각하면 된다.

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

function doFoo(fn) {
    // `fn` is just another reference to `foo`

    fn(); // <-- call-site!
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // `a` also property on global object

doFoo( obj.foo ); // "oops, global"

콜백으로 넘겨주었을 때 헷갈릴 수 있는 예시지만, 이렇게 생각해 볼 수 있다.

 

  1. objfoo 프로퍼티는 동명의 함수를 '참조'하는 것이지, 실질적인 값을 가지는 게 아니다.
  2. 때문에 doFoo 함수에 넘겨주는 인자는 선언부의 foo() 함수와 같다.
  3. 해당 함수를 호출하는 call-site가 참조를 위해 사용하는 객체가 따로 없기에 결국 default binding 과 같은 결과를 갖게 된다.

이러한 현상은 생성자 함수로 만들어낸 instance 에도 동일하게 적용된다. instance도 결국 객체이기 때문이다.

Explicit Binding

위의 암시적 바인딩과는 달리 함수 내부에 존재하고 있는 api인 bind, call, apply 를 사용하면 좀 더 직접적으로 바인딩이 가능하다. 각 메소드는 인자로 받는 값들에 따라 실행하는 동작들이 약간 달라질 뿐, this의 직접적인 바인딩을 위한다는 성격은 모두 같다. 그 예시로 apply 의 hard binding 형태로 출연한 것이 bind 함수다.

 

  • bind 의 경우, 바인딩된 타겟 함수의 이름을 .name 프로퍼티로 갖게 되는데, 만약 a라는 변수에 바인드 함수를 써서 할당을 시키고 a.name을 참조하면 "bound [target]" 이라는 이름을 갖게 된다.

추가로 forEach()map() 처럼 기존 JS의 라이브러리 함수같은 경우, 콜백 인자 다음으로 인자를 넣어주게 되면 두번 째 인자를 바인딩해준다. 이는 최근에 안 사실인데, 나중에 시도해보면 좋을 것 같다.

new Binding

JS 에서는 다른 클래스 지향 언어의 특징처럼 new 연산자를 가지고 있다. 하지만 이것은 겉보기에만 유사한 것이지, 실제 클래스 지향적인 기능과는 전혀 상관 없다. 그리고 JS의 constructor 는 단지 함수일 뿐이다. 즉, constructor라는 명칭이 따로 있지만 결국 regular function과 동일한 성질을 가지고 있다. 약간 다른 점이 있다면 이 함수의 호출 방식은 '생성자 호출'을 따른다. new를 동반한 호출은 다음과 같은 특징을 가진다.

 

  • 호출되는 함수가 마땅한 객체를 반환하지 않더라도 새로운 객체가 생성되고, 이 객체는 프로토타입을 가진다.
  • 새로운 객체는 호출된 함수의 this에 바인딩 된다. (implicit binding)

Everything In Order

지금까지 bind 에 대한 4가지 rule을 알아 보았다. 하지만 만약, this의 주체를 알아야하는 상황에서 두 가지 이상의 rule이 중첩될 때 어떻게 우선 순위를 구할 수 있을까? 본문에서 예시를 보여주지만 간단히 정리하면 순서는 다음과 같다.

 

  1. new binding
  2. explicit binding : call, apply + bind
  3. implicit binding
  4. default binding : strict vs non-strict mode

앞으로 this를 마주하게 되면, 위의 결과를 토대로 판단해주면 된다.

Lexical this

일반 함수에 대한 this 판단 방식이 동적으로 결정된다면, 화살표 함수에서는 오직 lexical rule에 기반해서 상위에 근접한 함수 호출 주체를 this로 설정한다. 그렇기 때문에 화살표 함수 안의 this를 판별하기 위해서 실행 시점보다는 선언 시점을 파악하는 것이 더 정확한 방법이다.

 

하지만 this-style code 를 작성할 때, 제대로 된 이해없이 화살표 함수나 var self = this를 쓰는 등 여러 트릭을 지향하게 되면 유지 보수가 점점 어려워지기 때문에 클린 코드를 위해서는 올바른 이해와 일관된 사용 방법 등이 권장된다.

 

  • 정말 this가 필요한 케이스가 아니라면, lexical scope rule 로 커버할 것
  • this 를 정확하게 이해하고, 필요한 곳은 bind(..)로 직접 바인딩해서 가독성을 높일 것
  • this 관련 요행은 자제하자. (화살표 함수, self etc.)