JavaScript

this 톺아보기: 함수 호출 시점과 this 바인딩

GoJay 2024. 10. 28. 01:40

this란?

this는 함수 내부에서 특정한 객체를 가리키는 식별자다. 자세히 살펴보자.

먼저, this식별자다. 식별자란 데이터가 올려진 메모리에 붙여진 이름이다(또는, 데이터가 저장된 메모리 주소 그 자체이다). 변수, 함수, 함수의 파라미터 등이 식별자에 해당한다. 마찬가지로, this도 메모리에 올려진 어떠한 데이터(객체)의 메모리 주소를 가리킨다(참조한다). 물론, 특별한 메서드를 사용하지 않으면 사용자가 직접 this의 값을 지정할 수 없다는 점에서(호출 시 동적으로 바인딩 대상이 결정된다) 다른 식별자들과의 차이점이 있긴 하다. 하지만, 식별자의 본질인 '데이터가 저장된 주소를 참조하는' 역할은 동일하게 수행한다.

this는 함수에 위치하고 객체를 가리킨다. 실제로, this에 대한 정보는 실행 컨텍스트의 렉시컬 환경(Lexical Environment) 에 존재한다. 렉시컬 환경에는 환경 레코드를 구성하는 VariableEnvironment 컴포넌트와 LexicalEnvironment 컴포넌트, 그리고 this에 대한 바인딩 정보로 구성된다. 자세한 내용은 포스트 참고).

실행 컨텍스트 구성 요소

this는 함수 내부에서 사용돼야 하는 특정한 객체를 자기 참조하며, 왜 이런 동작이 필요한지를 이해하는 게 중요하다. 특히, this는 함수(또는 메서드)가 호출되는 방식에 따라 다른 값을 가리킨다(실행 컨텍스트에 생성될 당시엔 this 바인딩은 값을 갖지 않고, 함수가 호출될 당시의 문맥에 다라 바인딩 값을 다르게 갖는다. 마치, 상자를 열어봐야 살아있음을 확신할 수 있는 슈뢰딩거의 고양이 같은 느낌).

this는 다양한 코드에서 굉장히 자유 보이기 때문에 중요한 반면, 동적으로 값을 바인딩한다는 특성 때문에 값의 예측이 힘들어서 사용에 어려움도 있다. 그래서, this의 개념과 활용에 대해선 꼼꼼하게 잘 공부해두는 게 필요하다.

자기 참조가 필요한 상황 예시

아래는 this를 활용하는 예시다. this는 함수 내부에 위치하며, 특정 객체를 가리킨다고 했다. 아래 예제에서의 thiscallName이라는 메서드(메서드는 객체 내부에서 정의된 함수를 의미한다) 내부에 존재한다. 그리고, this가 가리키는 객체는 자기 자신인 myObj이다.

let myObj1 = {
    name: 'Jay',
    callName: function () {
        console.log(`Hi ${this.name}!`); // Hi Jay!
    }
}

myObj1.callName(); // Hi Jay!

이미지로 표현해보면 다음과 같다. this가 myObj의 스택 메모리 주소를 직접 참조하기 때문에, myObj 객체를 통해 호출된 callName 메서드(함수) 내부에 있는 thisthis.nameHi ${myObj.name}!이 되는 셈이다.

해당 상황만 보면 this의 필요성이 와닿지 않는다. 실제로 callName 메서드 내부에서 Hi ${myObj.name}! 이라고 객체 자기 자신의 이름을 그대로 사용해도 동일한 결과가 확인되기 때문이다.

myObj1 = {
    name: 'Jay',
    callName: function () {
        console.log(`Hi ${myObj1.name}!`); // Hi Jay!
    }
}

myObj1.callName();

그냥 자기 자신의 변수명을 그대로 참조해도 동일한 결과가 나옴에도 불구하고 this를 사용하는 이유는, 해당 메서드를 다른 객체에서 복제해 사용해 보면 알 수 있다.

let myObj1 = {
    name: 'Jay',
    callName: function () {
        console.log(`Hi ${myObj1.name}!`); // this가 아닌 자기 자신의 변수명으로 자기 참조
    }
}

let myObj2 = {
    name: 'Ko'
}

myObj2.callName = myObj1.callName // 메서드 복제

myObj2.callName(); // Hi Jay!

myObj2에서 myObj1의 메서드를 복제해서 사용했을 때 동작 의도는 myObj2 객체에 사용된 name 프로퍼티의 값을 사용해서 Hi Ko!라는 출력이 되는걸 기대했을 것이다(다른 객체의 name을 참조해서 값을 출력하기보단, 객체 내부에서 자체적으로 독립적인 값-동작 쌍이 대응되는 편이 자연스럽다). 하지만, 예상과 달리 메서드의 결과는 Hi Jay!가 출력된다. 메서드에서 myObj1 객체를 직접 자기 참조하고 있기 때문에, 다른 객체에서 메서드를 복제해서 사용하려고 할 때 문제가 생긴 것이다.

이처럼, 메서드는 다른 객체에서 복제돼서 사용될 가능성이 있다. 그리고, 복제된 객체의 메서드에서도 의도한 대로의 '자기 참조'를 동일하게 잘 구현하기 위해선 변수명으로 직접 참조하는 것이 아니라, 문맥에 따라 '자기 자신인 객체'를 다르게 가리킬 수 있는 별도의 식별자가 필요하다. 그리고, this가 해당 역할을 한다. 위의 예시는 아래와 같이 바꿔보자.

myObj1 = {
    name: 'Jay',
    callName: Function() {
        console.log(`Hi ${this.name)!`); Hi Ko!
    }
}

myObj2 = {
    name: 'Ko'
}

myObj2.callName = myObj1.callName;

myObj2.callName(); // console.log(`Hi ${myObj2.name)!1)과 같은 동작

myObj1에서 변수명으로 했던 자기 참조를 this로 변경하면 기대했던 대로 Hi Ko!라는 문자열이 콘솔에 잘 출력된다. 이렇게, this는 함수 내의 로직이 자기 자신인 객체의 값을 참조해야 하고(위의 경우 name 프로퍼티), 생성한 함수가 다른 곳에 복제해야 해서 단순 변수명으로 자기 참조를 할 경우(예를 들어, myObj1.name과 같이 변수명을 직접 참조한 경우) 유용하게 활용된다.

this의 동적 바인딩

메서드에서의 this 바인딩: 호출한 객체

위에 언급한대로, this는 함수 내부에서 특정 값을 자기 참조로 사용해야 하고, 해당 함수를 다른 곳에서 복제해서 사용하는 경우가 존재해 함수(또는 메서드)의 자기 참조 값이 복제될 때마다 변경이 필요한 경우 유용하다. 그리고, 해당 필요가 있는 대표적인 상황 중 하나가 위에서 활용한 객체 내의 메서드로 사용되는 경우이다.

객체 내부에 선언된 메서드는 객체 내부에 있는 다른 프로퍼티들을 참조할 수 있다. 객체라는 게 프로퍼티와 메서드를 조합하여 어떠한 개념(정의)을 구분 짓는 것이기 때문에, 특정 객체의 고유한 특성을 나타내는 데 있어서 메서드와 프로퍼티의 유기적인 참조와 활용이 필요하다.

이렇게, 객체 내부의 메서드에서 활용된 this의 경우, 메서드를 호출한 주체가 되는 객체가 this에 바인딩된다. 위의 예시에서 myObj1 내부에 최초로 등장에서 사용된 callName 메서드는 myObj1.callName()이라는 식으로 myObj1 객체에 의존돼서 사용되기 때문에, 이럴 경우 callName() 메서드의 this 바인딩엔 myObj1이 참조된다. 마찬가지로, myObj2 객체에서 callName 메서드를 복제한 후 myObj2.callName() 형태로 사용하면 메서드의 this 바인딩은 myObj2를 참조한다. 메서드는 메서드를 호출한 객체를 this로 가리킨다.

참고로, myObj2.callName = myObj1.callName; 형태로 객체의 메서드를 직접 할당하면 두 객체는 같은 함수를 메서드로 참조한다(얕은 복사와 깊은 복사 참고). 결국, myObj1.callName()myObj2.callName()은 같은 함수를 참조하고, 동일한 명령을 실행하는 것이다. 하지만, myObj1.callName()myObj2.callName()의 실행 컨텍스트가 다르고, 렉시컬 환경에서의 this 바인딩 정보가 다르기 때문에, 둘은 다른 동작을 수행한다. 즉, 함수가 호출되어 생성된 실행 환경에 따라 같은 코드도 다른 결과가 호출될 수 있는 것이다.

생성자 함수에서의 this 바인딩: (미래에 생성할) 인스턴스

생성자 함수는 말 그대로 '객체를 생성하는 함수'다. 그리고, 위에서 얘기한 '함수 내에서 자신의 객체를 참조해야 하고, 복제된 값을 사용해서 this의 참조가 변경돼야 하는 경우에 정확하게 해당한다. 아래는 대표적인 생성자 함수의 this 사용 예시다.

function Person(name, age) {
    this.name = name; 
    this.age = age;
}

let person1 = new Person('Jay', 20);

console.log(person1); // Person {name: 'Jay', age: 20}

위의 상황에서 사용된 this는 생성자 함수인 Person을 가리키는 게 아니라, 미래에 생성될 객체인 person을 가리킨다. 그리고, 생성자 함수로 다른 객체를 생성할 때마다 this는 새롭게 생성된 인스턴스를 가리킨다(확인을 위해 생성자 함수에 console.log로 생성자 함수 호출 시마다 this가 가리키는 값을 출력해 봤다).

function Person(name, age) {
    this.name = name; 
    this.age = age;

    console.log(this, this.name, this.age)
}

let person1 = new Person('Jay', 20); // Person {name: 'Jay', age: 20} 'Jay' 20;
let person2 = new Person('Ko', 21); // Person {name: 'Ko', age: 20} 'Ko' 21;

this가 가리키는 값이 인스턴스로 확인됐다. 이렇게, 생성자 함수는 this를 통해 자신이 생성할 인스턴스를 가리키고, 해당 인스턴스에 추가할 프로퍼티에 대한 정의를 수행한다. 미래에 생성될 인스턴스를 가리킬 식별자'라는 컨셉을 가진 식별자는 this뿐이기 대문에, this 없이 해당 과정을 수행한다는 건 불가능하다.

// 아래 코드는 SyntaxError가 발생합니다
fuction Person(name, age) {
    let name = name; // let으로 선언한 변수는 미래에 생성할 인스턴스가 참조할 수 없음
    'age': age;
}

자바스크립트의 this는 동적 바인딩이라는 특성 때문에, 함수가 호출되는 상황에 따라 this에 바인딩되는 값이 다르다. 하지만, 다른 객체 지향 언어들에서 this와 같이 자기 참조를 하는 변수들은 클래스 생성 시 사용된다. 그리고, 생성자 함수가 유사하게 객체를 생성하는 생성자(원형)를 의미한다는 점과, 자바스크립트에 추가된 클래스 문법도 생성자 함수를 확장했다는 점을 봤을 때, 가장 this의 정의를 명료하게 이해할 수 있는 대표적인 사용 사례가 생성자 함수를 사용한 경우인 게 아닌가 생각한다.

중첩 함수에서의 this

객체 안의 메서드에서 함수를 선언하는 경우가 있다. 아래는 예시다.

let myObj = {
    name: 'Jay',
    myMethod: function () {
        console.log(this.name); // Jay
        function myFunc () {
            console.log(this.name); // '' (빈 문자열)
        }

        myFunc();
    }
}

myObj.myMethod();

myMethod 메서드는 myObj 객체로부터 호출됐기 때문에, myMethodthismyObj다. 객체의 메서드가 갖는 this는 메서드를 소유한 객체를 가리킨다.

하지만, myMethod 메서드 내부에서 선언된 myFuncthis는 전역 객체다(일반 함수로 선언되고 호출된 모든 함수의 this는 전역을 가리킨다). 만약에 myMehtod 메서드 내부의 함수 선언문 myFunc에서 상위 스코프 함수의 this를 참조해야 하는 경우라면 이러한 동작은 문제가 된다.

let myObj = {  
    firstName: 'Jay',
    lastName: 'Ko',
    fullName: function() {
        let getFullName = function() {
            return `${this.firstName}` + ' ' + `${this.lastName}`;
        }

        console.log(getFullName());
    }
}

myObj.fullName() // undefined undefined

fullName 메서드의 내부 함수 getFullName은 일반 함수로 호출됐기 때문에 this가 전역을 가리킨다(브라우저일 경우 window를 가리킨다). 그리고, 전역 객체엔 firstNamelastName이라는 프로퍼티가 없는 상황이기 때문에, 값으로 undefined undefined가 출력된다. 이는, 중첩 함수에서의 this 바인딩이 다른 상황이 문제가 되는 하나의 예시다(예시를 위해 만든 상황이고, 좋은 코드는 아닌 것 같다).

해당 상황을 해결하려면 중첩 함수로 선언한 getFullName 함수에 명시적으로 fullName 메서드와 같은 this를 바인드 해줘야 한다. 이를 위해 bind, apply, call 메서드를 사용할 수 있다. bindthis를 바인딩만 하고 함수 호출은 하지 않는다.

// bind는 this 바인딩만 해주고 함수 호출은 하지 않는다
myObj = {  
    firstName: 'Jay',
    lastName: 'Ko',
    fullName: function() {
        let getFullName = function() {
            return `${this.firstName}` + ' ' + `${this.lastName}`;
        }.bind(this); // getFullName 함수에 fullName 메서드의 this를 binding

        console.log(getFullName());
    }
}

myObj.fullName(); // Jay Ko

applycall은 둘 다 함수를 호출하면서 this를 바인딩한다(applycall은 원래 태생이 함수를 호출하는 동작을 하는 거고, 해당 과정에 this 바인딩 정보를 전달하는 인자를 이용하는 것이다). 둘은 아규먼트로 전달할 인자의 데이터 형식에만 차이가 있고, 실제 동작은 동일하다.

// apply는 함수를 호출하면서 첫번째 인자의 값을 this에 바인딩한다
// apply는 인자로 배열을, call은 문자열을 전달한다
myObj = {  
    firstName: 'Jay',
    lastName: 'Ko',
    fullName: function() {
        let getFullName = function() {
            return `${this.firstName}` + ' ' + `${this.lastName}`;
        }

        console.log(getFullName.apply(this, []));
    }
}

myObj.fullName(); // Jay Ko

화살표 함수(Arrow Function)

bind, apply, call 메서드를 사용해 명시적으로 this를 바인딩하는 것 말고도, 화살표 함수를 사용해서도 중첩 함수의 this 바인딩 문제를 해결할 수 있다.

화살표 함수의 this는 함수가 정의되는 시점에 결정된다. 그리고, 함수 호출 시점에 따라 this가 가리키는 대상이 동적으로 바뀌지 않는다. 따라서, 화살표 함수를 사용하면 this의 값이 유동적으로 변경해 다른 대상을 지칭하여 발생할 수 있는 문제를 일부 해결할 수 있다.

그리고, 함수 정의 시점에 화살표 함수의 this 바인딩되는 객체는 상위 스코프의 this다. 즉, 중첩 함수가 선언된 경우 화살표 함수로 선언된 함수의 this는 상위 함수의 this를 그대로 참조하게 되고, 손쉽게 중첩된 함수의 this를 일치시킬 수 있다.

위에서 활용한 예제를 화살표 함수를 활용해 변경해 보면 다음과 같다.

// 화살표 함수 getFullName은 fullName의 this를 참조한다
myObj = {  
    firstName: 'Jay',
    lastName: 'Ko',
    fullName: function() {
        let getFullName = () => {
            return `${this.firstName}` + ' ' + `${this.lastName}`;
        }

        console.log(getFullName());
    }
}

myObj.fullName(); // Jay Ko

화살표 함수가 자바스크립트의 비교적 최신 문법이라는 점과, 화살표 함수의 this 바인딩 방식이 정적이면서 동시에 가리키는 대상을 보다 명확하게 할 수 있다는 걸 고려했을 때, 화살표 함수의 등장 자체가 this 바인딩 값 추론의 어려움을 해소하기 위한 방향으로 설계된 게 아닌가 싶다. 아무튼, 필요한 상황에서 화살표 함수를 잘 사용해서 동작이 예측 가능학 코드를 짤 수 있도록 노력해야겠다.

결론

짧은 개발 경험이지만, 그 속에서도 this가 가리키는 값이 헷갈리는 경우를 몇 번 겪었다. 그런 의미에서 좀 더 공부해 보고 내용을 정리해 둔다. 다양한 케이스 경험해 보면서 더 적합한 방식으로 this 바인딩을 처리할 수 있게 노력해야겠다. 끝.