오늘은 자바스크립트에서 많은 개발자들이 헷갈려하는 this 키워드에 대해서 확실하게 짚고 가보려고 한다.
많은 개발자들은 this를 다음 두 가지 의미로 많이 생각한다.
- 함수 그 자체를 가리킴
- 함수의 스코프(scope)를 가리킴
결론적으로 둘 다 올바른 의미로 이해한 것은 아니다. 하나씩 설명해 보려고 한다,
자기 자신으로의 this
함수가 내부에서 자기 자신을 가리킬 일이 언제 있을까? 재귀 로직이 들어가는 경우도 있고 최초 호출 시 이벤트에 바인딩 된 함수 자신을 언바인딩 할 때도 자기 참조가 필요하다.
아래의 코드는 함수가 this로 자기 참조를 할 수 없다는 것을 보여주는 예제이다.
foo.count = 0을 하면 foo라는 함수 객체에 count 프로퍼티가 추가된다. 하지만 this.count에서 this는 함수 객체를 바라보는 것이 아니며, 프로퍼티 명이 똑같아 헷갈리지만 근거지를 둔 객체 자체가 다르다. 여기에서 증가한 값은 함수 객체 foo의 프로퍼티 count가 아니라, 전역 변수 count이다.
이 문제를 렉시컬 스코프(Lexical Scope)등을 이용하여 우회적으로 해결할 수도 있다. 자바스크립트는 렉시컬 스코프를 따르는데, 렉시컬 스코프란 함수를 어디서 호출하는지가 아니라 어디에 선언하였는지에 따라 결정되는 스코프이다. 함수를 어디서 호출하였는지는 스코프 결정에 아무런 의미를 주지 않는다.
foo 함수 객체를 직접 가리키도록 강제하는 방법은 다음과 같다.
함수의 스코프 this
this가 함수의 스코프를 가리킨다는 말은 아주 흔한 오해이다. 가끔은 맞지만 잘못 이해한 개념이다. this는 어떤 식으로도 함수의 렉시컬 스코프를 참조하지 않는다. 내부적으로 스코프는 별개의 식별자가 달린 프로퍼티로 구성된 객체의 일종이나 스코프 '객체'는 자바스크립트 구현체인 '엔진'의 내부 부품이기 때문에 일반적인 자바스크립트 코드로는 접근하지 못한다.
this가 암시적으로 함수의 렉시컬 스코프를 가리키도록 해보자. 이 코드는 물론 실패한다.
이 코드에는 여러가지 문제가 있다. 먼저 bar() 함수를 this.bar()로 참조하려고 한 것부터 문제이다. bar() 앞의 this를 빼고 식별자를 어휘적으로 참조하는 것이 가장 자연스러운 호출방법이다. 물론 작성자는 foo()와 bar()의 렉시컬 스코프 사이에 어떤 통로를 만들어 bar()가 foo()의 내부 스코프 변수에 있는 변수 a에 접근하게 하고 싶었을 것이다. 그러나 그런 연결 통로는 없다.
그렇다면 this는 도대체 무엇인가? this는 작성 시점이 아닌 런타임 시점에 바인딩 되며 함수 호출 당시 상황에 따라 콘텍스트가 결정된다. 함수 선언 위치와 상관없이 this 바인딩은 오로지 어떻게 함수를 호출했느냐에 따라 정해진다.
어떤 함수를 호출하면 활성화 레코드(Activation Record), 즉 실행 콘텍스트(Execution Context)가 만들어진다. 여기엔 함수가 호출된 근원(Call-Stack)과 호출 방법, 전달된 인자 등의 정보가 담겨 있다. this 레퍼런스는 그 중 하나로, 함수가 실행되는 동안 이용할 수 있다. this가 함수 자신이나 함수의 렉시컬 스코프를 가리키는 레퍼런스가 아니라는 점을 분명히 인지해야 한다.
this 바인딩의 개념을 이해하려면 호출부, 즉 함수 호출(선언 X) 코드부터 확인하고 this가 가리키는 것이 무엇인지를 찾아보아야 한다. 호출부와 호출 스택을 설명하기 위한 예제를 확인해 보자.
지금부터 함수가 실행되는 동안 this가 무엇을 참조할지를 호출부가 어떻게 결정하는지 알아보자. 총 4가지 규칙이 있다.
기본 바인딩
첫 번째 규칙은 가장 평범한 함수 호출인 '단독 함수 실행(Standalone Function Location)'에 관한 규칙으로 나머지 규칙에 해당하지 않을 경우 적용되는 this의 기본 규칙이다.
다음과 같이 var a = 2 처럼 전역 스코프에 변수를 선언하면 변수명과 같은 이름의 전역 객체 프로퍼티가 생성된다. 이는 서로의 사본이 아니고 같은 동전의 앞뒷면 이라고 보면 된다. 그리고 foo() 함수 호출 시 this.a는 전역 객체 a다. 기본 바인딩이 적용되어 this는 전역 객체를 참조한다.
암시적 바인딩
두 번째 규칙은 호출부에 콘텍스트 객체가 있는지, 즉 객체의 소유/포함 여부를 확인하는 것이다.
앞에서 선언한 foo() 함수를 obj에서 프로퍼티로 참조하고 있다. foo()를 처음부터 foo 프로퍼티로 선언하든 이 예제처럼 나중에 레퍼런스로 추가하든 obj 객체가 이 함수를 정말로 '소유'하거나 '포함'한 것은 아니다. 그러나 호출부는 obj 콘텍스트로 foo()를 참조하므로 obj 객체는 함수 호출 시점에 함수의 레퍼런스를 '소유'하거나 '포함'한다고 볼 수 있다.
이런 패턴을 뭐라 하건 foo() 호출 시점에 이미 obj 객체 레퍼런스는 준비된 상태다. 함수 레퍼런스에 대한 콘텍스트 객체가 존재할 때 암시적 바인딩(Implicit Binding) 규칙에 따르면 바로 이 콘텍스트 객체가 함수 호출 시 this에 바인딩 된다. foo() 호출 시 obj는 this이니 this.a는 obj.a가 된다.
암시적으로 바인딩 된 함수에서 바인딩이 소실되는 경우가 있는데 this 바인딩이 뜻밖에 헷갈리기 쉬운 경우다.
bar는 obj의 foo를 참조하는 변수처럼 보이지만 실은 foo를 직접 가리키는 또 다른 레퍼런스다. 게다가 호출부에서 그냥 평범하게 bar()를 호출하므로 기본 바인딩이 적용된다.
콜백 함수를 전달하는 경우에는 좀 더 결과가 애매하게 나온다.
인자로 전달하는 건 일종의 암시적인 할당이다. 따라서 예제처럼 함수를 인자로 넘기면 암시적으로 레퍼런스가 할당되어 이전 예제와 결과가 같다.
이처럼 콜백 과정에서 this 바인딩의 행방이 묘연해지는 경우가 많다. 콜백 호출 시 this를 개발자가 의도적으로 변경하면 미궁에 더 깊이 빠질 수 있다. 하지만 가져다 쓰는 입장에서 다른 방법은 없다. 어떤 이유 때문이든 this가 예기치 않게 바뀌어도 콜백 함수의 레퍼런스를 우리가 마음대로 통제할 수가 없고, 따라서 각자의 입맛에 맞게 호출부를 조정할 수도 없다. 뒷부분에서 this를 고정해 이 문제를 해결하는 방법을 소개한다.
명시적 바인딩
앞서 살펴본 암시적 바인딩에서는 함수 레퍼런스를 객체에 넣기 위해 객체 자신을 변형해야 했고 함수 레퍼런스 프로퍼티를 이용하여 this를 간접적으로 바인딩했다. 이 때 함수 레퍼런스 프로퍼티를 이용하지 않고 직접 밝히는 방법은 없을까? 이럴 때 모든 자바스크립트 함수에서 사용할 수 있는 유틸리티가 call()과 apply()이다. 두 메서드는 this에 바인딩할 객체를 첫째 인자로 받아 함수 호출 시 이 객체를 this로 세팅한다. this를 지정한 객체로 직접 바인딩 하므로 이를 '명시적 바인딩(Explicit Binding)'이라 한다.
foo.call()에서 명시적으로 바인딩하여 함수를 호출하므로 this는 반드시 obj가 된다.
명시적 바인딩을 약간 변형한 방법이 있다. 아래의 코드를 보도록 하자.
함수 bar()는 내부에서 foo.call(obj)로 foo를 호출하면서 obj를 this에 강제로 바인딩 하도록 하드 코딩한다. 따라서 bar를 어떻게 호출하든지 이 함수는 항상 obj를 바인딩하여 foo를 실행한다. 이런 바인딩은 명시적이고 강력해서 '하드 바인딩'이라고 한다.
하드 바인딩으로 함수를 감싸는 형태의 코드는 다음과 같이 인자를 넘기고 반환 값을 돌려받는 창구가 필요할 때 주로 쓰인다.
new 바인딩
네 번째 바인딩 규칙을 설명하기 전에 먼저 JS 함수와 객체에 대한 오해 하나를 바로잡고 가자. 전통적인 클래스 지향 언어의 생성자는 클래스에 붙은 특별한 메서드로 클래스 인스턴스 생성 시 new 연산자로 호출한다. 자바스크립트도 new 연산자가 있고 사용 방법이 다른 클래스 지향 언어와 별 차이가 없어 보인다. 그래서 비슷할 거라고 생각할 수가 있는데, 자바스크립트에서 new는 클래스 지향적인 기능과 전혀 상관이 없다. 자바스크립트 생성자는 앞에 new 연산자가 있을 때 호출되는 일반 함수에 불과하다. 클래스에 붙은 것도 아니고 클래스 인스턴스화 기능도 없다. 단지 new를 사용하여 호출할 때 자동으로 붙들려 실행되는 그저 평범한 함수다.
앞에 new를 붙여 foo()를 호출했고 새로 생성된 객체는 foo 호출 시 this에 바인딩 된다. 따라서 결국 new 함수는 함수 호출 시 this를 새 객체와 바인딩하는 방법이며 이것이 new 바인딩이다.
함수 호출부에서 this가 결정되는 규칙의 우선순위는 다음과 같다.
자바스크립트 ES6에서도 기본적으로는 위에서 설명한 this 바인딩의 4가지 규칙을 따른다. 하지만 ES6에서 화살표 함수(Arrow Function)가 등장하면서 this에 대한 추가적인 바인딩 규칙이 생겼다. 화살표 함수는 에두른 스코프(Enclosing Scope)를 보고 this를 알아서 바인딩 한다. 아래의 예제를 보자
foo() 내부에서 생성된 화살표 함수는 foo() 호출 당시 this를 무조건 어휘적으로 포착한다. foo()는 obj1에 this가 바인딩 되므로 bar의 this 역시 obj1으로 바인딩 된다. 화살표 함수의 어휘적 바인딩은 절대로 오버라이드를 할 수 없다.
정리
많은 내용을 이번 포스팅에서 다루었다. 간단하게 정리하면서 마무리하려고 한다.
함수 실행에 있어서 this 바인딩은 함수의 직접적인 호출부에 따라 달라진다. 일단 호출부를 식별한 후 다음 4가지 규칙을 열거한 우선순위에 따라 적용한다.
- new로 호출했다면 새로 생성된 객체로 바인딩한다.
- call이나 apply 또는 bind로 호출되었다면 주어진 객체로 바인딩 된다.
- 호출의 주체인 콘텍스트 객체로 호출되었다면 바로 이 콘텍스트 객체로 바인딩 된다.
- 기본 바인딩에서 엄격 모드는 undefined, 그 밖엔 전역 객체로 바인딩 된다.
실수로 또는 예기치 않게 기본 바인딩 규칙이 적용되는 경우를 조심해야 한다. this 바인딩을 안전하게 하고 싶으면 Object.create(null) 처럼 DMZ 객체를 자리 끼움 값으로 바꿔 넣어 뜻하지 않는 부수 효과가 전역 객체에서 발생하지 않게 한다.
ES6 화살표 함수는 표준 바인딩 규칙을 무시하고 렉시컬 스코프로 this를 바인딩 한다. 즉, 에두른 함수 호출로부터 어떤 값이든 this 바인딩을 상속한다. 이는 ES6 이전 시절 self = this 구문을 대체한 장치이다.
참고문헌
- <YOU DON'T KNOW JS (this와 객체 프로토타입, 비동기와 성능)> 카일 심슨 저
'Prog. Langs & Tools > JavaScript' 카테고리의 다른 글
JS #6. 자바스크립트 작동 위임(behavior delegation) (0) | 2020.05.06 |
---|---|
JS #5. 자바스크립트 프로토타입(Prototype) (0) | 2020.04.27 |
JS #4. 자바스크립트 클래스(Class) (0) | 2020.04.22 |
JS #3. 자바스크립트 객체(Object) (0) | 2020.04.15 |
JS #1. ES6에서 import/export 올바르게 이해하고 사용하기 (2) | 2020.01.28 |