TypeScript this 키워드 삽질기

TypeScript로 개발을 진행하던 중 발생한 에러가 생각지도 못한 원인에서 비롯된 경험을 이곳에 남겨봅니다. 나중에 또 실수할 저를 위해.

문제의 발생

웹 개발 프로젝트를 진행하면서 REST API를 개발중이었습니다. 대략적인 구현은 거의 끝난 상태였지만 아직 테스트 코드가 작성중인 상태라 원하는대로 동작하는지는 확인해보질 못했었습니다. 그래서 테스트 코드는 시간 날때 따로 작성하는 것으로 하고, 프론트 개발을 하면서 API 테스트로 함께 하면 되겠다 싶어 하나씩 해보는 중에 문제를 발견하게 됩니다.

먼저 백엔드의 대략적인 아키텍처를 설명드리자면 다음과 같습니다. ()는 해당 단계를 구현한 클래스명입니다.

  • 백엔드로 들어오는 요청을 받아서 버전 라우터에 넘긴다.(APIRouter)
  • 버전 라우터에서는 API 버전(현재는 v1)에 따라 API 라우터에 넘긴다.(V1Router)
  • API라우터에서 요청 컨텍스트(user, post, board, etc.)에 따라 각 API로 요청을 넘긴다.(UserAPI, PostAPI, …)
  • API에서 요청을 처리하고 응답을 보낸다.

또한 각 과정에서 발생하는 DB 연산과 에러 처리를 위한 객체를 각 클래스 생성자를 통해 연쇄적으로 넘겨주는 형태로 설계했습니다.

문제는 사용자 로그인을 처리하는 함수에서 처음 발견되었습니다.

다음과 같은 로그인 요청에

POST /api/v1/auth/login

로그인 프로세스를 처리하고 결과를 응답으로 받을 수 있어야 하는데 다음과 같은 예외가 발생합니다.

(node:14676) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 2): TypeError: Cannot read property 'db' of undefined

db는 요청을 처리하는 클래스에서 사용하도록 생성자를 통해 넘겨준 DB 연산을 추상화한 객체입니다. 대략 다음과 같은 형태로 구현돼있다고 보시면 되겠습니다.

// 사용자 인증과 관련된 API
public AuthAPI {

	public constructor(
		private db: IDatabase,
		private eh: IErrorHandler) { /* ... */ }
	
	private loginUser(req: express.Request, res: express.Response) {
		// ...
		this.db.findUserByID(); // DB 연산
	}
}

실질적으로 요청이 처리되는 부분은 loginUser 메서드인데, 이곳에서 db의 함수를 호출하는 과정에서 예외가 발생한 것으로 보였습니다.

예외 메시지를 해석하면 db 프로퍼티가 있어야 할 객체에 없다는, 즉 this객체가 db 필드를 가지고 있지 않다는 것으로 해석이 되었습니다.

해결

저는 여기서 가능한 원인을 크게 다음과 같이 두 가지로 생각했습니다.

  • AuthAPI의 생성자의 db 파라미터에 undefined가 전달됐다.
  • loginUser에서 참조하는 this가 어떤 이유로 인해 undefined가 되어 있다.

하지만 TypeScript의 강점 중 하나가 함수에 전달되는 타입을 일정 수준 강제함으로써 원치 않는 값이 전달되는 상황을 방지한다는 것이고, 위의 코드 처럼 undefinednull이 아닌 IDatabase객체만 넘길 수 있도록 작성된 코드에서 다른 타입이 전달되는 코드가 있었다면 분명 컴파일 과정에서 찾을 수 있을 것으로 생각했습니다.

결국 가능한 원인은 두 번째 뿐인 것으로 결론 짓고 구글링하다가 다음과 같은 글을 발견했습니다. 원문

특히, ‘Typical Symptoms and Risk Factors’ 항목의 ‘The value this points undefined instead of the class instance (strict mode)‘가 이 문제를 정확히 표현하고 있다고 생각했습니다.

해당 문서에서 제안한 방법은 3가지 입니다.

  1. Instance Function 사용하기

    private doSomething = () => { console.log("Hello!"); }
    
  2. Fat Arrow Function 사용하기

    doOtherThing(() => { console.log("Hello!"); });
    
  3. Bind 함수 사용하기

    const doSome = new Something();
    doOtherThing(doSome.doSomthing.bind(doSome));
    

각 방법마다 장단점이 있는데, 이번 문제에서는 1번 방법을 적용해봤습니다.

다음과 같이 코드를 수정한 결과 정상적으로 동작하는 것을 확인하였습니다.

private loginUser = (req: express.Request, res: expresss.response) => {
	// ...
	this.db.findUserById();
}

원인

참고한 문서에 따르면, TypeScript에서 this는 다음과 같이 정해진다고 합니다. (상위 항목부터 우선순위를 가짐)

  • 호출한 함수가 function#bind함수의 결과로 반환된 함수인 경우, thisbind에 전달된 인자를 가리킵니다.
  • 함수가 x.doSomething()과 같은 형태로 호출된 경우, 함수는 x를 가리킵니다.
  • strict 모드인 경우, thisundefined입니다.
  • 아니면, this는 전역 객체를 가리킵니다(브라우저에서는 window).

저의 경우에는 3번 항목이 원인이 된 것으로 보입니다. TypeScript의 strict 모드는 컴파일러 설정인 tsconfig.json에서 stricttrue를 지정하는 것으로 활성화되는데, 이로 인해 thisundefined가 된 것이었습니다.

결론

함수를 정의하는 방법에 큰 차이가 없어서 아무 생각 없이 사용한 문법이 수 시간의 삽질을 불러오게 될 줄은 몰랐습니다. 물론 하나 배웠다고 생각하니 마음은 뿌듯하지만, 원인을 찾는 데에 시간을 많이 쓰지 않았나 싶습니다. 역시 이런 부분은 경험으로 깨달아야 할 것 같습니다.

목록으로