화살표 함수와 this
일반 함수의 this가 호출 방식에 따라 정해지고 화살표 함수는 바깥의 this를 그대로 쓴다는 차이를 예제로 추적하고, this 매개변수와 bind, 화살표 함수 속성으로 콜백에 넘길 메서드의 this를 다루는 방법을 비교합니다.
자바스크립트의 this는 함수를 어디에 선언했는지가 아니라 어떻게 호출했는지에 따라 값이 정해집니다. 그래서 객체의 메서드를 콜백으로 넘기거나 변수에 따로 담아 부르면, 같은 함수 안의 this가 다른 값을 가리키게 됩니다. ES2015에 들어온 화살표 함수(arrow function)는 이 규칙을 따르지 않고, 함수를 만든 곳의 바깥 this를 그대로 씁니다.
이 글은 화살표 함수의 문법을 짧게 복습한 뒤, 두 종류의 함수에서 this가 어떻게 정해지는지 예제로 추적합니다. 이어서 타입스크립트의 this 매개변수로 this의 타입을 적는 방법과, 콜백으로 넘길 메서드의 this를 지키는 방법을 비교합니다. 예제는 ts-first 프로젝트에 this-binding.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
화살표 함수의 문법
화살표 함수는 function 키워드 없이 매개변수 목록과 =>로 함수를 만듭니다. 본문이 식 하나라면 중괄호와 return을 생략할 수 있고, 그 식의 값이 반환값이 됩니다.
const add = (a: number, b: number): number => {
return a + b;
};
const square = (num: number): number => num * num;
const sayHello = (): string => "안녕하세요!";
const createPoint = (x: number, y: number) => ({ x, y });
console.log(add(5, 3), square(4), sayHello());
console.log(createPoint(1, 2));8 16 안녕하세요!
{ x: 1, y: 2 }매개변수가 하나여도 타입을 적었다면 괄호가 필요하고, 매개변수가 없으면 빈 괄호를 씁니다. 객체 리터럴을 바로 돌려줄 때는 중괄호가 함수의 본문으로 읽히지 않도록 ({ x, y })처럼 괄호로 감쌉니다.
일반 함수의 this는 호출 방식이 정한다
function 키워드로 만든 함수와 클래스의 메서드에서 this는 호출하는 순간에 정해집니다. obj.method()처럼 객체를 통해 부르면 그 객체가, new로 부르면 새로 만든 인스턴스가, call이나 apply, bind로 부르면 직접 넘긴 값이 this가 됩니다. 아무 객체도 거치지 않고 함수만 부르면, 엄격 모드에서는 this가 undefined입니다.
class TraditionalGreeter {
greeting = "Hello";
sayGreeting(name: string) {
console.log(`${this.greeting}, ${name}!`);
}
}
const tg = new TraditionalGreeter();
tg.sayGreeting("TypeScript");
const detached = tg.sayGreeting;
// detached("TypeScript");Hello, TypeScript!tg.sayGreeting(...)은 tg를 통해 불렀으므로 this가 tg이고 Hello를 읽습니다. 메서드를 detached에 따로 담아 주석을 풀고 부르면 검사는 통과하지만, 클래스의 코드는 항상 엄격 모드여서 this가 undefined가 되고 TypeError: Cannot read properties of undefined (reading 'greeting')로 멈춥니다. 함수는 같아도 부르는 방식이 달라지면 this가 달라지는 것입니다.
메서드 안에서 타이머에 일반 함수를 콜백으로 넘길 때도 같은 문제가 생깁니다. 콜백을 부르는 것은 tg가 아니라 타이머이므로, 그 안의 this는 메서드가 받은 this와 관계가 없습니다.
class TimerGreeter {
greeting = "Hello";
sayGreetingLater() {
setTimeout(function () {
console.log(`${this.greeting}, world!`);
}, 100);
}
}error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.strict에 포함된 noImplicitThis 설정은 이렇게 타입을 알 수 없는 this를 TS2683으로 알려 줍니다. 진단을 무시하고 실행하더라도, 콜백의 this는 타이머를 실행한 환경이 정하는 값이어서 greeting을 읽지 못합니다.
화살표 함수는 바깥의 this를 쓴다
화살표 함수에는 자기만의 this가 없습니다. 함수 안에서 this를 쓰면, 화살표 함수를 만든 곳을 감싸는 바깥 코드의 this를 그대로 가리킵니다. 이것을 렉시컬 this(lexical this)라고 합니다.
class ArrowGreeter {
greeting = "Hello";
sayGreetingLater() {
setTimeout(() => {
console.log(`${this.greeting}, world!`);
}, 100);
}
}
const ag = new ArrowGreeter();
ag.sayGreetingLater();Hello, world!화살표 함수는 sayGreetingLater 안에서 만들어졌으므로, 그 메서드가 호출될 때 받은 this, 곧 ag를 씁니다. 타이머가 콜백을 어떻게 부르든 바깥의 this가 유지되어, 약 0.1초 뒤에 Hello, world!가 출력됩니다. 화살표 함수가 여러 겹으로 겹쳐 있어도 새로운 this를 만들지 않으므로, 가장 가까운 일반 함수나 메서드의 this까지 거슬러 올라갑니다.
TYPE / RUNTIME / CONTRACT
일반 함수는 호출 방식, 화살표 함수는 바깥 this
본문의 일반 타이머 콜백은 Greeter 인스턴스를 캡처하지 않습니다. 메서드 안의 실제 화살표 함수는 메서드가 받은 this를 유지합니다.
REGULAR FUNCTION
obj.method()의 receiver는 obj
메서드를 값으로 꺼내 전달하면 원래 객체가 자동 유지되지 않습니다. 타이머 receiver는 호스트마다 달라 TraditionalGreeter의 지연 호출 결과를 하나로 단정할 수 없습니다.
ACTUAL ARROW
메서드 안에서 바깥 this를 캡처
ArrowGreeter의 setTimeout(() => …)는 인스턴스 greeting을 사용해 Hello, world!을 출력합니다. call·bind로 화살표 함수의 this를 바꿀 수 없습니다.
BIND / IDENTITY
함수 참조를 한 번 만들어 보관
handler.handleClick.bind(handler)는 handler를 receiver로 고정합니다. 이벤트 제거 등에 같은 참조가 필요하면 만들어 둔 bound 함수를 보관합니다. 이 참조 관리 설명은 설계 확장입니다.
STATIC THIS
타입 표기가 자동 바인딩은 아닙니다
this: ButtonHandler는 JavaScript 매개변수에서 제거됩니다. 함수 타입 (message: string) => void도 실제 구현을 화살표 함수로 바꾸지 않습니다. MyLogger는 생성자에서 실제 화살표 함수를 할당합니다.
화살표 함수도 자신을 감싼 메서드가 잘못된 receiver로 호출되면 그 this를 캡처합니다. 필요한 receiver와 실제 호출 방식을 함께 확인합니다.
위 그림처럼 this를 판단할 때는 먼저 그 함수가 실제로 화살표 함수인지를 봅니다. 화살표 함수라면 바깥의 렉시컬 this를, 일반 함수라면 호출할 때의 대상을 씁니다. bind는 일반 함수의 대상을 하나로 고정하는 별도의 방법이고, 뒤에서 볼 this의 타입 표기는 검사만 할 뿐 이 결정을 바꾸지 않습니다. 그림에서 타이머 콜백의 예로 든 TraditionalGreeter의 지연 호출은 이 글의 TimerGreeter에 해당합니다.
더 깊이: 화살표 함수가 갖지 않는 것들
화살표 함수에 없는 것은 this만이 아닙니다. MDN의 화살표 함수 설명에 따르면 화살표 함수는 자기만의 arguments와 super도 갖지 않아서, 이 이름을 쓰면 this처럼 바깥 함수의 것을 가리킵니다. 같은 이유로 call, apply, bind로 넘긴 첫 인수는 화살표 함수의 this를 바꾸지 못하며, 이 메서드들은 나머지 인수를 넘기는 데에만 쓸모가 있습니다.
화살표 함수는 생성자로도 쓸 수 없습니다. new로 부르면 TypeError가 나고, 생성자에 필요한 prototype 속성도 없으며, new.target에도 접근할 수 없습니다. 본문에서 yield를 쓸 수 없어 제너레이터 함수로 만들 수도 없습니다. 그래서 모든 함수를 화살표 함수로 바꾸는 것은 답이 아니고, 객체의 메서드처럼 호출한 대상을 this로 읽어야 하는 자리에는 일반 함수나 메서드 문법을 씁니다.
this 매개변수로 타입 적기
타입스크립트에서는 함수의 첫 매개변수 자리에 this라는 이름으로 this의 타입을 적을 수 있습니다. 이 this 매개변수는 실제 인수가 아니어서 호출할 때 넘기지 않고, 컴파일한 자바스크립트에서도 지워집니다.
class ButtonHandler {
buttonLabel = "저장";
handleClick(this: ButtonHandler, event: Event) {
console.log(`버튼 '${this.buttonLabel}'이 클릭되었습니다. (${event.type})`);
}
}
const handler = new ButtonHandler();
handler.handleClick(new Event("click"));
const boundHandleClick = handler.handleClick.bind(handler);
boundHandleClick(new Event("click"));버튼 '저장'이 클릭되었습니다. (click)
버튼 '저장'이 클릭되었습니다. (click)this: ButtonHandler를 적었으므로, 컴파일러는 이 메서드를 부를 때 this가 될 값이 ButtonHandler인지 검사합니다. const detachedClick = handler.handleClick; detachedClick(new Event("click"));처럼 따로 담아 부르면 this가 될 값이 없으므로 The 'this' context of type 'void' is not assignable to method's 'this' of type 'ButtonHandler'.라는 TS2684가 나옵니다. bind(handler)로 대상을 고정한 함수는 this가 정해져 있으므로 그냥 불러도 됩니다.
다만 this 매개변수는 검사를 위한 표시일 뿐, 함수를 자동으로 bind해 주지 않습니다. 브라우저의 addEventListener는 핸들러를 부를 때 리스너를 등록한 요소를 this로 넘기는데, button이 HTMLButtonElement 타입의 변수일 때 button.addEventListener("click", handler.handleClick)처럼 이 메서드를 그대로 등록해도 this의 타입에 관한 진단은 나오지 않습니다. 요소의 타입을 this로 적은 첫 번째 오버로드는 맞지 않지만, 리스너를 this를 적지 않은 EventListener로 받는 두 번째 오버로드가 있어서 호출이 그쪽으로 통과하기 때문입니다. 그러면 실행할 때 this가 버튼 요소가 되어 this.buttonLabel이 undefined로 읽히므로, 그런 곳에는 bind한 함수나 다음에 볼 화살표 함수를 넘깁니다. Event는 브라우저의 타입이지만, Node.js 24에도 전역 Event가 있어 이 예제를 그대로 실행할 수 있습니다.
객체 리터럴의 메서드는 그 객체에 붙은 타입에서 this의 타입을 얻습니다. 다음 calc의 add에는 this를 적지 않았지만, Calculator 인터페이스가 정한 대로 this가 Calculator로 정해집니다.
interface Calculator {
value: number;
add(this: Calculator, num: number): void;
}
const calc: Calculator = {
value: 0,
add(num) {
this.value += num;
},
};
calc.add(10);
console.log(calc.value);10calc.add(10)은 점 앞의 calc를 this로 호출하므로, 실행할 때도 this.value가 0에서 10으로 바뀝니다. add를 const f = calc.add;로 떼어 내 f(10)처럼 부르면, 앞의 ButtonHandler와 마찬가지로 TS2684로 진단됩니다.
콜백으로 넘길 메서드
메서드를 콜백으로 자주 넘긴다면, 클래스의 속성에 화살표 함수를 넣는 방법이 있습니다. 화살표 함수는 인스턴스를 만들 때 생성자 안에서 만들어지므로, 그 인스턴스를 this로 계속 씁니다.
interface Logger {
logLevel: string;
logMessage: (message: string) => void;
}
class MyLogger implements Logger {
logLevel = "INFO";
logMessage: (message: string) => void;
constructor() {
this.logMessage = (message: string) => {
console.log(`[${this.logLevel}] ${message}`);
};
}
}
const logger = new MyLogger();
logger.logMessage("데이터 로드 완료.");[INFO] 데이터 로드 완료.implements Logger는 이 클래스가 Logger 인터페이스의 모양을 갖추었는지 컴파일러가 검사하게 하는 표시이며, 자세한 규칙은 4장의 상속과 구현에서 다룹니다. 인터페이스의 (message: string) => void는 함수의 타입을 적은 것일 뿐, 구현을 화살표 함수로 만들어 주지는 않습니다. this를 지키는 것은 생성자에서 실제로 넣은 화살표 함수이며, 같은 일을 function 표현식에 .bind(this)를 붙여서 할 수도 있습니다.
이 방법에도 대가가 있습니다. 화살표 함수 속성은 프로토타입에 한 번 정의되는 메서드와 달리 인스턴스마다 새 함수가 만들어져 메모리를 더 쓰고, 이 클래스를 상속한 자식 클래스에서 super.logMessage()처럼 부모의 구현을 부를 수도 없습니다. 이 차이는 공식 Handbook의 클래스와 this에 정리되어 있습니다.
호출 방식을 한눈에 비교하기
타이머 없이 세 가지 방식을 한 클래스에서 비교해 봅니다. read는 this 매개변수를 적은 일반 메서드이고, readArrow는 화살표 함수 속성이며, boundRead는 read를 counter에 bind한 함수입니다.
class Counter {
value = 10;
read(this: { value: number }): number {
return this.value;
}
readArrow = (): number => this.value;
}
const counter = new Counter();
const read = counter.read;
const readArrow = counter.readArrow;
const boundRead = counter.read.bind(counter);
console.log(counter.read());
console.log(read.call({ value: 20 }));
console.log(readArrow.call({ value: 20 }));
console.log(boundRead.call({ value: 20 }));10
20
10
10일반 메서드 read는 부를 때의 대상을 읽으므로, counter.read()는 10을, call로 다른 객체를 넘긴 read.call({ value: 20 })은 20을 돌려줍니다. 화살표 함수 readArrow와 bind한 boundRead는 call로 다른 객체를 넘겨도 무시하고 counter를 읽어 10을 돌려줍니다. 여기에 read()를 그냥 부르는 줄을 더하면 TS2684가 나오며, 화살표 함수가 this를 정하는 규칙은 MDN의 화살표 함수 설명에 있습니다.
정리
- 일반 함수와 메서드의
this는 호출하는 방식이 정하며, 객체를 거치지 않고 부르면 엄격 모드에서undefined가 됩니다. - 화살표 함수는 자기만의
this가 없어, 만들어진 곳을 감싸는 바깥의this를 그대로 씁니다. strict의noImplicitThis는 타입을 알 수 없는this를 TS2683으로 알려 줍니다.this매개변수는this의 타입을 적어 잘못된 호출을 TS2684로 잡지만, 함수를 자동으로bind하지는 않습니다.- 콜백으로 넘길 메서드는
bind하거나 화살표 함수 속성으로 만들며, 화살표 함수 속성은 인스턴스마다 함수가 생기고super로 부를 수 없다는 대가가 있습니다.
연습 문제
1. 값을 바꾼 뒤의 세 호출
위의 counter.value를 30으로 바꾼 뒤 read.call({ value: 7 }), readArrow(), boundRead()는 각각 무엇을 돌려주나요?
해설 보기
차례로 7, 30, 30입니다. 일반 함수 read는 call로 넘긴 객체의 value를 읽습니다. readArrow와 boundRead는 같은 counter 인스턴스를 가리키므로, 그 객체의 지금 값인 30을 읽으며, 화살표 함수나 bind가 처음의 숫자 10을 복사해 둔 것이 아니라는 점을 보여 줍니다.
2. 따로 담은 메서드
const log = logger.logMessage; log("분리 호출");과 const click = handler.handleClick; click(new Event("click"));은 각각 검사와 실행에서 어떻게 되나요?
해설 보기
log는 생성자에서 만든 화살표 함수이므로 검사를 통과하고, 따로 담아 불러도 인스턴스를 this로 써서 [INFO] 분리 호출을 출력합니다. click은 this: ButtonHandler를 요구하는 일반 메서드를 객체 없이 부르는 것이어서 TS2684로 진단됩니다. 같은 "메서드를 꺼내 부르는" 코드라도, 구현이 화살표 함수인지 일반 함수인지에 따라 결과가 갈립니다.
3. 화살표 함수의 this 매개변수
const show = (this: Counter) => this.value;처럼 화살표 함수에 this 매개변수를 적으면 어떻게 되나요? 그 이유는 무엇인가요?
해설 보기
An arrow function cannot have a 'this' parameter.라는 TS2730이 나옵니다. 화살표 함수는 자기만의 this가 없고 바깥의 this를 그대로 쓰므로, 호출할 때 this가 될 값을 받을 자리가 처음부터 없기 때문입니다. this의 타입을 적어야 한다면 일반 함수나 메서드로 만들어야 합니다.
이것으로 3장의 함수와 타입을 마칩니다. 다음 장의 클래스 기본 문법에서는 이 글에서 쓴 클래스의 속성과 생성자, 메서드를 타입과 함께 처음부터 정리합니다.