티스토리 뷰


[Typescript] class에서 'this'를 사용할 때 주의사항

얼마전, 타입스크립트로 express를 사용할 수 있는 템플릿을 만들다가 곤란함에 빠졌던적이 있습니다. (참고: Github - express-ts-template) 우선 구조와 코드를 소개해드려야겠군요. 다만, 이 구조는 어려울 수 있으니까요. 좀 더 쉬운 설명을 원하시는 분은 바로 다음 파트로 넘어가셔도 무방합니다.

express-ts-template 에서 발견된 문제점

타입스크립트의 클래스는 자바스크립트의 ES6보다 좀 더 기존 자바나 C++의 클래스와 유사합니다. 그래서 좀 더 MVC 패턴같은 구조를 만들어보고자 한 것이죠. 전체적인 구조는 컨트롤러의 생성자에서 모델을 등록하여, 해당 모델을 이용하게끔 하는 구조입니다. 이 과정에 추상클래스로 구현된 컨트롤러와 모델이 존재하구요.

우선 추상클래스인 controller.ts 입니다.

// controller.ts

import { Request, Response } from 'express';
import { Model } from './model';

export abstract class Controller {
  protected dao?: any;

  constructor(protected model?: Model) {
    if(this.model) {
      this.dao = this.model.getModel();
    }
  }
  ...후략
}

그리고 sample-dao라는 이름을 가진 컨트롤러 입니다.

// sample-dao.controller.ts

export class SampleDaoController extends Controller {
  constructor() {
    super(new SampleDaoModel());
  }

  public readAll (req: Request, res: Response): void {
    this.dao.readAll()
      .then((docs: any) => {
        res.json(docs);
      })
      .catch((err: any) => res.status(400).json(err));
  };
  ...후략
}

그리고 마지막으로 해당 컨트롤러로 연결해주는 routes 입니다.

// sample-dao.routes.ts

export class SampleDaoRoutes extends Routes {
  constructor() {
    super(new SampleDaoController());
  }

  protected routes(): void {
    this.router
      .route('/')
      .get(this.ctrl.readAll)
      .all(this.ctrl.badRequest);
  ...후략
}

이게 typescript라는 것만 제외하면, 위 구조상에서 문제될만한 점이 보이시나요? 언뜻 쉽게 들어오지 않습니다. 혹자는 제가 공개해드린 깃허브의 코드와 위 코드들의 차이점도 발견하지 못하시는 분이 계실겁니다. 위 코드를 실행하면 다음의 에러가 발생합니다.

TypeError: Cannot read property 'this.dao' of undefined

그리고 추상 클래스인 Controller 클래스의 생성자에 가면 정상적으로 dao값을 담는 것을 확인할 수 있습니다.

// controller.ts

constructor(protected model?: Model) {
    if(this.model) {
      this.dao = this.model.getModel();
      // console.log(this.dao) => 여기까지는 정상적인 값이 담김
    }
  }
  ...
}

그리고 멘붕에 빠집니다. SampleDaoController 는 정상적으로 값을 담은 Controller를 상속했는데, 왜 readAll 함수에서만 this.dao 객체가 undefined 일까요..? 심지어 SampleDaoController 생성자의super()함수 밑에 this.dao 값을 체크해봐도 정상입니다.

// sample-dao.controller.ts

export class SampleDaoController extends Controller {
  constructor() {
    super(new SampleDaoModel());
    // console.log(this.dao) => 여기까지도 정상
  }
  ...
}

그리고 다시 멘붕에 빠집니다. 가 아니라, 사실 여기까지 보고나서 저는 무슨 문제인지 캐치할 수 있었습니다. 답은 간단했습니다. 기본적인 문장 하나를 떠올려봅니다. 타입스크립트는 자바스크립트의 슈퍼셋이다. (혹은 슈퍼셋 정도로 생각해도 큰 무리는 없다.) 즉, 아무리 클래스를 가다듬은 타입스크립트라 할지라도, 자바스크립트의 본질적인 문제에서 벗어날 수는 없다는 의미가 되기도 합니다.

생성자단계까지 정상이던 this.dao의 값이, 어떤 함수가 호출될 때 undefined 값이 된다? 이 말은 다시 말해 this 바인딩 문제가 생겼다고 추측할 수 있습니다. 그래서, 위 코드상에서 가장 간단한 수정방법은 함수형태로 된 클래스의 메소드를 화살표함수(Arrow Function)으로 변경하는 것입니다.

  ...
  // () => {} 함수 호출 시, this를 별도로 바인딩하지 않는 화살표함수 형태로 변경
  public readAll = (req: Request, res: Response): void => {
    this.dao.readAll()
      .then((docs: any) => {
        res.json(docs);
      })
      .catch((err: any) => res.status(400).json(err));
  };
  ...

그러면 이제 this.dao 를 읽어올 때 아무런 문제가 생기지 않습니다.

this 바인딩을 유의하자

이제 파트1을 건너 뛰었을 몇몇 분들을 위해, 쉬운 코드로 전환하겠습니다.

class Foo {
  private bar: string = 'bar';

  print() {
    console.log(`bar: ${this.bar}`);
  }
}

function someFunction(method: any) {
  //
  // something else
  //
  method();
}

let foo = new Foo();
someFunction(foo.print);

콜백으로 메소드를 넘겨주는 단순한 코드입니다. 구조는 단순화했지만, express-ts-template 에서와 동일한 문제가 있습니다.

TypeError: Cannot read property 'this.bar' of undefined

이처럼 오류가 나거나, 아니면 bar: undefined로 찍힐겁니다. this 바인딩을 잘 생각해봅시다. someFunction을 호출할 때, this는 global 객체에 바인딩 됩니다. method 역시 global 객체에 바인딩 되는데, method는 Foo 클래스의 print 함수 입니다. 하지만 global 객체에는 bar 라는 변수가 없기 때문에 undefined가 됩니다.

아래 코드는 재미로 한번 찍어봅시다.

...
declare var bar: string;
bar = 'I am global';

let foo = new Foo();
...

그럼 아래와 같이 정상 출력이 됩니다. declare 를 이용하면 타입스크립트에서는 global 객체에 변수를 추가하는 것이 됩니다.

bar: I am global

자, 진정한 해결책은 뭘까요? 이 파트의 제목과 동일합니다. this 바인딩을 유의하자. ES6에서는 this 바인딩이 제멋대로 되는 문제를 해결하는 문법이 있습니다. 화살표함수(Arrow Function)입니다.

class Foo {
  private bar: string = 'bar';

  // print 함수를 애로우펑션으로 변경해줍니다.
  print = () => {
    console.log(`bar: ${this.bar}`);
  }
}

function someFunction(method: any) {
  //
  // something else
  //
  method();
}

let foo = new Foo();
someFunction(foo.print);

class 내의 print 함수를 화살표함수로 변경합니다. 그러면 this 바인딩 문제가 정상적으로 해결됩니다.

More Deep Inside

마지막은 좀 더 깊이 확인해보는 시간입니다. ^_^ 이미 여러분은 해결책을 아셨으니, 해결책만이 궁금하셨던분들은 이제 나가셔도 됩니다. 자, 타입스크립트에서 ES5 모듈로 트랜스파일링을 해보면, 두 방식의 진정한 차이점을 알 수 있습니다.

첫번째, print() {} 와 같이 함수형태로 선언했을 때 타입스크립트는 아래와 같이 트랜스파일링 합니다.

var Foo = /** @class */ (function () {
    function Foo() {
        this.bar = 'bar';
    }
    Foo.prototype.print = function () {
        console.log("bar: " + this.bar);
    };
    return Foo;
}());

print = () => {} 와 같이 화살표함수형태로 선언하면 다음과 같습니다.

var Foo = /** @class */ (function () {
    function Foo() {
        var _this = this;
        this.bar = 'bar';
        this.print = function () {
            console.log("bar: " + _this.bar);
        };
    }
    return Foo;
}());

코드를 빠르게 훑어보시면 아시겠지만, 주요 차이점은 this를 다루는 방식입니다. 화살표함수일때, this를 _this에 담아놓고 사용합니다. 따라서 print 함수는 기존에 this 가 제멋대로 바인딩 되는 문제에서 자유로울 수 있게 됩니다.

참고로 자바스크립트의 ES6에서도 동일한 문제가 있습니다.

class Foo {
  constructor() {
    this.bar = 'bar';
  }

  print() {
    console.log(`bar: ${this.bar}`);
  }
}

function someFunction(method) {
  //
  // something else
  //
  console.log(global === this); // true로 출력됨
  method();
}

let f = new Foo();
someFunction(f.print);

이 코드는 자바스크립트 코드입니다. 자바스크립트에서도 class내부에 this 를 다루는 방식이 약간 다른 것 같습니다. 이 코드를 실행시키면, print함수내의 this 가 undefined 이기 때문에 에러가 납니다. 아, 참고로 타입스크립트에서는 global에 매핑되었던건, 제가 ES5로 변환시켰기 때문입니다.

method를 호출할 때, method.call(this); 로 변경해주어야 global에 바인딩이 됩니다. 물론 그렇다고 이것이 현재의 문제를 타계하기 위한 해결책은 아니겠지요. 해결하는 방법은 역시 this를 강제 바인딩하는 방법밖에는 없을것으로 보입니다. print 함수를 화살표함수로 바꾸는것은 ES6에서 허용하지 않는 문법인거 같거든요. 변경되지 않습니다.

따라서, 우린 마지막줄의 코드를 아래처럼 바꿉니다.

let f = new Foo();
someFunction(f.print.bind(f));

강제로 Foo 클래스의 객체에 바인딩을 시켜줍니다. 그럼 모든 것이 정상입니다.

결론

this 바인딩은 언제나 어렵고, 언제나 헷갈립니다. 정말 자바스크립트의 고질적인 암과 같은 존재입니다. 그래도 적어도 타입스크립트에서는, 클래스의 메소드를 언제나 Arrow Function으로 선언해주는 것은 좋은 습관인것 같습니다.

참조

  1. 'this' in TypeScript
  2. TypeScript and 'this' in class method

댓글
  • 프로필사진 ian 좋은 글 감사드립니다. 그런데 Arrow Function 을 사용하는 방법은 성능 관점에서 주의할 부분이 있는거 같아요~

    Arrow Function 으로 print 함수를 선언하면, 인스턴스가 많아질수록 개별적인 print 함수가 만들어지게 되고, 이로 인해 메모리 낭비가 발생하겠네요.
    일반 멤버 함수에서는 prototype 객체에 추가되지만, Arrow Function 형태의 정의는 그렇지 않을테니까요.

    위 상황이라면 router 코드를 수정하는 방향이 더 좋지 않을까요?
    > this 를 참조하는 클래스 멤버 함수는, 외부에서 Arrow Function 을 사용해 this context 를 유지해주는 방향

    this.router
    .get( (req: Request, res: Response) => this.ctrl.readAll(req, res) )
    2018.07.03 15:37 신고
  • 프로필사진 norux 좋은 의견 주셔서 감사합니다. ^^

    말씀해주신것처럼 인스턴스를 생성할 때마다, 메모리 낭비가 있을 수 있겠습니다. 다만, 일부 경우에 성능은 더 높을 수 있는 트레이드 오프는 있다고 합니다.

    타입스크립트 진영에서도 주로 콜백으로 사용되는 메소드의 경우에는 Arrow Function을 사용한 것과 같이 클로저를 미리 만들어놓았을 때, 성능측면에서 더 우수하다고 합니다. (https://github.com/Microsoft/TypeScript/wiki/'this'-in-TypeScript)

    위 주소 따라가보시면, 3가지 해결책에 대한 각각의 장/단점을 나열해 놓았습니다. 상황에 맞게 잘 사용하는 것이 더 중요할 것 같습니다. 좋은 의견 주셔서 다시 한 번 감사드립니다 :)
    2018.07.04 10:31 신고
  • 프로필사진 ian 네, 확실히 call stack 이 깊어지지 않기 때문에 성능면에서 이점은 있을 것 같아요~

    그리고 공유해주신 주소에 정말 잘 정리되어 있네요! 좋은 정보 감사드립니다~
    2018.07.04 17:25 신고
댓글쓰기 폼