본문 바로가기
FE Development/React

Class 인스턴스 메소드의 this와 Arrow Function에 대하여

by 개발자 데이빗 2023. 1. 18.

이 글은 리액트의 Function Component에서 클래스의 인스턴스 메소드를 사용했을 때, 메소드 내부의 this가 undefined가 나왔던 이유에 대해 알아번 내용이다.

 

우선 문제가 되었던 코드 예시는 아래와 같다.

export class SomeClass {
  private user: User;

  constructor(user: User) {
    console.log("initializing!");
    this.user = user;
  }

  someFuncWithUser() {
    //...무엇인가 일을 한다.
    console.log(this.user);
  }
}

export default function useSomeClass() {
  const { user } = useAuthContext();
  const { someFuncWithUser } = new SomeClass(user);
  return {
    someFuncWithUser,
  };
}

유저정보와 함께 생성되어 유저정보로 로직을 처리하는 메소드를 가지고 있는 클래스가 있고 유저정보는 컨텍스트에 있기 때문에 컨텍스트에 접근하기 위해 useSomeClass라는 훅을 만들어 내부 메소드를 사용할 수 있도록 리턴했다.

 

그리고 실제 사용되는 컴포넌트에서는 다음과 같이 사용된다.

import useSomeClass from "../utils/hooks";

export default function Home() {
  const { someFuncWithUser } = useSomeClass();

  const result = someFuncWithUser();

  return (
    <div>
        {result}
    </div>
  );
}

여기서 예상치 못한 문제가 발생했다.

someFuncWithUser 메소드 내부에서 this.user에 접근하려고 시도하였지만 this가 undefined가 되는 문제였다.

 

SomeClass의 인스턴스 내부 메소드였을때의 this는 SomeClass를 가리키지만 someFuncWithUser 메소드를 구조분해 할당하여 Home 컴포넌트에서 사용하게 되면 호출되는 순간 this는 Home 컴포넌트의 this에 바인딩이 되는 문제였다.

Home Usual Function이기 때문에 this undefined 된다.

 

Usual Function 컴포넌트의 this가 undefined인 이유

 

해결 방법으로는 세가지 정도가 있는데 다음과 같다.

 

1. useSomeClass 훅에서 SomClass 인스턴스의 메소드를 반환하지 않고 인스턴스 자체를 반환한다.

  • 사용하는 곳에서 인스턴스 자체를 받아 사용하기 때문에 메소드의 this는 인스턴스를 가리킨다.

2. constructor에서 this를 바인딩 해준다. 

  • 아래 코드 블럭과 같이 미리 바인딩 해주면 this는 Home 컴포넌트의 this에 바인딩 되지 않는다.

   constructor() {

     this.normalBind = this.normalBind.bind(this);

   }

3. arrow function을 사용한다.

  • arrow function은 Lexical this에 의해 this가 변경되지 않는다.

 

1번 방법 같은 경우 위 예시에서는 적합할지 모르지만 인스턴스의 몇몇 메소드만 모아서 사용하길 바라는 경우에 부적합할 수 있다.

2번의 경우 원하는 동작을 하지만 일일히 this를 바인딩 해주어야 하는 불편함이 있다.

그렇담 3번 방법이 가장 적합해 보이는데 문제점은 없을까?

 

1번 문제 - 프로토타입

우선 위 타입스크립트 코드를 es5 문법으로 트랜스파일하면 다음과 같이 된다.

// Arrow Function

"use strict";

exports.__esModule = true;

exports.SomeClass = void 0;

var SomeClass = /** @class */ (function () {

    function SomeClass(user) {

        var _this = this;

        this.someFuncWithUser = function () {

            //...무엇인가 일을 한다.

            console.log(_this.user);

        };

        console.log("initializing!");

        this.user = user;

    }

    return SomeClass;

}());

exports.SomeClass = SomeClass;



// Usual Function

"use strict";

exports.__esModule = true;

exports.SomeClass = void 0;

var SomeClass = /** @class */ (function () {

    function SomeClass(user) {

        console.log("initializing!");

        this.user = user;

    }

    SomeClass.prototype.someFuncWithUser = function () {

        //...무엇인가 일을 한다.

        console.log(this.user);

    };

    return SomeClass;

}());

exports.SomeClass = SomeClass;

Arrow Function에서는 _this 변수에 this를 바인딩 해놓는다.

그렇기 때문에 this가 엉뚱하게 바인딩 되어 예상했던 것과 다르게 동작하지 않는다.

그러나 Usual Function과 달리 SomeClass의 프로토타입에 메소드가 할당되지 않고 someClass 함수 내부에 직접 생성된다.

이로 인해 인스턴스가 생성될때마다 prototype을 참조하지 않고 매번 함수를 새로 생성하게 되는 단점이 있다.

물론 프로토타입 체인을 통한 디버깅 또한 할 수 없게 된다.

 

2번 문제 - 상속

Arrow Function 메소드의 문제는 상속에도 있었다.

이해하기 쉽게 다른 예시 코드를 준비했다.

class Parent {
  constructor() {
    // 상속될때는 자식의 this를 가리킨다.
    this.normalBind = this.normalBind.bind(this);
  }

  normal() {
    console.log("normal");
    console.log(this);
  }

  normalBind() {
    console.log("normalBind");
    console.log(this);
  }

  arrow = () => {
    console.log("arrow");
    console.log(this);
  };
}

class Child extends Parent {
  // Class 'Parent' defines instance member property 'arrow'
  // but extended class 'Child' defines it as instance member function.
  arrow() {
    super.arrow();
    console.log("in child");
  };

  arrow = () => {
    // Only public and protected methods of the base class are accessible via the 'super' keyword.
    super.arrow();
    console.log("in child");
  };
  
  normal(): void {
    super.normal();
    console.log("in child");
  }

  normalBind(): void {
    super.normalBind();
    console.log("in child");
  }
}

const child = new Child();
// 정상 작동
// normal
// Child { arrow: [Function (anonymous)], normalBind: [Function: bound ] }
// in child
child.normal();
// 정상 작동
// normalBind
// Child { arrow: [Function (anonymous)], normalBind: [Function: bound ] }
// in child
child.normalBind();

Parent 클래스에는 usual function인 normal, usual function을 바인딩한 normalBind, 그리고 arrow function인 arrow 메소드가 있다.

이 클래스를 상속한 Child에서 이 메소드들을 오버라이딩하려고 할때 arrow fuction 메소드는 다음과 같은 문제들이 발생한다.

 

1. arrow function을 usual function 메소드로 오버라이딩 하려고 할때

 

컴파일 타임에 다음과 같은 에러 메세지가 노출된다.

Class 'Parent' defines instance member property 'arrow', but extended class 'Child' defines it as instance member function.

 

arrow function 메소드의 경우 es6 문법으로 트랜스파일하면 다음과 같이 생성자에서 인스턴스 프로퍼티로 할당하게 된다.

이를 상속 받은 Child 클래스에서 인스턴스 메소드로 오버라이딩하려고 해서 에러가 발생한다.

class Parent {
  constructor() {
    this.arrow = () => {
        console.log("arrow");
        console.log(this);
    }
  }
}

 

2. super키워드 호출시

다음과 같은 에러 메세지가 노출된다.

Only public and protected methods of the base class are accessible via the 'super' keyword.

 

es5 문법으로 트랜스파일 됐을때 코드와 같이 arrow function 메소드는 prototype에 할당되지 않기 때문에 super 키워드를 통해 찾아 올라갈 수 없기 때문에 발생하는 문제이다.

 

정리

보통 일반 객체의 메소드로 arrow function의 this는 상위 환경의 this를 가리키기 때문에 사용하지 않는다.

그에 비해 class의 메소드로는 this가 멋대로 바인딩되는 일이 없어서 사용하면 좋다는 글들이 꽤 많다.

그러나 앞서 살펴본 것처럼 무조건적으로 arrow function을 사용했다간 예상치 못한 문제들을 겪을 수 있다.

 

그렇다면 class의 메소드로 arrow function는 언제 사용할 수 있을까 생각해보았다.

결국 메인이 되는 두가지 문제는 프로토타입을 참조하지 않아 인스턴스를 생성할때마다 새로 생성된다는 점과 상속과 관련된 문제였다.

그렇다면 싱글톤 패턴으로 사용되는 class에는 arrow function을 사용해도 문제가 되지 않겠다라는 생각이 들었다.

싱글톤 패턴이라면 매번 새로운 인스턴스를 생성하지도 않고 상속을 받지도 않기 때문이다.

 

새삼 느끼지만 자바스크립트의 this는 언제나 머리가 아프다.

최대한 this의 바인딩 문제를 신경 쓸 필요 없는 방향으로 코드를 짜는 것이 베스트라고 생각한다.

 

 

 

댓글