본문 바로가기
나의 개발일지/javascript

javascript class this bind 문제 - this가 왜 undefined로 나오지?

by stella_gu 2024. 2. 25.

typescript로 express api 기본 세팅의 보일러 플레이트를 만들다가 이론으로만 접하던 this 문제를 제대로 겪었다.

controller-service-repository의 3계층 구조(3 layer pattern)로 분리하고 있는데 this가 undefined로 뜬는 게 아닌가..

gpt에 물어보니 bind 키워드를 알려줘서 아차!했다.

 

문제가 된 코드는 아래와 같다.

// users.route.ts

import { Router } from "express";
import { UserController } from "./users.controller";
import wrapAsyncMiddleware from "../middlewares/wrapAsyncMiddleware";

const router = Router();
const userController = new UserController();

router.post("/signup", wrapAsyncMiddleware(userController.signup));

export default router;
// users.controller.ts

import { Request, Response } from "express";
import { UserService } from "./users.service";

export class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService();
  }

  async signup(req: Request, res: Response) {
    const user = await this.userService.signup(req.body);
    res.status(201).json(user);
  }
}
// users.service.ts

import { UserRepository } from "./users.repository";

export class UserService {
  private userRepository: UserRepository;
  
  constructor() {
    this.userRepository = new UserRepository();
  }

  async signup(data: any): Promise<any> {
    return this.userRepository.createUser(data);
  }
}

 

// users.repository.ts

export class UserRepository {
  async createUser(data: any): Promise<any> {
    // 데이터베이스 로직 구현
    return { id: 1, ...data }; // 예시 응답
  }
}

 

signup api를 호출하면 users.controller.ts 파일의 "const user = await this.userService.signup(req.body);"에서 에러가 난다.

TypeError: Cannot read properties of undefined (reading 'userService')

 

// users.controller.ts

import { Request, Response } from "express";
import { UserService } from "./users.service";

export class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService();
    console.log(this.userService);	// -> 여기선 this가 정상적으로 나온다.
  }

  async signup(req: Request, res: Response) {
    console.log(this);	// -> 여기서 this는 undefiend
    const user = await this.userService.signup(req.body);
    res.status(201).json(user);
  }
}

this에 userService가 없나 싶어서 constructor에서 확인해보니 잘만 나온다.

근데 signup 함수 내에서 this는 undefined로 찍히는 게 아닌가

 

지금껏 이론으로만 보던 this의 바인딩을 여기서 접하게 될 줄이야

제로초 유튜브의 인간 JS 엔진 되기를 그렇게 봤건만...

이것 땜에 꽤나 시간을 날렸지만 덕분에 제대로 공부가 되었다.

 

[원인]

constructor 생성자까진 this가 UserController 객체를 제대로 가리켜 userService를 갖고 있지만

signup 함수가 호출 되면서 this 값이 변해버리는(새로 bind되어 버리는) 문제가 생긴 것이다.

 

게다가 this가 window 객체(nodejs 전역 객체)를 가리키는 게 아닌 undefined가 된 건 typescript가 기본적으로 strict mode로 컴파일 하기 때문이었다.

 

[해결]

1. bind 사용

export class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService();
    this.createUser = this.createUser.bind(this);
    this.login = this.login.bind(this);
  }

  // 메서드 정의...
}

위와 같이 생성자에서 signup 메서드에 this를 바인딩 해주거나 route에서 signup함수를 부를 때 userService를 바인딩 해주면 되지만 메서드가 추가될 때마다 바인딩 해줘야하는 귀찮음이 있다.

 

2. 화살표 함수(arrow function)

import { Request, Response } from "express";
import { UserService } from "./users.service";

export class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService();
  }

  signup = async (req: Request, res: Response) => {
    const user = await this.userService.signup(req.body);
    res.status(201).json(user);
  };

  login = async (req: Request, res: Response) => {
    const token = await this.userService.login(req.body);
    res.status(200).json(token);
  };
}

일반 함수는 동적으로 this를 바인딩하여 함수가 호출될 때 this가 정해지지만, 화살표 함수는 정적으로 렉시컬 컨텍스트의 this를 바인딩하여 함수가 선언될 당시의 this 컨텍스트를 "캡처"하고, 어디서 호출되든 그 컨텍스트를 유지한다.

함수가 선언된 렉시컬 환경인 UserController 내부의 this를 바인딩하여 this.userService가 정상적으로 나온다고 볼 수 있다.

 

 

+

이제 UserController 내부 메서드만 화살표 함수로 변경하고 UserService class는 그대로 두면 이제 UserService에서 this.userRepository가 undefined로 나올 거라 예상할 수도 있다.

하지만 여기선 정상적으로 작동한다.

 

이유는 UserService의 메서드들이 클래스 인스턴스를 통해 직접 호출되기 때문이다.

"router.post("/signup", wrapAsyncMiddleware(userController.signup));"처럼 메서드가 라우터에 의해 직접적으로 콜백으로 사용된 것과 달리 메서드가 클래스 인스턴스를 통해 직접 호출되기 때문에 this 바인딩에 문제가 없다.

 

앞으로 controller 내부에 메서드를 정의할 때는 화살표 함수를 써서 this 바인딩 되지 않는 문제를 해결하는 게 좋겠다.

 

 

 

 

참고) 상속과 메서드 오버라이딩

클래스의 메서드를 화살표 함수로 정의하면 메서드 오버라이딩이나 상속과 같은 일부 OOP 패턴에서는 원하는 대로 작동하지 않을 수 있다.

class Parent {
    regularMethod() {
        console.log('Parent method');
    }
}

class Child extends Parent {
    regularMethod() {
        console.log('Child method');
    }
}

위 코드에서 Child 클래스는 Parent 클래스의 regularMethod를 오버라이드한다.

이때 Child 인스턴스를 사용하여 regularMethod를 호출하면 "Child method"가 출력된다.

 

화살표 함수를 사용하면 클래스의 인스턴스에 직접 바인딩된다. 이는 메서드가 인스턴스 프로퍼티처럼 작동하게 만들며, 클래스의 protorype 체인에는 포함되지 않는다.

결과적으로, 자식 클래스에서 부모 클래스의 화살표 함수 메서드를 오버라이드(Override)하는 것이 실제로는 부모 클래스의 메서드를 오버라이드하는 것이 아니라, 단순히 자식 클래스 인스턴스에 새 프로퍼티를 추가하는 것에 불과하다.

class Parent {
    arrowMethod = () => {
        console.log('Parent method');
    };
}

class Child extends Parent {
    arrowMethod = () => {
        console.log('Child method');
    };
}

위처럼 화살표 함수로 정의한 경우 Child 클래스의 인스턴스는 자신의 arrowMethod를 가지며, 이는 부모 클래스의 arrowMethod와는 별개이다. 부모 클래스의 메서드를 실제로 오버라이드(Override)하지 않으며, 부모와 자식 클래스 간의 메서드는 독립적이다.

이는 메서드를 프로토타입 체인을 통해 공유하는 전통적인 OOP의 메커니즘과는 다른 동작 방식으로 주의가 필요하다.

 

그러므로 bind 문제를 피하겠다고 클래스 내부 메서드를 무조건 화살표 함수로 정의하지는 말자.

 

(좌) 일반 함수로 정의된 메서드는 prototype에 메서드가 포함되는 걸 볼 수 있다.

(우) 화살표 함수로 정의된 메서드는 protorype에 메서드가 포함되지 않아 undefined가 출력되는 걸 볼 수 있다.