우당탕탕 우리네 개발생활

[JS] this를 사용할 때 이런 경우를 조심하세요! 본문

tech

[JS] this를 사용할 때 이런 경우를 조심하세요!

미스터카멜레온 2024. 8. 2. 08:07
class Logger {
  error(error: unknown) {
    this.log(error);
  }

  log(arg: unknown) {
    console.log(arg);
  }
}

class Test {
  private logger = new Logger();

  private async doSomethingAsPrivate() {
    throw new Error('error');
  }

  public async doSomething() {
    // ...
    this.doSomethingAsPrivate().catch(this.logger.error);
  }
}

function main() {
  const test = new Test();

  test.doSomething();
}

main();

위 코드를 말로 풀어보면 아래와 같습니다.

 

1. Logger라는 로깅을 담당하는 클래스가 있습니다.

2. Logger는 log라는 메서드를 가지고 있습니다.

3. Logger는 error라는 메서드도 가지고 있습니다.

4. error메서드는 내부 구현에서 log라는 메서드를 호출하고 있습니다.

5. Test class가 있습니다.

6. Test는 doSomething 이라는 비동기 메서드를 가지고 있습니다.

7. doSomethingAsPrivate 이라는 private 비동기 메서드도 가지고 있습니다. 이 메서드는 의도적으로 에러를 던지도록 설정했습니다.

8. doSomething 메서드는 내부 구현에서 doSomethingAsPrivate 메서드를 호출하고 있습니다.

9. 이 비동기 메서드(doSomethingAsPrivate)는 await 를 사용하지 않고 에러를 catch chain으로만 처리하려고 합니다.

10. Test 의 인스턴스 변수로 Logger를 인스턴스로 생성해놨습니다.

11. doSomething 메서드 구현에서 doSomethingAsPrivate.catch 함수에 인자로 this.logger.error 를 넘겨 주었습니다.

12. main이라는 함수의 구현에서 Test 인스턴스를 생성한 후, test.doSomething 메서드를 호출했습니다.

13. main함수를 호출했습니다.

 

위 코드를 직접 읽어 보셨거나 위 설명을 천천히 읽으셨을 때 특이점이 보이시나요? 위 코드의 실행 결과는 어떨까요?

 

결론부터 얘기하자면 런타임 에러가 발생합니다.

 

무엇이 문제 였을까요?

 

실무에서 위와 같은 문제를 직접 겪었었고 이를 해결해보면서 js의 주요 개념인 this 바인딩에 대해 이해를 높일 수 있는 계기가 되었습니다. 시행착오를 통해 정리해 본 지식들을 공유하고자 합니다.

this에 대한 이해

js에서 유독 복잡(?) 또는 독특(?)하다는 this에 대한 이해가 필요합니다(this에 대한 기본 이해가 아예 없으신 분들이라면 이곳에서 쉽게 공부하실 수 있습니다). 

 

js에서의 this는 런타임 시 해당 구문이 실행될 때 평가가 된다는 매우 중요한 특징을 가지고 있습니다. 이러한 특징은 곧 js의 context 철학과 관련이 있는데, 함수가 실행되는 그 시점의 맥락(context)이 무엇이냐에 따라 this가 유동적으로 변할 수 있음을 의미합니다. 예를 들어 아래와 같이 이름을 반환해주는 함수가 하나 있습니다(여기서 this를 사용하고 있음에 주목합니다). 보시는 바와 같이 이 함수를 cat이라는 리터럴 객체에도 포함시켜보고, dog라는 객체에도 포함시켜봤습니다. 그 후 이들의 메서드로써 함수를 실행했고, 마지막은 함수를 실행하는 주체 없이 함수 자체만 실행을 했습니다. 주체가 있는 printName함수는 그 주체의 name을 반환했고, 주체가 없는 printName은 error를 던졌습니다.

function printName() {
  return this.name
}

let cat = {
  name: 'cat',
  printName,
}

let dog = {
  name: 'dog',
  printName,
}

console.log(cat.printName()); // cat
console.log(dog.printName()); // dog
console.log(printName()); // Cannot read properties of undefined (reading 'name')

이 예제를 통해 this를 사용하는 상황에서 '주체'가 얼마나 중요한 지 알게 되셨을 거라 생각합니다. 이 '주체'가 바로 앞서 얘기했던 '맥락(context)'과 대응된다고 생각하시면 될 것 같습니다. this를 사용할 땐 항상 맥락이 명확해야 한다는 사실을 명심해야 합니다.

Logger 클래스

위에서 살펴봤던 코드 중에서 Logger 클래스의 구현부만 다시 한번 살펴봅니다.

class Logger {
  error(error: unknown) {
    // this를 사용해서 내부 메서드를 실행하고 있습니다.
    this.log(error);
  }

  log(arg: unknown) {
    console.log(arg);
  }
}

앞서 예시를 봤던 상황과 현재 Logger 클래스의 this를 대비시켜볼까요?

Dog or Cat Logger
printName 메서드 error 메서드
this.name this.log()

 

이렇게 딱 비교가 되니 Logger를 인스턴스로만 만든다면, 해당 주체가 error 함수를 호출했을 때 기대하는 동작대로 실행될 것 같습니다.

 

Logger 클래스 자체도 문제가 없다는 것은 확인했습니다. 다시 돌아와서 그렇다면 무엇이 런타임 에러를 발생하게 만들었을까요?

'함수 자체'를 인자로 사용하는 것과 '함수의 실행결과'를 인자로 사용하는 것

다른 언어들은 어떤지 모르겠지만 js에서는 함수 자체를 다른 함수의 인자로 전달할 수 있다는 특징이 있습니다(함수에 대한 공부가 필요하시다면 이곳이 좋습니다). 이 특징은 위 문제상황과 관련이 있습니다.

 

이러한 힌트를 통해 대다수 분들은 문제의 라인을 식별하셨으리라 생각합니다. 주목해야할 부분은 아래 코드라인 입니다.

this.doSomethingAsPrivate().catch(this.logger.error);

this.logger.error는 아직 함수 그 자체이지 실행된 함수가 아닙니다. 여기서 짐작해볼 수 있는 중요한 포인트는 catch 함수가 인자로 전달 받은 this.logger.error 함수를 내부적으로 실행하겠구나가 될 것입니다(혹시 헷갈리실 수도 있는데 this.logger.error 라는 글자 자체에서의 this는 this.doSomethingAsPrivate().catch(this.logger.error) 구문이 런타임에서 실행되면서 평가를 받고 제역할을 다하게 되어 catch 내부에서까지 영향을 미치지 않습니다. catch에 인자로 전달된 함수는 그저 error함수입니다. 이 this가 문제를 일으킨다는 오해는 안하셨으면 합니다).

 

Logger 클래스의 error 메서드는 아래와 같이 내부적으로 this를 통해 메서드를 호출하고 있었습니다.

  error(error: unknown) {
    // this를 사용해서 내부 메서드를 실행하고 있습니다.
    this.log(error);
  }

 

앞서 얘기했던 중요한 특성! this는 런타임 시 실행될 때 결정된다 를 되새겨봅니다.

 

이를 기반으로 위 코드를 보면 catch 함수의 내부 구현이 어떻게 되어 있는지는 모르겠지만 한 가지 확실한 건 더이상 this가 지칭하는 주체가 logger가 아니게 된다는 사실입니다. 런타임에서 해당 구문을 실행할 때 error라는 함수는 this.logger라는 인스턴스의 메서드가 아닌 알 수 없는 주체에게 실행되는 그냥 그런 함수가 될 것입니다. 

 

문제는 여기서 주목할 부분은 this.logger 인스턴스의 메서드였던 error() 는 this.logger의 또다른 메서드인 log() 를 내부 구현에서 사용하고 있다는 점입니다. 심지어 this.log 의 형태로 말이죠.

 

이제 무슨일이 발생할까요?

 

catch 내부 구현에서 어떠한 인스턴스를 통해 error 함수가 메서드로서 실행될 가능성도 있고, 주체가 없이 함수 자체로 실행될 가능성도 있습니다. 중요한 건 그 주체가 뭐가 됐건 this.logger는 아니라는 것입니다. this.logger 가 주체가 아닌 이상 error 내부 구현에서 호출됐던 this.log 에 대한 정보는 알 수 없는 정보가 되어버리게 됩니다. 그렇기에 해당 뭔지모를인스턴스.log() or undefined.log() 는 잘못된 참조를 야기하고 런타임 에러를 발생시키게 되는 것 입니다.

 

에러의 원인은 명확히 알게 되었습니다. 그렇다면 이런 상황을 벗어날 수 있는 방법은 무엇이 있을까요?

 

하나. 화살표 함수

하나는 화살표 함수(arrow function)을 사용하는 방법입니다. 화살표 함수 내부에서 사용된 this는 화살표 함수가 선언된 스코프의 바로 상위 스코프를 참조한다는 특성이 있습니다(실행이 아닌 선언 시인 점을 명심해야 합니다). 아래 예시에서 보면 Test 클래스의 doSomething 메서드 내부가 화살표 함수의 선언 스코프이기 때문에 그 상위 스코프인 Test 인스턴스를 참조하게 되고 this는 곧 Test 인스턴스를 나타내게 됩니다. 그렇기 때문에 this(Test인스턴스).logger는 정상적으로 동작하게 됩니다.

class Test {
    // 화살표 함수 선언 스코프의 상위 스코프
    private logger = new Logger();

  private async doSomethingAsPrivate() {
    throw new Error('error');
  }

  public async doSomething() {
    // ...
    // 화살표 함수의 선언 스코프
    this.doSomethingAsPrivate().catch((error) => this.logger.error(error));
  }
}

 

둘. bind 함수

두번째는 bind() 함수를 사용하는 방법입니다. bind는 js에서 기본적으로 제공하는 함수로, function.bind(context)의 형태로 사용할 수 있습니다. 앞서 설명했듯이 js에서의 this는 맥락을 중시합니다. function이 어떤 객체의 메서드로 사용되느냐에 따라 this가 바뀌는데 이 this 는 곧 context를 의미합니다. 그런의미에서 다시 bind() 함수를 주목해보면, bind(context)의 인자로 context(맥락)를 넘겨주는 모습을 볼 수 있습니다. 즉, 이 함수의 주체가 될 context(인스턴스)를 명시적으로 나타내게 되는 것 입니다. 이렇게 되면 this.logger.error 라는 함수(사실 그냥 error)는 this.logger(사실 그냥 Logger 인스턴스)라는 context에서 사용될 메서드라고 확정이 되면서 원래 의도에 맞는 this.logger.error 메서드가 됩니다.

class Test {
  private logger = new Logger();

  public async printLog() {
    throw new Error('error');
  }
	
  public async print() {
    // this.logger.error 라는 함수에 this.logger라는 context를 인수로 넘겨준다
    this.printLog().catch(this.logger.error.bind(this.logger));
  }
}

 

정리

실무에서 겪었던 문제를 돌아보면서 정리를 하니 개념에 대한 이해가 더 높아졌습니다. js에서 this에 대한 시행착오를 겪고 계신 많은 분들께 저의 시행착오가 도움이 되었으면 좋겠습니다.