Devin.KR
로그인

자바스크립트 this 바인딩 - call apply bind 와 화살표 함수 (JS 중급 10단원)

개발자 조회 1

이 단원에서 배우는 것

9단원에서 변수를 찾는 규칙은 선언된 위치로 정해진다고 했다. 렉시컬 스코프다. 그런데 this 하나만은 이 규칙을 따르지 않는다. this 는 함수가 어디에 적혀 있는지가 아니라 어떻게 호출됐는지로 정해진다. 같은 함수가 호출 방식에 따라 다른 this 를 갖는다는 이 예외 때문에 "분명 되던 코드가 콜백으로 넘기니까 안 된다"는 상황이 생긴다. 이 단원에서 그 규칙을 정리한다. 9단원에서 만든 createLoanDesk 를 객체 메서드 형태로 다시 쓰면서 시작한다.

  • 호출 방식 네 가지(일반 호출·메서드 호출·new·명시적 바인딩)별로 this 가 무엇이 되는지 판별한다.
  • call·apply·bind 의 차이를 알고 필요한 자리에서 골라 쓴다.
  • 화살표 함수가 this 를 갖지 않는다는 뜻을 이해하고, 화살표를 써야 할 자리와 쓰면 안 되는 자리를 구분한다.

왜 필요한가

대출 창구를 객체로 만들었다고 하자.

const loanDesk = {
  name: '3층 자료실',
  loans: ['B001', 'B002', 'B003'],
  report() {
    return `${this.name} 대출 ${this.loans.length}건`;
  }
};

console.log(loanDesk.report());   // 3층 자료실 대출 3건

잘 된다. 그런데 이 report 를 다른 곳에 넘기는 순간 깨진다.

const printer = loanDesk.report;
printer();
// TypeError: Cannot read properties of undefined (reading 'name')

setTimeout(loanDesk.report, 1000);   // 같은 에러
['a'].forEach(loanDesk.report);      // 같은 에러

함수는 바뀌지 않았는데 결과가 달라졌다. 이 현상을 "this 가 유실됐다"고 부른다. 콜백을 쓰는 코드는 전부 이 함정 위에 있다. 이벤트 핸들러, 타이머, 배열 순회, 프로미스 then — 전부 함수를 다른 곳에 넘기는 일이다. 그래서 this 규칙은 문법 지식이 아니라 디버깅에 매일 필요한 지식이다.

문법과 예제

판별 규칙: 호출부의 점(.) 왼쪽을 본다

this 는 함수를 호출하는 그 줄을 보면 결정된다. 위에서부터 순서대로 맞는 것을 고른다.

호출 방식this
new 호출new LoanDesk()새로 만들어진 객체
명시적 바인딩fn.call(desk), fn.bind(desk)()넘긴 값
메서드 호출loanDesk.report()점 왼쪽 객체 (loanDesk)
그냥 호출printer()strict mode·모듈·클래스 안에서는 undefined, 그 외에는 전역 객체
화살표 함수() => this.name규칙 적용 없음. 바깥 this 를 그대로 쓴다

"그냥 호출"의 결과가 환경에 따라 다른 점을 짚어야 한다. ESM 모듈(.mjs, "type": "module"), class 본문, 'use strict' 안에서는 undefined 다. 요즘 코드는 대부분 여기 해당하므로 this 유실은 조용한 오작동이 아니라 즉시 TypeError 로 나타난다. 반대로 오래된 스크립트 파일에서는 thiswindow 가 되어 this.name 이 빈 문자열로 읽히고, 버그가 훨씬 뒤에 발견된다.

call · apply · bind

셋 다 this 를 직접 지정한다. 차이는 인자 전달 방식과 실행 시점이다.

function summary(open, close) {
  return `${open}${this.name} / ${this.loans.length}건${close}`;
}

const loanDesk = { name: '3층 자료실', loans: ['B001', 'B002', 'B003'] };
const annex   = { name: '분관 자료실', loans: ['B009'] };

// call: 인자를 낱개로 넘기고 즉시 실행
console.log(summary.call(loanDesk, '[', ']'));    // [3층 자료실 / 3건]

// apply: 인자를 배열로 넘기고 즉시 실행
console.log(summary.apply(annex, ['<', '>']));    // <분관 자료실 / 1건>

// bind: 실행하지 않고, this 가 고정된 "새 함수"를 만든다
const deskSummary = summary.bind(loanDesk, '(');
console.log(deskSummary(')'));                    // (3층 자료실 / 3건)

apply 는 스프레드 문법(fn.call(obj, ...args))이 생긴 뒤로 쓸 일이 거의 없다. 실무에서 남는 것은 bind 하나다. 콜백으로 넘기기 직전에 this 를 붙여 두는 용도다.

setTimeout(loanDesk.report.bind(loanDesk), 1000);   // 정상 동작

화살표 함수는 this 를 만들지 않는다

흔한 설명은 "화살표 함수의 this 는 바깥을 가리킨다"인데, 정확히는 화살표 함수에는 자기 this 가 아예 없다. 그래서 this 를 쓰면 9단원의 스코프 체인을 타고 바깥 함수의 this 를 찾아 올라간다. 일반 변수를 찾는 방식과 똑같다. 그러니 call·bind 로 바꿀 수도 없다.

const loanDesk = {
  name: '3층 자료실',
  loans: ['B001', 'B002', 'B003'],

  printAll() {
    // 일반 함수: forEach 가 콜백을 "그냥 호출"하므로 this 는 undefined
    this.loans.forEach(function (id) {
      // console.log(`${this.name}: ${id}`);   // TypeError
    });

    // 화살표: 자기 this 가 없으니 바깥 printAll 의 this(= loanDesk)를 쓴다
    this.loans.forEach((id) => {
      console.log(`${this.name}: ${id}`);
    });
  }
};

loanDesk.printAll();
// 3층 자료실: B001
// 3층 자료실: B002
// 3층 자료실: B003

ES5 시절에는 const self = this 를 위에 적어 두고 콜백 안에서 self 를 쓰는 관용구를 썼다. 오래된 코드에서 var self = thisvar that = this 를 보면 화살표 함수가 없던 시절의 흔적이다. forEach·map·filter 는 두 번째 인자로 thisArg 를 받기도 하지만(forEach(fn, this)), 화살표를 쓰면 필요 없다.

클래스에서의 this

11단원에서 클래스를 본격적으로 다루지만, this 문제가 가장 자주 터지는 곳이 클래스라 여기서 해법까지 정리한다.

class LoanDesk {
  constructor(name) {
    this.name = name;
    this.loans = [];
  }

  // 프로토타입 메서드: 호출 방식에 따라 this 가 유실될 수 있다
  borrow(bookId) {
    this.loans.push(bookId);
    return this.loans.length;
  }

  // 클래스 필드 + 화살표: 인스턴스마다 this 가 고정된 함수를 하나씩 만든다
  report = () => `${this.name} 대출 ${this.loans.length}건`;
}

const desk = new LoanDesk('3층 자료실');
desk.borrow('B001');

const detachedBorrow = desk.borrow;
// detachedBorrow('B002');
// TypeError: Cannot read properties of undefined (reading 'loans')

const detachedReport = desk.report;
console.log(detachedReport());   // 3층 자료실 대출 1건 ← 떼어내도 동작한다

클래스 필드에 화살표 함수를 넣으면 this 유실이 사라진다. 대가는 있다. 프로토타입 메서드는 클래스당 하나만 존재하지만, 클래스 필드는 인스턴스마다 함수 객체가 하나씩 생긴다. 인스턴스를 수천 개 만드는 클래스라면 차이가 난다. 판단 기준은 이렇다. 콜백으로 자주 넘기는 메서드(이벤트 핸들러 등)는 클래스 필드 화살표로, 나머지는 일반 프로토타입 메서드로 둔다.

실무에서 자주 틀리는 것

1. 메서드 참조를 그대로 콜백으로 넘긴다

가장 흔한 사고다. 아래 두 줄은 겉보기에 비슷하지만 완전히 다르다.

bookIds.forEach(loanDesk.borrow);          // 깨진다. borrow 가 "그냥 호출"된다
bookIds.forEach((id) => loanDesk.borrow(id));  // 정상. 호출부에 점이 있다

판별법은 단순하다. 실제로 함수가 실행되는 줄에 객체. 가 붙어 있는가를 본다. loanDesk.borrow 라고 적었어도 그건 함수를 꺼내는 줄이고, 실행은 forEach 안에서 fn(item) 형태로 일어난다.

덧붙여, 인자를 그대로 넘길 수 있어 보인다고 메서드를 바로 넘기면 인자 개수 때문에도 사고가 난다. forEach 는 콜백에 (값, 인덱스, 배열) 세 개를 넘긴다. ['1','2','3'].map(parseInt)[1, NaN, NaN] 이 되는 유명한 예가 이 경우다.

2. 객체 리터럴의 메서드를 화살표 함수로 쓴다

const loanDesk = {
  name: '3층 자료실',
  report: () => `${this.name} 창구`   // ← 잘못
};

console.log(loanDesk.report());
// ESM/strict: TypeError 또는 undefined
// 브라우저 스크립트: "undefined 창구" (this 가 window)

객체 리터럴은 스코프를 만들지 않는다. 그래서 화살표 함수가 찾아 올라간 this 는 그 객체가 아니라 모듈 최상위(undefined)나 window 다. this 를 쓰는 메서드는 반드시 report() { ... } 축약형이나 function 으로 쓴다. 화살표는 그 메서드 안쪽의 콜백에서 쓴다.

3. bind 를 두 번 하면 나중 것이 이긴다고 착각한다

const bound = summary.bind(loanDesk);
const rebound = bound.bind(annex);

console.log(rebound('[', ']'));   // [3층 자료실 / 3건]  ← annex 가 아니다

bind 로 만든 함수의 this 는 다시 바꿀 수 없다. call·apply 로도 안 되고, new 만 예외다. 그래서 "라이브러리에 넘긴 콜백이 this 를 바꿔 줄 것"이라고 기대하면 안 된다. 이미 bind 된 함수라면 라이브러리의 thisArg 는 무시된다. 반대로 화살표 함수도 마찬가지다. jQuery·D3 처럼 this 로 대상 요소를 넘겨 주는 API 에 화살표 함수를 넘기면 this 를 못 받는다.

4. 클래스 메서드를 이벤트 핸들러로 등록하고 해제하지 못한다

class BookRow {
  constructor(el) { this.el = el; }
  onClick() { console.log(this.el); }

  attach() {
    this.el.addEventListener('click', this.onClick.bind(this));
  }
  detach() {
    this.el.removeEventListener('click', this.onClick.bind(this));  // ← 해제되지 않는다
  }
}

bind 는 호출할 때마다 새 함수 객체를 만든다. 등록한 함수와 해제하려는 함수가 다른 객체이므로 removeEventListener 가 아무 일도 하지 않는다. 9단원의 메모리 누수와 정확히 같은 결과다. 고치려면 바인딩한 함수를 한 번만 만들어 보관한다.

class BookRow {
  constructor(el) { this.el = el; }

  onClick = () => { console.log(this.el); };   // 인스턴스마다 하나, this 고정

  attach() { this.el.addEventListener('click', this.onClick); }
  detach() { this.el.removeEventListener('click', this.onClick); }
}

스스로 확인하기

  1. 다음 코드가 각각 무엇을 출력하는지, 그리고 이유를 쓰라. 파일은 ESM 모듈이다.
    const desk = {
      name: '3층',
      a() { return this?.name; },
      b: () => this?.name
    };
    const a = desk.a;
    console.log(desk.a(), desk.b(), a());
  2. 아래 Timer 는 1초 뒤 TypeError 를 낸다. bind 를 쓰는 방법과 화살표 함수를 쓰는 방법 두 가지로 고쳐라.
    class Timer {
      constructor(label) { this.label = label; this.tick = 0; }
      start() {
        setInterval(function () {
          this.tick += 1;
          console.log(`${this.label} ${this.tick}`);
        }, 1000);
      }
    }
  3. ['1', '2', '3'].map(parseInt) 의 결과는 [1, NaN, NaN] 이다. this 와는 무관한 이유인데, 무엇인가. 그리고 어떻게 고치는가.

정답

  1. 3층 undefined undefined.
    • desk.a() — 메서드 호출이라 thisdesk. '3층'.
    • desk.b() — 화살표라 자기 this 가 없고, 객체 리터럴은 스코프를 만들지 않으므로 모듈 최상위의 thisundefined 를 본다. this?.name 이므로 에러 없이 undefined.
    • a() — 함수를 꺼내 그냥 호출했다. ESM 은 strict 이므로 thisundefined.
  2. // (1) bind
    start() {
      setInterval(function () {
        this.tick += 1;
        console.log(`${this.label} ${this.tick}`);
      }.bind(this), 1000);
    }
    
    // (2) 화살표 — 실무에서는 이쪽을 쓴다
    start() {
      setInterval(() => {
        this.tick += 1;
        console.log(`${this.label} ${this.tick}`);
      }, 1000);
    }
    덧붙여 9단원에서 다룬 대로 setInterval 의 id 를 보관하고 정지 함수를 제공하는 편이 낫다.
  3. map 이 콜백에 (값, 인덱스, 배열) 세 인자를 넘기고, parseInt 는 두 번째 인자를 진법으로 받는다. 그래서 parseInt('1', 0)(0 은 무시되어 10진 → 1), parseInt('2', 1)(1진법은 없음 → NaN), parseInt('3', 2)(2진법에 3은 없음 → NaN)이 된다. 고치려면 인자를 명시적으로 하나만 넘긴다.
    ['1', '2', '3'].map((s) => parseInt(s, 10));   // [1, 2, 3]
    ['1', '2', '3'].map(Number);                   // [1, 2, 3]
    교훈은 단순하다. 남이 만든 함수를 콜백 자리에 그대로 넘길 때는 그 함수가 인자를 몇 개 받는지 확인한다.