this 와 Dynamic scoping

Sean H.W. Lee
11 min readJun 10, 2021

--

JavaScript 를 공부하다보면 꼭 한번은 어리둥절하게 만드는 this. 그 동안 벌써 두 세번은 정리해 본것 같지만 아직도 두루뭉술하게만 알고 있지, 이거야! 라고는 못하겠다. 그래서 정말 마지막이다 생각하고 다시 한번 정리해본다!

Photo by sydney Rae on Unsplash

this 란?

우선 this 의 정의부터 알아보면

자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 자기 참조 변수. this 를 통해 자신이 속한 객체 또는 자신이 생성할 인스턴스의 프로퍼티나 메서드를 참조할 수 있다.

정의만 보면 무슨 개념인지 한번에 이해하기 어려운 것들중의 하나가 바로 this 이고, 그래서 번번히 헷갈리는것 같다. 그럼 조금 더 이해하기 쉽게 주변부터 정리해 보자.

“객체 지향 프로그래밍”에서 객체는 객체의 상태를 나타내는 프로퍼티와 동작을 나타내는 메서드를 하나의 단위로 묶은 자료구조 라고 정의하고 있다. 여기서 메서드는 자신이 속한 객체의 상태를 참조하고 변경할 수 있어야 하는데, 이때 메서드가 상태를 참조하려면 먼저 자신이 속한 객체를 가리키는 식별자를 참조할 수 있어야 한다.

위 말을 생성자 함수를 통해 객체를 생성하는 경우에 적용해 풀어보면, 생성자 함수의 내부에서는 상태나 메서드를 추가하기 위해 자신이 만들 인스턴스를 참조할 수 있어야 한다. 하지만 인스턴스를 만드려면 생성자 함수가 정의되고 new 키워드를 사용해서 생성자 함수를 호출해야한다. 즉, 생성자 함수가 정의되는 상황에서는 인스턴스가 생성되기 이전이고, 따라서 생성자 함수 입장에서는 자신이 만들 인스턴스를 가리킬 방법이 없다. 이때 자신이 속한 객체 혹은 자신이 만들 인스턴스를 가리키는 식별자가 필요하고, 그래서 나온게 this 이다.

(100% 장담은 못하겠지만,) 위 설명을 아래와 같은 코드에 적용했을 때 주석과 같이 이해하면 큰 무리는 없었던 듯 하다.

function SquareArea (width) {
this.width = width
// 이것(내가 만들 인스턴스)의 width
}
SquareArea.prototype.getArea = function() {
return this.width * this.width
// 이것(내가 만들 인스턴스)의 width
}

const square = new SquareArea(3);
console.log(square.getArea()) // 9

this 바인딩

this 바인딩은 함수가 호출되는 방식에 따라 동적으로 결정된다. 즉, this 는 상황에 따라 가리키는 대상이 다르다.

여기서 잠깐❗️

binding 이란❓

프로그래밍 언어를 처음 배울 때 단어 자체가 의미하는 바가 이해가 안 되서 전체 맥락까지 이해가 안 되는 경우가 한번씩 있는데, 바인딩이란 단어가 그중 하나였다. 그래서 논지에서 벗어나기는 하지만 나온김에 바인딩의 뜻도 정리해 본다.

바인딩이란 식별자와 값을 연결하는 과정이다. 쉽게 말해 변수를 선언하면 변수 이름과 값을 저장할 메모리 공간이 바인딩(묶인다)된다. 이 개념을 this 바인딩에 적용해 보면 this 라는 식별자와 this 가 가리킬 대상(객체)를 묶는 것을 this 바인딩이라고 보면 된다.

다시 본론으로 돌아와서, 아래는 this 가 상황에 따라 무엇을 가리키는지, 즉 호출 방식에 따라 동적으로 결정됨을 보여주는 예시!

this 의 동적 바인딩

위에서 가볍게 확인하기는 했지만 this 바인딩이 결정되는 방식에 대해 조금 더 깊이 정리해 본다. 우선 앞서 말했듯이 this 는 함수가 호출되는 방식에 따라 동적으로 결정된다. 또한 함수의 호출 방식도 아래와 같이 분류할 수 있다.

  1. 일반 함수 호출
  2. 메서드 호출
  3. 생성자 함수 호출
  4. call, apply, bind 메서드에 의한 간접 호출

일반 함수 호출

앞서 살펴본 예시와 같이 일반 함수로 호출했을 때는 기본적으로 this에 전역 객체가 바인딩된다. 이는 전역함수는 물론이고 중첩 함수나 콜백 함수도 일반 함수로 호출했을 때는 동일하게 적용된다. 헌데, 처음에 this 에 대해 정의했듯이 this 는 자신이 속한 객체나 자신이 생성할 인스턴스를 가리키기 위한 용도로 사용되기 때문에 일반 함수를 호출하는 경우에서 this는 큰 의미가 없다. (즉, 그닥 쓸 일이 없다)

function sum(a, b) {
console.log('함수 내부 : 전역 객체 window 를 가리킨다',this); // window
return a + b;
}
sum(1, 2);

메서드 호출

메서드 내부의 this 는 메서드를 호출한 객체, icecream.getName() 예시에서 icecream 을 가리킨다. 즉, 메서드 내부의 this 는 메서드가 정의되어 있는 객체를 가리키는 것이 아니라 메서드를 호출한 객체에 바인딩 된다. 이게 무슨 말인가 하면, 아래와 같이 getName 메소드를 다른 객체(jelly)에 할당 후 getName 을 호출해 보면 this 는 메소드가 정의되어있었던 icecream 을 가리키는 것이 아니라, 메소드를 호출한 jelly 라는 객체를 가리킨다. 이는 메소드(함수)가 객체에 포함된 것이 아니라 별도로 존재하고, 메소드 이름(getName)이 이 함수를 가리키고 있는 것을 말한다.

const icecream = {
name: 'Jaws',
getName() {
// console.log('메서드 내부 : 메서드를 호출한 객체를 가리킨다',this);
return this.name;
}
}
const jelly = {
name : 'JawsTaste',
};
jelly.getName = icecream.getName;
console.log('jelly 의 this :',jelly.getName())

생성자 함수 호출

생성자 함수 내부의 this 는 생성자 함수에 의해 생성될 인스턴스를 가리킨다.

function SquareArea (width) {
this.width = width
// 이것(내가 만들 인스턴스)의 width
}
SquareArea.prototype.getArea = function() {
return this.width * this.width
// 이것(내가 만들 인스턴스)의 width
}

const square1 = new SquareArea(3);
const square2 = new SquareArea(5);

// 말로 풀어보면 약간 편한데, 즉 this(인스턴스 : 폭 3을 가진 사각형)의 면적은 9
console.log(square1.getArea()) // 9
// this(인스턴스 : 폭 5을 가진 사각형)의 면적은 25
console.log(square2.getArea()) // 25

call, apply, bind 메서드에 의한 간접 호출

일단 call, apply, bind 이 세가지 메서드는 모두 Function.prototype 의 메서드다. 즉, 모든 함수는 이 세가지 메서드를 상속받고 있기 때문에 사용 가능하다.

call & apply

call 과 apply 메서드를 사용하면 함수를 호출할 때 첫 번째로 전달된 인수에 함수의 this 가 바인딩된다. 이것도 글로만 보면 이해하기 어려우니 예제랑 같이 보아야겠다.

// 현재 this 가 무엇을 가리키는지 알려줄 함수
function getThis() {
return this;
}
// 일반 함수로 호출하면 this 는 역시나 window 를 가리킨다
console.log('일반 함수로 호출 :',getThis());
const arg = { hello: 'world'};// 하지만 call 이나 apply 를 사용하면 this 는 함수에 첫 번째 인수로 전달된 객체를 가리킨다
console.log('apply 로 바인딩 :',getThis.apply(arg));
console.log('call 로 바인딩 :',getThis.call(arg));

call 이나 apply 는 함수를 호출할 때 this 를 명시적으로 가리키는 용도로 사용할 뿐이지, call 과 apply 가 있어도 마찬가지로 함수에 인수를 전달할 수 있다. 또한 인수 전달 방식만 다를 뿐, 하는 역할을 같다.

// apply 는 아래와 같이 배열에 넣어서 전달
getThis.apply(arg, [1, 2, 3]));
// call 은 아래와 같이 쉽표로 구분해서 전달
getThis.call(arg, 1, 2, 3));

역시나 사용 예제를 같이 보는것이 이해에 도움이 되니 예시 추가!

let first = [1, 2];
let second = [3, 4, 5];
// 첫 번째 인자인 this(first) 에 두 번째로 전달된 second 를 푸시해라!
first.push.apply(first, second);
console.log('first :',first);
// max 는 최댓값만 계산해 주면 되고, 굳이 this 를 지정할 필요없으니 null 로 설정
let maxNum = Math.max.apply(null, [3, 5, 7, 9])
console.log('maxNum :',maxNum)

bind

call 이나 apply 와는 달리 bind 는 함수는 호출하지 않고, this 만 명시적으로 전달하고자 할 때 사용한다. 또 다른점은 call 이나 apply 의 본질은 함수를 호출하는 것이지만(쉽게 말해 함수를 바로 실행시켜서 결과를 리턴한다), bind 는 함수를 즉시 호출하는 것이 아니라 “함수처럼 호출 가능한 특수 객체를 반환”한다. 이 객체를 호출하면 인수로 전달된 객체가 this 로 고정된 함수가 반환된다. 역시나 말로는 어려우니, 아래 예시를 같이 보자.

const icecream = {
name: 'Jaws'
}
function getName() {
return this.name;
}
// getName 의 this 가 icecream 을 가리키도록 명시적으로 전달.
// icecreamName 은 호출 가능한 특수 객체이다.
let icecreamName = getName.bind(icecream);
// icecreamName 은 호출 가능하다고 했으니 호출을 해 보면
// this 가 icecream 으로 고정된 getName 이라는 함수를 호출하는 것과 같다. 휴.. 어렵..
console.log(icecreamName()) // Jaws

동적 스코프와 정적 스코프

스코프 결정 방식에는 두가지가 있다.

  1. 함수를 어디서 호출했는지에 따라 함수의 상위 스코프를 결정한다.
  2. 함수를 어디서 정의했는지에 따라 함수의 상위 스코프를 결정한다.

1번은 동적 스코프이다. 즉, 함수를 정의하는 시점에서는 함수가 어디서 호출될 지 모르니, 함수가 호출되는 시점에서 상위 스코프를 결정하겠다는 방식이다.

이와 반대로 2번은 정적 스코프이다. 함수가 정의될 때 상위 스코프를 결정하겠다는 방식이다. 자바스크립트는 바로 이 정적 스코프 방식을 따른다.

this와 dynamic scoping의 연관성

사실 오늘의 메인 주제는 이것인데, this 를 정리하겠다는 욕심에 너무 돌아왔다. 지금까지 정리해왔듯, this 는 함수가 호출되는 방식에 따라 결정된다고 하였다. 즉, 예를들어 생성자 함수에서의 this 는 인스턴스를 가리키지만, 생성자 함수를 일반 함수로 호출하면 this 가 window 를 가리키는 것과 같다. 이는 함수를 어디서 호출했는지에 따라 상위 스코프가 결정되는 동적 스코프처럼 this 도 함수의 호출방식에 따라 가리키는 대상이 달라지기 때문에 이 둘은 비슷한 면이 있다고 볼 수 있겠다.

Reference

책 : 모던 자바스크립트 Deep Dive

--

--