JavaScript

this 바인딩을 예측하기 어려운 상황 예시

GoJay 2024. 11. 19. 13:15

this는 함수 자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 자기 참조 변수이다(모던 자바스크립트 딥다이브 p343). 이는 다른 클래스 기반 객체 지향 언어들의 this가 반드시 생성된 인스턴스를 가리키는 것과는 많은 차이가 있다. 함수가 호출되는 방식에 따라 동적으로 결정되기 때문에 유연하다는 장점은 있지만, this의 바인딩을 예측하기 어렵다는 문제가 있다.

자바스크립트의 this 바인딩은 어떠한 경우에서도 일반 함수에서는 전역, 메서드에서는 호출된 객체, 생성자 함수에서는 인스턴스를 가리킨다. 이렇게만 생각하면 어렵지 않은 것 같지만, 사실 사용된 시점의 함수(또는 메서드)가 세 개 상황 중 어떤 상황에 해당하는지를 추론하는 건 생각보다 어려운 일이다.

그래서, 그런 상황들 중 몇 가지를 추려서 정리해 봤다. 전부는 아니겠지만, 공부하면서 this가 어떻게 작동하는지와,'콜백 함수'가 어떤 식으로 동작하는지에 대해 많이 배울 수 있던 내용이라 남겨둔다.

이벤트 핸들러 콜백 함수의 this

자바스크립트에서 이벤트 핸들러를 붙이는 방법은 HTML 태그의 어트리뷰트로 붙이는 방법, DOM 요소 객체의 프로퍼티로 등록하는 방법, addEventlistener를 사용하는 방법까지 세 가지이다. 그리고 각각의 방식에 따라 콜백 함수에서의 this가 바인딩하는 대상에 약간의 차이가 있다.

HTML 어트리뷰트 방식

아래 코드에서 buttonClick 함수 내부의 this는 전역을 가리킨다.

<body>
  <button id="btn" onclick="buttonClick()">클릭</button>
  <script>
    function buttonClick() {
      console.log(this) // window
    }
  </script>
<body>

이유는, HTML 태그의 어트리뷰트로 등록된 이벤트 핸들러 함수는 onclick이라는 함수로 한 번 감싸지기 때문이다. pseudo 코드로 나타내보면 대략 이런 식이다.

const $button = {
  // ... 다른 프로퍼티와 메서드들

  onclick: function onclick() {
    function buttonClick() {
      console.log(this);
    }
  }

실제로 HTML 어트리뷰트 방식으로 이벤트 핸들러 콜백 함수를 등록한 요소를 document.getElementId('btn')으로 선택한 후 console.dir로 내부 프로퍼티들을 확인해보면 아래와 같이 onclick이라는 함수가 onclick 이벤트 프로퍼티에 등록되어있는 게 확인된다.

위의 pseudo 코드의 형태가 맞다고 했을 때, buttonClick 함수는 객체의 메서드 내부에 일반 함수로 정의된 중첩 함수이다. '일반 함수의 this는 전역 객체를 가리킨다'는 규칙에 따라 this가 전역(브라우저 환경에서는 window)을 가리킨다.

DOM 요소의 이벤트 프로퍼티 방식

아래 코드에서 buttonClick 함수의 this는 이벤트 핸들러가 등록된 HTML 태그를 가리킨다(정확하게는, DOM에 있는 HTMLElement 객체를 가리킨다). buttonClick은 분명 일반 함수로 정의됐는데 이상하다(호출이 다른 방식으로 된 거다).

<body>
  <button id="btn">클릭</button>
  <script>
    function buttonClick() {
      console.log(this); // <button id="btn">클릭</button>
    }

    const $button = document.getElementById('btn');
    $button.onclick = buttonClick();
  </script>
<body>

해당 코드를 실행하면 pseudo 코드로 아래와 같은 형태가 된다.

const $button = {
  // ... 다른 프로퍼티와 메서드들

  onclick: function buttonClick() {
    console.log(this);
  }

이렇게 바꿔놓고 보니 꽤 명확하다. 일반 함수로 정의한 buttonClick은 실제론 HTMLElement 객체인 $buttononclick 프로퍼티에 등록되어 메서드로 호출된다. 실제로 console.dir$button 요소의 프로퍼티를 출력해 보면 onclick 프로퍼티에 buttonClick() 함수가 등록되어 있는 게 확인된다(HTML 어트리뷰트 방식에서 onclick 함수가 등록되고, 내부 함수로 buttonClick 함수가 들어있던 것과는 차이가 있다)

addEventListener 방식

addEventListener의 두 번째 인자로 넘긴 콜백 함수의 this는 DOM 요소의 이벤트 프로퍼티 방식과 동일하게 이벤트 핸들러가 달린 HTMLElement 객체를 가리킨다.

<body>
  <button id="btn">클릭</button>
  <script>
    function buttonClick() {
      console.log(this); // <button id="btn">클릭</button>
    }

    const $button = document.getElementById('btn');
    $button.addEventListener('click', buttonClick);
  </script>
<body>

addEventListenereventTarget이라는 프로토타입에 정의되어 있으며, 우리가 document 객체를 통해 접근하는 HTMLElementeventTarget을 상속받아 addEventListener 메서드를 사용하게 된다. 그리고, addEventListener로 추가한 이벤트 핸들러는 HTMLElement 객체의 프로퍼티로 등록되지 않기 때문에, 사용자가 쉽게 접근해서 값을 확인하기 어렵다.

단, 구글 크롬 개발자 도구의 '요소'탭 '이벤트 리스너' 부분을 보면 대략적으로 이벤트 핸들러가 어떻게 등록되어 있는지를 파악할 수 있다.

handler라는 프로퍼티에 buttonClick이라는 함수가 등록되어 있다. 대략, 이런 식이지 않을까 싶다.

const $button = {
  // ... 다른 프로퍼티와 메서드들

  handler: function buttonClick() {
    console.log(this);
  }
}

당연히 객체의 메서드로 등록되어 호출되기 때문에, 호출된 객체인 $buttonthis에 바인딩된다.

이렇게, 이벤트 핸들러의 콜백 함수는 함수의 형태가 일반 함수더라도 실제론 메서드와 같이 호출이 되기 때문에 this의 값이 주로 전역이 아니라 다른 값을 갖는다. 하지만, 이벤트 핸들러의 콜백 함수가 실제로 어떤 식으로 동작하는지 알지 못한다면 콜백 함수에서의 this가 무엇을 바인딩하는지 추론하기 어려울 수 있다.

setTimeout 콜백 함수에서의 this

아래와 같은 코드에서 thismyObj를 가리킨다.

const myObj = {
  name: 'Ko',
  func: function() {
    setTimeout(() => {
      console.log(this); // myObj
    }, 2000);
  }
}

myObj.func(); 
console.log('hi'); // hi <-- 이 코드가 먼저 실행

화살표 함수는 this 바인딩 값을 갖지 않고, 상위 스코프의 this를 참조하기 때문에, setTimeout 함수 내부의 콜백 함수가 상위 스코프인 myObj.func 스코프의 this에 바인딩되는 것은 자연스러워 보일 수 있다. 하지만, 처음 이 코드를 봤을 때 'setTimeout의 콜백이 시작될 땐 실행될 땐 메모리에서 myObj.func의 실행 컨텍스트가 해제된 이후이기 때문에 정상적으로 thismyObj를 가리키지 못할 것 같다는 생각을 했었다.

하지만, 실제로는 실행 중인 실행 컨텍스트와 상관없이 thismyObj를 잘 가리키고 있었다. 이유를 생각해 보면, setTimeout의 콜백 함수 내부의 this가 클로저(Closure)로 동작한 게 아닐까 싶다.

실행 순서를 생각해보면 이렇다. 먼저, 전역 실행 컨텍스트가 콜 스택에 쌓이고, myObj.func가 호출된다. 그리곤 myObj.func의 실행 컨텍스트가 생성되어 콜 스택에 쌓이고 '실행 중인 실행 컨텍스트'가 된다. 그 다음으론 setTimeout의 실행 컨텍스트가 생성되지만, 해당 실행 컨텍스트는 내부 콜백 함수를 이벤트 큐에 밀어넣은 뒤 곧바로 종료된다(해당 이벤트 큐는 2000ms 이후에 다시 호출된다). 그 다음 myObj.func 실행 컨텍스트가 종료되고, 외부에 있는 console.log('hi')의 실행 컨텍스트가 생성되어 실행된 후 소멸한다. 그 다음 2000ms 후에 console.log(this)의 실행 컨텍스트가 콜 스택에 올라가고, this가 호출된다. 마지막에 this가 호출되고 난 이후엔 전역 실행 컨텍스트가 해제되고 실행이 종료된다.

마지막 전역 실행 컨텍스트가 종료되기 전에 console.log(this)가 실행될 땐 myObj.func의 실행 컨텍스트는 소멸한 이후이다. 하지만, console.log(this)myObj를 잘 참조하고 있는 것은 this에 대한 정보가 myObj.func 실행 컨텍스트 종료 이후에도 살아있다는 뜻이고, 바꿔 말하면 클로저로 동작하게 된 셈이다.

이렇게 실행 순서가 바뀌더라도 this에 대한 정보는 클로저로 정보를 캡처하고 저장하기 때문에, '화살표 함수는 상위 스코프의 this를 참조한다'는 것은 유지된다.

화살표 함수로 지정한 메서드의 this

this 바인딩이 흔들리는 것 때문에 화살표 함수 사용을 권장하는 걸 봤다. 특히, 콜백 함수에서는 화살표 함수를 사용하여 상위 스코프의 thisthis를 고정하는 게 더욱 권장되는 것 같다.

하지만, 모든 상황에서 화살표 함수가 왕도는 아니다. 예를 들어서, 아래와 같이 메서드를 화살표 함수를 사용해 선언할 경우 this는 전역을 가리킨다. 화살표 함수에서의 this는 상위 스코프의 this를 의미하는 것이기 때문에, 메서드 실행의 상위 스코프인 전역이 참조되는 것이다.

const myObj = {
  func: () => {
    console.log(this);
  }
}

myObj.func() // window

실제로, MDN 문서에서 화살표 함수를 설명하면서 아래와 같은 문장을 사용하였다.

Arrow functions don't have their own bindings to this, arguments, or super, and should not be used as methods.

화살표 함수는 메서드를 정의할 때에는 사용하지 않는 게 바람직하다.

결론

this의 바인딩이 헷갈리는 몇 가지 상황에 대해 살펴보았다. 전반적으로 this를 추측하기 어려운 상황은 함수가 어떠한 형태로 사용되는지 알기 어려운 때라는 생각이 든다. 자바스크립트는 태생이 유연성을 강조하고, 재사용성을 넓은 폭으로 허용하기 때문에, 일반 함수로 정의된 함수도 메서드가 될 수 있고, 메서드도 일반 함수가 될 수 있다. 이러한 언어적 특성을 고려해서, 정확하게 개념을 이해하고 쓸 수 있게 공부의 깊이를 깊게 파야겠다는 생각이 든다. 아무튼, 실전에서 this 때문에 애먹지 않게 앞으로도 틈틈이 케이스들을 잘 정리해 둬야겠다.

'JavaScript' 카테고리의 다른 글

순수 함수에 대해서  (1) 2024.12.06
Date 객체 사용하기  (2) 2024.11.15
프로토타입 톺아보기  (1) 2024.11.13
241102 TIL  (1) 2024.11.03
자바스크립트 엔진과 런타임  (2) 2024.10.30