자바스크립트와 신비한 this

Lee Jooam·2022년 5월 27일
0

자바스크립트를 배우다보면 유독 괴롭게 느껴지는 녀석들이 있다. this는 그 중 하나이다.

그간 Lexical Scope에 익숙했었기 때문에 특히나 그렇게 느껴졌던 것 같다. 이번 기회에 this와 조금이라도 가까워지려고 한다.

1. 자바스크립트 함수에서 this는 기본적으로 전역 객체다.

'기본적으로' 전역 객체라고 생각하는 게 나에게는 더 편했다. 여러가지 케이스를 전부 구별한다는 접근보다는 일반적으로는 전역 객체이지만 예외가 조금 있다!

이렇게 생각하는 게 가슴에 더 와닿았다. 직접 코드로 작성해서 확인해보자.

function checkThisOut() {
  console.log(this);
}

checkThisOut();
console.log(this);

다음과 같은 자바스크립트 코드를 브라우저에서 실행시키면 위와 같은 출력을 볼 수 있다. window는 브라우저의 전역객체 역할을 한다.

Node 환경에서는 어떻게 출력되는지 보도록 하자.

난잡하지만 Object[global]은 node에서의 전역 객체이다. 그리고 마지막 줄에 보이는 빈 객체는 전역에서 console.log(this)를 출력했을 때의 결과이다.

함수 안에서 this를 출력한 것과 전역에서 this를 출력한 게 다르다.

첫 번째 출력은 Node의 전역 객체인 global이 맞지만 두 번째 this는 module.exports 객체이다.

벌써부터 하나의 예외가 발생했다.

2. 객체에서의 this

const truck = { wheel: 4, weight: 5000 };

자바스크립트에서는 객체 리터럴을 이용해 손쉽게 객체를 생성할 수 있다. 이 방법이 틀린 것은 아니지만 재사용성을 고려하면 좋지 않다.

vehicle이 있고 truck이 vehicle의 인스턴스 중 하나라면 우리는 생성자 함수를 이용하여 재사용성이 높은 코드를 작성할 수 있다.

function Vehicle(wheel, weight) {
  this.wheel = wheel;
  this.weight = weight;
}

const truck = new Vehicle(4, 5000);
const motorcycle = new Vehicle(2, 1500);
const car = new Vehicle(4, 3000);

다음과 같이 생성자 함수와 new 키워드를 이용하면 된다. 생성자 함수를 보면 this가 있다.

this는 일반적으로 전역이고 함수 내부에서도 전역이 참조되기 때문에 this는 전역이 되어야 하는 게 맞다.

하지만 new 키워드가 붙으면 조금 달라진다.

function Vehicle(wheel, weight) {
  this = {};
  
  this.wheel = wheel;
  this.weight = weight;
  
  return this;
}

new 키워드와 함께 호출하면 이러한 방식으로 함수가 호출된다. 마치 this가 새로운 객체처럼 작동하는 것이다.

new 키워드를 생략한다면 this는 전역을 참조하고 전역에 wheel과 weight 속성이 생기게 된다. 그리고 생성자의 리턴은 undefined이기 때문에 할당 받은 변수는 undefined를 참조한다.

function Vehicle(wheel, weight) {
  if (!new.target) {
    return new Vehicle(wheel, weight);
  }
  this.wheel = wheel;
  this.weight = weight;
}

그럴 경우 이렇게 방어적인 코드를 작성하여 문제를 방지할 수 있다. new.target 속성은 new 키워드와 함께 불렸을 경우 함수 자체, 그렇지 않다면 undefined를 가리킨다.

다음은 객체 메소드에서의 this에 대해 알아보자.

function Vehicle(wheel, weight) {
  if (!new.target) {
    return new Vehicle(wheel, weight);
  }
  this.wheel = wheel;
  this.weight = weight;
  this.blowHorn = function () {
    console.log("BBANG BBANG!! I have", this.wheel, "wheels.");
  };
}

const car = new Vehicle(4, 3000);

const horn = car.blowHorn;
car.blowHorn();
horn();

다음과 같은 코드가 있을 때 나오는 결과는 아래와 같다.

car 객체에서 직접 호출한 blowHorn의 this는 호출한 객체인 car를 참조하지만 horn 변수에 담긴 car.blowHorn은 undefined를 참조한다.

어떻게 된 상황일까?

이를 알기 위해 우선 바인딩에 대해 알아야 한다. this를 특정 객체에 묶는 행위를 바인딩이라고 할 수 있다.

문제는 자바스크립트에서 함수가 호출되는 방식에 따라 this가 동적으로 바인딩된다는 것이다.

의도가 있어서 그렇게 설계했겠지만 그 이유를 나는 아직 모르겠다.

위의 예시에서 horn은 전역에서 호출되었기 때문에 horn의 this는 global을 참조한다.

global.wheel = 3; // 노드
// window.wheel = 3; // 브라우저

horn의 호출전 다음과 같은 문장을 추가하면 더이상 undefined가 아닌 걸 확인할 수 있다.

  this.blowHorn = function () {
    const _this = this;
    const funcInMethod = function () {
      console.log(this);
    };
    console.log("BBANG BBANG!! I have", this.wheel, "wheels.");
    funcInMethod();
  };

만약 객체의 메소드 안에서 또 다른 함수를 호출하면 어떨까? 이는 원칙을 생각하면 예상할 수 있다.

'호출' 자체는 일반적으로 호출되었기 때문에 전역을 참조한다. 우리가 관심을 가져야 하는 건 어떻게 '호출'이 되었는가에 있다.

물론 이런 원칙이 항상 통하는 것은 아니다... 😱

화살표 함수와 this

화살표 함수의 this는 lexical this이다. 즉 일반 함수처럼 함수를 호출할 때가 아니라 선언할 때 this가 결정된다는 것이다.

function Vehicle(wheel, weight) {
  if (!new.target) {
    return new Vehicle(wheel, weight);
  }
  this.wheel = wheel;
  this.weight = weight;
  this.blowHorn = function () {
    const funcInMethod = () => {
      console.log(this);
    };
    funcInMethod();
  };
  this.arrowFunc = () => {
    console.log(this);
  };
}

const car = new Vehicle(4, 3000);
const arrFunc = car.arrowFunc;

car.blowHorn();
arrFunc();

예시를 조금 수정했다. 일반 함수를 화살표 함수로 바꾸고 해당 함수를 호출한 결과는 아래와 같다.

놀랍게도 모두 car 객체를 가리킨다. 이는 화살표 함수가 선언될 때 상위 스코프의 this를 참조하기 때문이다.

이제야 예상대로 코드가 돌아가는 느낌이다. 그렇다면 모든 함수를 화살표 함수로 만들어버려 this를 정적으로 바인딩 시켜버리면 어떨까?

const obj = {
  name: "lee",
  sayHi: function () {
    console.log(this.name);
  },
  sayHiArrow: () => {
    console.log(this.name);
  },
};

obj.sayHi(); // lee
obj.sayHiArrow(); // undefined

이 코드가 사례 중 하나다. sayHi는 일반 함수 호출이기 때문에 this에 obj가 바인딩 되지만, sayHiArrow는 상위 컨텍스트의 this를 참조한다.

obj는 함수가 아니라 일반 객체다. 그렇기 때문에 this는 전역 객체를 참조한다.

경우에 따라 알맞게 구별해서 사용하는 것이 올바르다. 참 오묘하다.

이벤트 핸들러의 this

document.querySelector(".first-btn").addEventListener("click", function () {
  console.log(this);
});

document.querySelector(".second-btn").addEventListener("click", () => {
  console.log(this);
});

이벤트 핸들러에서 화살표 함수를 사용하는 것도 주의해야한다.

일반 함수를 이벤트 핸들러로 지정할 경우 이벤트가 발생한 DOM 요소를 가리키지만 화살표 함수는 전역 객체를 가리킨다.

bind, call, apply

bind, call, apply는 this를 특정 요소에 바인딩해주는 메소드들이다.

call과 apply는 유사하다.

const obj = {
  name: "lee",
  age: 27,
};

const sayHi = function () {
  console.log(this.name);
};

sayHi.call(obj); // lee
sayHi.apply(obj); // lee

call과 apply 메소드는 this를 특정 객체로 바인딩할 수 있다. 위의 예시에서 sayHi의 호출은 모두 this에 obj가 바인딩된다.

const sayHi = function (...args) {
  console.log(this.name);
  console.log(args);
};

sayHi.call(obj, 1, 2, 3, 4, 5);
sayHi.apply(obj, [1, 2, 3, 4, 5]);

차이점이라면 인자를 전달하는 형식이다. call은 위처럼 나열하는 형식으로 인자를 전달하고 apply는 배열의 형태로 인자를 전달한다.

call과 apply는 모두 함수를 호출해준다.

const sayHi = function (...args) {
  console.log(this.name);
  console.log(args);
};

const bindedSayHi = sayHi.bind(obj);

bindedSayHi(1, 2, 3, 4, 5);

bind는 this를 바인딩하지만 호출하지는 않는다. 호출을 위해서는 위처럼 한번 호출해주는 과정을 거쳐야한다.

후기

개발을 연습하다보면 this 때문에 곤욕을 겪는 경우가 가끔 생기곤 한다.

처음 마주하면 되는데 왜 안 되지? 라는 생각 뿐이지만, 익숙해지면 그러려니 받아들인다.

꾸준한 연습을 통해 안 되는 이유 또한 체득시키며 배우는 것이 중요한 것 같다.

profile
프론트엔드 개발자로 걸어가는 중입니다.

0개의 댓글