자바스크립트 this 바인딩 - call apply bind 와 화살표 함수 (JS 중급 10단원)
이 단원에서 배우는 것
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 로 나타난다. 반대로 오래된 스크립트 파일에서는 this 가 window 가 되어 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 = this 나 var 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); }
}
스스로 확인하기
- 다음 코드가 각각 무엇을 출력하는지, 그리고 이유를 쓰라. 파일은 ESM 모듈이다.
const desk = { name: '3층', a() { return this?.name; }, b: () => this?.name }; const a = desk.a; console.log(desk.a(), desk.b(), a()); - 아래
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); } } ['1', '2', '3'].map(parseInt)의 결과는[1, NaN, NaN]이다.this와는 무관한 이유인데, 무엇인가. 그리고 어떻게 고치는가.
정답
3층 undefined undefined.desk.a()— 메서드 호출이라this는desk.'3층'.desk.b()— 화살표라 자기this가 없고, 객체 리터럴은 스코프를 만들지 않으므로 모듈 최상위의this인undefined를 본다.this?.name이므로 에러 없이undefined.a()— 함수를 꺼내 그냥 호출했다. ESM 은 strict 이므로this는undefined.
덧붙여 9단원에서 다룬 대로// (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); }setInterval의 id 를 보관하고 정지 함수를 제공하는 편이 낫다.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]