이벤트 핸들러에서의 this 바인딩은 JavaScript로 DOM을 다루는 개발자라면 반드시 이해해야 하는 핵심 주제입니다. 브라우저는 이벤트가 발생하면 등록된 콜백 함수를 호출하면서 this를 이벤트가 발생한 DOM 요소로 자동 설정합니다. 이 동작은 편리해 보이지만, 클래스 기반 컴포넌트나 객체 메서드를 이벤트 핸들러로 등록할 때 예상치 못한 this 손실 문제를 일으킵니다. 특히 React 클래스 컴포넌트에서 이벤트 핸들러를 바인딩하지 않아 발생하는 버그는 프론트엔드 개발에서 가장 흔한 실수 중 하나였습니다. 이 토픽에서는 브라우저가 이벤트 핸들러의 this를 결정하는 원리를 파악하고, 이를 올바르게 제어하는 방법을 학습합니다.
🔍 핵심 특징
- 브라우저의 자동 this 바인딩: addEventListener로 등록된 콜백에서 this는 이벤트가 발생한 요소(currentTarget)를 가리키며, 이는 브라우저 엔진이 내부적으로 콜백을 호출할 때 결정합니다
- 메서드 참조 전달 시 this 손실: 객체의 메서드를 이벤트 핸들러로 전달하면 메서드가 객체에서 분리되어 원래 객체에 대한 this 참조가 사라집니다
- 인라인 핸들러와 addEventListener의 차이: HTML 속성으로 등록하는 인라인 핸들러와 JavaScript의 addEventListener는 this가 결정되는 방식이 다르며, 각각의 동작 원리를 구분해야 합니다
- 화살표 함수의 렉시컬 this 활용: 화살표 함수는 자체 this를 갖지 않으므로, 이벤트 핸들러에서 외부 스코프의 this를 유지하는 데 효과적인 해결책이 됩니다
- bind를 통한 명시적 this 고정: Function.prototype.bind를 사용하면 핸들러 함수의 this를 원하는 객체로 영구적으로 고정할 수 있어, 클래스 메서드를 안전하게 이벤트 핸들러로 사용할 수 있습니다
💡 실무에서의 영향
이벤트 핸들러에서의 this 문제는 프론트엔드 개발 실무에서 매우 빈번하게 마주치는 상황입니다. 바닐라 JavaScript로 DOM을 조작할 때, 클래스로 구성된 UI 컴포넌트의 메서드를 이벤트 핸들러로 등록하면 this가 인스턴스가 아닌 DOM 요소를 가리키게 되어 내부 상태에 접근할 수 없는 오류가 발생합니다. React 클래스 컴포넌트에서도 이벤트 핸들러 메서드를 생성자에서 바인딩하지 않으면 setState 호출 시 “Cannot read property of undefined” 에러가 발생하는 것이 대표적인 사례입니다. 이 원리를 이해하면 프레임워크가 내부적으로 이벤트를 어떻게 처리하는지 파악할 수 있고, 디버깅 시간을 크게 단축할 수 있습니다. 또한 이벤트 위임 패턴을 구현할 때 this와 event.target의 차이를 명확히 이해해야 올바른 요소에 대한 처리를 할 수 있습니다. 현대 프레임워크에서 화살표 함수와 훅이 이 문제를 상당 부분 해소했지만, 레거시 코드 유지보수나 라이브러리 내부 동작을 이해하는 데 있어 이 지식은 여전히 필수적입니다.
핵심 개념
addEventListener의 this 바인딩
입문
브라우저에서 버튼 클릭 같은 이벤트를 처리할 때, 함수 안의 this는 자동으로 클릭된 요소를 가리켜요. 왜 그런지 알아볼까요!
🖱️ 이벤트 핸들러가 뭔가요? 이벤트 핸들러는 ‘무언가 일어났을 때 실행되는 함수’예요. 마치 초인종이 울리면 문을 열어보는 것처럼, 버튼이 클릭되면 정해진 동작을 하는 거예요.
📦 this는 누구를 가리키나요? addEventListener로 등록한 함수 안에서 this는 이벤트가 일어난 요소를 가리켜요. 버튼에 클릭 이벤트를 등록했다면, this는 그 버튼이에요. 마치 초인종을 누른 사람이 아니라, 초인종이 달린 ‘그 문’을 가리키는 것과 같아요.
🏠 왜 요소를 가리키나요? 브라우저는 “이 버튼에서 일어난 일이니까, 함수가 이 버튼에 대해 알아야 해”라고 생각해요. 그래서 함수를 호출할 때 this를 자동으로 그 버튼으로 설정해줘요. 편의점 알바생이 계산대에서 일할 때, ‘내 담당 계산대’를 자동으로 아는 것과 비슷해요.
🎯 인라인 핸들러는 다른가요? HTML에 직접 쓰는 onclick=”…” 방식은 조금 달라요. 이 경우에도 this는 해당 요소를 가리키지만, 동작 방식이 다르기 때문에 addEventListener를 사용하는 것이 더 안전하고 권장되는 방법이에요.
중급
addEventListener로 등록된 콜백 함수 내부에서 this는 이벤트 리스너가 등록된 요소, 즉 event.currentTarget과 동일한 값을 가집니다. 이는 브라우저가 콜백을 호출할 때 내부적으로 this를 해당 요소로 설정하기 때문입니다.
const button = document.querySelector('#myBtn');
button.addEventListener('click', function(event) {
console.log(this === event.currentTarget); // true
console.log(this === button); // true
this.style.backgroundColor = 'red'; // this로 요소 직접 조작
});
인라인 핸들러와의 차이
HTML 속성으로 등록하는 인라인 핸들러(onclick="...")에서도 this는 해당 요소를 가리키지만, 동작 메커니즘이 다릅니다. 인라인 핸들러는 브라우저가 내부적으로 함수를 감싸서 실행하며, addEventListener는 콜백을 직접 호출합니다.
// HTML: <button onclick="handleClick(this)">Click</button>
// 인라인에서 this는 요소 자체 (함수 인자로 명시 전달)
// addEventListener - this가 자동으로 요소를 가리킴
button.addEventListener('click', function() {
console.log(this); // <button> 요소
});
// 주의: 화살표 함수는 this를 바인딩하지 않음
button.addEventListener('click', () => {
console.log(this); // window (또는 상위 스코프의 this)
});
심화
addEventListener의 this 바인딩은 DOM Level 2 Events 명세의 EventListener 인터페이스 호출 규약과 ECMAScript의 함수 호출 메커니즘이 결합된 결과입니다.
WHATWG DOM Living Standard의 이벤트 디스패치 알고리즘 DOM Living Standard의 이벤트 디스패치(dispatch) 알고리즘에서, 브라우저는 등록된 리스너의 callback을 호출할 때 내부적으로 Call(callback, currentTarget, «event»)와 동등한 연산을 수행합니다. 여기서 currentTarget이 thisArg로 전달되어 콜백 내부의 this가 이벤트가 디스패치되는 현재 대상 요소를 가리키게 됩니다. 이는 ECMAScript 명세의 OrdinaryCallBindThis 추상 연산(Abstract Operation)을 통해 함수의 [[ThisValue]] 내부 슬롯에 바인딩됩니다.
EventTarget.addEventListener의 내부 동작 addEventListener는 EventListenerList(이벤트 리스너 목록)에 {type, callback, capture, passive, once, signal} 구조의 리스너 레코드를 추가합니다. 이벤트 발생 시 브라우저 엔진(Blink, Gecko 등)은 이벤트 전파 경로(propagation path)를 계산한 뒤, 캡처 단계(capture phase) → 타겟 단계(target phase) → 버블 단계(bubble phase) 순서로 각 노드의 리스너를 호출합니다. 이때 각 단계에서 currentTarget은 현재 처리 중인 노드로 갱신되며, 콜백의 this도 그에 따라 변경됩니다.
handleEvent 패턴과 this 바인딩의 예외 EventListener 인터페이스는 함수뿐 아니라 handleEvent 메서드를 가진 객체도 허용합니다. 이 경우 브라우저는 listener.handleEvent(event) 형태로 호출하므로, this는 currentTarget이 아닌 리스너 객체 자체를 가리킵니다. 이 패턴은 메서드의 this를 자연스럽게 보존하면서 이벤트 처리 로직을 객체 안에 캡슐화할 수 있어, bind나 화살표 함수 없이도 this 손실 문제를 해결하는 고급 기법입니다.
메서드 참조 전달과 this 손실
입문
객체의 함수를 이벤트 핸들러로 넘기면, 그 함수는 원래 주인을 잊어버려요. 왜 이런 일이 벌어지는지 알아볼게요!
📋 메서드가 뭔가요? 메서드는 객체에 속한 함수예요. 마치 회사원이 회사에 소속되어 일하는 것처럼, 메서드는 자기가 속한 객체의 정보를 this로 접근할 수 있어요.
✂️ 왜 주인을 잊어버리나요? 이벤트 핸들러로 메서드를 넘기면, 함수만 복사되고 객체와의 연결은 끊어져요. 마치 회사원이 명함만 가지고 다른 회사에 파견 나가면, 원래 회사의 시스템에 접근할 수 없는 것과 같아요.
🚨 어떤 문제가 생기나요? 함수 안에서 this를 사용하면, 원래 객체가 아니라 버튼 같은 DOM 요소를 가리키게 돼요. 그래서 객체의 데이터에 접근하려 하면 “undefined” 에러가 발생해요. 택배 기사가 잘못된 주소로 배달하는 것과 비슷한 상황이에요.
💡 이건 이벤트 핸들러만의 문제인가요? 아니에요! 함수를 변수에 넣거나, 다른 함수의 인자로 넘기거나, 배열에 저장할 때도 같은 문제가 생겨요. 이벤트 핸들러는 가장 흔하게 마주치는 경우일 뿐이에요.
중급
객체의 메서드를 addEventListener의 콜백으로 전달하면, 메서드가 객체로부터 분리(detach)됩니다. 이는 JavaScript에서 함수가 일급 객체(first-class object)이기 때문입니다. 메서드를 전달하는 것은 함수 참조만 복사하는 것이며, 호출 컨텍스트(this)는 함께 전달되지 않습니다.
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count++; // this가 Counter 인스턴스여야 함
console.log(this.count);
}
}
const counter = new Counter();
// 메서드 참조만 전달 → this 손실
button.addEventListener('click', counter.increment);
// 클릭 시 this === button → this.count === undefined → NaN
왜 this가 손실되는가
counter.increment라고 쓰면 함수 객체에 대한 참조만 추출됩니다. counter.increment()처럼 점(.) 앞의 객체와 함께 호출해야만 암묵적 바인딩이 작동합니다. addEventListener는 내부적으로 저장된 함수 참조를 callback.call(currentTarget, event) 형태로 호출하므로, 원래 객체와의 연결이 완전히 끊어집니다.
// 이 두 줄은 동일한 효과
button.addEventListener('click', counter.increment);
const fn = counter.increment; // 함수 참조만 추출
button.addEventListener('click', fn); // counter와 무관하게 호출됨
심화
메서드 참조 전달 시 this 손실은 ECMAScript의 Reference Record 명세와 함수 호출 시맨틱의 차이에서 비롯됩니다.
Reference Record와 GetThisValue
ECMAScript 명세에서 counter.increment라는 멤버 표현식(MemberExpression)을 평가하면 Reference Record가 생성됩니다. 이 Reference Record는 { [[Base]]: counter, [[ReferencedName]]: “increment”, [[Strict]]: false } 형태의 내부 구조를 가집니다. counter.increment()처럼 즉시 호출하면 GetThisValue 추상 연산이 [[Base]]에서 this 값을 추출하여 함수에 전달합니다. 그러나 const fn = counter.increment처럼 변수에 할당하면 GetValue 추상 연산이 Reference Record를 해소(resolve)하여 순수 함수 객체만 반환하고, Reference Record는 소멸합니다.
addEventListener 내부의 콜백 호출 메커니즘 Blink 엔진(Chromium)의 EventTarget::FireEventListeners 구현에서, 등록된 콜백은 V8의 Function::Call을 통해 호출됩니다. 이때 receiver(thisArg)로 currentTarget 노드를 전달합니다. 원래 함수가 어떤 객체의 메서드였는지에 대한 정보는 함수 객체 자체에 저장되지 않으므로(함수의 [[HomeObject]] 내부 슬롯은 super 참조용이며 this 바인딩과 무관), 브라우저가 원래 객체를 복원할 방법이 없습니다.
프레임워크의 this 손실 대응 전략 React 클래스 컴포넌트의 합성 이벤트(SyntheticEvent) 시스템에서도 동일한 문제가 발생했습니다. React는 이벤트 위임(delegation) 패턴으로 루트 노드에서 이벤트를 처리하며, 컴포넌트 메서드를 콜백으로 호출할 때 this가 컴포넌트 인스턴스가 아닌 undefined(strict mode)가 됩니다. 이로 인해 React 공식 문서에서 생성자 내 bind 또는 클래스 필드(class field) 화살표 함수 패턴을 권장했으며, 이 문제는 함수형 컴포넌트와 Hooks의 도입 동기 중 하나가 되었습니다.
this 손실 해결 패턴
입문
이벤트 핸들러에서 this가 엉뚱한 곳을 가리키는 문제를 해결하는 방법이 여러 가지 있어요. 각각 어떤 원리인지 쉽게 알아볼게요!
🔗 bind로 묶어두기 bind는 함수에 ‘이름표’를 달아주는 거예요. “너는 항상 이 객체를 기억해!”라고 미리 약속하는 거죠. 마치 파견 나가는 직원에게 원래 회사 사원증을 꼭 들고 다니라고 하는 것과 같아요.
➡️ 화살표 함수로 감싸기 화살표 함수는 자기만의 this가 없어서, 바깥에 있는 this를 그대로 사용해요. 마치 투명 유리 상자 안에 있는 것처럼, 밖에 있는 환경을 그대로 볼 수 있어요.
📝 클래스 필드로 선언하기 클래스 안에서 화살표 함수로 메서드를 만들면, 처음부터 this가 고정돼요. 마치 직원을 채용할 때부터 전용 사원증을 발급하는 것과 같아서, 어디에 파견을 보내도 원래 소속을 잊지 않아요.
🎁 handleEvent 객체 전달하기 함수 대신 객체 자체를 이벤트 핸들러로 등록할 수도 있어요. 브라우저가 객체의 handleEvent라는 이름의 함수를 자동으로 찾아서 실행해주기 때문에, this 문제가 자연스럽게 해결돼요.
중급
이벤트 핸들러에서 this 손실을 해결하는 대표적인 패턴은 네 가지입니다. 각 패턴의 동작 원리와 트레이드오프를 이해하면 상황에 맞는 최선의 방법을 선택할 수 있습니다.
class Counter {
constructor() {
this.count = 0;
// 생성자에서 this를 영구 고정
this.increment = this.increment.bind(this);
}
increment() {
this.count++;
console.log(this.count);
}
}
const counter = new Counter();
button.addEventListener('click', counter.increment); // this === counter ✅
// 화살표 함수로 감싸서 외부 this 유지
button.addEventListener('click', () => {
counter.increment(); // 점(.) 호출이므로 암묵적 바인딩 작동
});
// 또는 클래스 필드 화살표 함수
class Counter {
count = 0;
increment = () => { // 인스턴스 생성 시 this 캡처
this.count++;
console.log(this.count);
};
}
class Counter {
constructor() {
this.count = 0;
}
handleEvent(event) {
// this는 Counter 인스턴스 (객체로 호출되므로)
this.count++;
console.log(`${event.type}: ${this.count}`);
}
}
const counter = new Counter();
// 함수가 아닌 객체를 전달
button.addEventListener('click', counter); // counter.handleEvent(event) 호출
심화
각 this 손실 해결 패턴은 ECMAScript 명세의 서로 다른 메커니즘을 활용하며, 메모리 특성과 성능 영향이 상이합니다.
Function.prototype.bind의 BoundFunctionCreate bind는 ECMAScript 명세의 BoundFunctionCreate 추상 연산을 통해 exotic object(특이 객체)인 Bound Function을 생성합니다. 이 객체는 [[BoundTargetFunction]](원본 함수), [[BoundThis]](고정된 this), [[BoundArguments]](부분 적용 인자)를 내부 슬롯으로 보유합니다. 호출 시 [[Call]] 내부 메서드가 [[BoundThis]]를 thisArg로 사용하여 원본 함수를 호출하므로, addEventListener가 어떤 this로 호출하든 무시됩니다. 주의할 점은 bind가 호출할 때마다 새로운 함수 객체를 생성하므로, removeEventListener에서 동일한 참조를 사용하려면 바인딩된 함수를 변수에 저장해야 합니다.
화살표 함수의 [[ThisMode]]: lexical
화살표 함수는 생성 시 [[ThisMode]] 내부 슬롯이 lexical로 설정됩니다. OrdinaryCallBindThis 추상 연산은 [[ThisMode]]가 lexical인 함수에 대해 this 바인딩 단계를 완전히 건너뜁니다. 대신 외부 함수의 Environment Record에서 this를 조회합니다. 클래스 필드 화살표 함수(increment = () => {})는 인스턴스 초기화 시(InitializeInstanceElements) 실행되어 생성자의 this를 캡처합니다. 이는 프로토타입이 아닌 각 인스턴스에 함수가 할당되므로, 인스턴스 수만큼 함수 객체가 생성되는 메모리 트레이드오프가 있습니다.
handleEvent의 EventListener 인터페이스 규약
Web IDL(Web Interface Definition Language) 명세에서 EventListener는 callback interface로 정의되며, 함수 또는 handleEvent 메서드를 가진 객체를 모두 허용합니다. 객체가 전달되면 브라우저는 내부적으로 listener.handleEvent.call(listener, event)와 동등한 호출을 수행합니다. 이때 this가 리스너 객체 자체가 되므로 추가 바인딩 없이 메서드의 this가 보존됩니다. 이 패턴은 함수 객체를 추가 생성하지 않고, removeEventListener에서도 동일한 객체 참조로 제거할 수 있어 메모리 효율적입니다.
this vs event.target vs event.currentTarget
입문
이벤트가 발생하면 ‘어떤 요소에서 일어났는지’를 알려주는 방법이 여러 가지예요. 비슷해 보이지만 각각 다른 것을 가리키고 있어요!
🎯 event.target이 뭔가요? event.target은 이벤트가 실제로 시작된 요소예요. 마치 돌을 던진 사람이 누구인지 알려주는 거예요. 버튼 안의 아이콘을 클릭하면, target은 아이콘이에요.
📌 event.currentTarget은 뭔가요? currentTarget은 이벤트 핸들러가 등록된 요소예요. 마치 돌을 맞은 사람이 아니라, 경비실에서 감시하는 경비원이에요. 버튼에 핸들러를 등록했으면, currentTarget은 항상 버튼이에요.
🔄 this는 어디를 가리키나요? 일반 함수로 이벤트 핸들러를 만들면 this는 currentTarget과 같아요. 하지만 화살표 함수를 쓰면 this는 완전히 다른 곳을 가리킬 수 있어요. 그래서 요소를 확실하게 참조하려면 this 대신 event.currentTarget을 사용하는 것이 더 안전해요.
🏢 이벤트 위임에서는요? 큰 목록에서 각 항목마다 이벤트를 등록하는 대신, 부모 요소에 하나만 등록할 수 있어요. 이때 target은 실제 클릭된 항목이고, currentTarget은 부모 요소예요. 이 차이를 알아야 올바른 항목을 처리할 수 있어요.
중급
이벤트 처리에서 this, event.target, event.currentTarget은 각각 다른 요소를 참조할 수 있으며, 이 차이를 명확히 구분해야 이벤트 위임(event delegation) 등의 패턴을 올바르게 구현할 수 있습니다.
| 참조 | 의미 | 일관성 |
|---|---|---|
this | 핸들러 등록 방식에 따라 다름 | 화살표 함수에서 달라짐 |
event.target | 이벤트가 실제 발생한 요소 | 항상 일관 |
event.currentTarget | 핸들러가 등록된 요소 | 항상 일관 |
// <ul id="list">
// <li><span>Item 1</span></li>
// <li><span>Item 2</span></li>
// </ul>
const list = document.querySelector('#list');
list.addEventListener('click', function(event) {
console.log(this); // <ul> (핸들러가 등록된 요소)
console.log(event.currentTarget); // <ul> (this와 동일)
console.log(event.target); // <span> 또는 <li> (실제 클릭된 요소)
// 이벤트 위임: target에서 가장 가까운 li 찾기
const item = event.target.closest('li');
if (item) {
item.classList.toggle('selected');
}
});
실무 권장사항
this 대신 event.currentTarget을 사용하면 화살표 함수든 일반 함수든 동일하게 동작하므로, 코드의 일관성과 안전성이 높아집니다. 특히 TypeScript 환경에서는 event.currentTarget이 올바른 타입 추론을 제공합니다.
// 화살표 함수에서도 안전하게 요소 참조
button.addEventListener('click', (event) => {
// this는 외부 스코프 → 요소가 아님
// event.currentTarget은 항상 button
event.currentTarget.disabled = true;
});
심화
this, event.target, event.currentTarget의 차이는 DOM 이벤트 전파 모델과 JavaScript 함수 호출 시맨틱의 교차점에서 명확하게 드러납니다.
이벤트 전파 경로와 currentTarget의 동적 갱신 DOM Living Standard에서 이벤트 디스패치 알고리즘은 먼저 이벤트 경로(event path)를 구성합니다. 이 경로는 composed path로, Shadow DOM 경계를 포함하여 Window에서 target까지의 노드 목록입니다. 디스패치 루프가 각 노드를 순회할 때마다 event.currentTarget이 해당 노드로 갱신됩니다. event.target은 이벤트 생성 시 고정되어 전체 전파 과정에서 불변(immutable)이지만, currentTarget은 전파 단계마다 동적으로 변합니다. 따라서 비동기 콜백(setTimeout, Promise.then)에서 event.currentTarget을 참조하면 null이 됩니다. 이는 이벤트 디스패치가 완료되면 currentTarget이 null로 리셋되기 때문입니다.
Shadow DOM에서의 event.target 리타겟팅 Shadow DOM을 사용하는 웹 컴포넌트에서 event.target은 리타겟팅(retargeting)됩니다. 이벤트가 Shadow Root를 넘어 전파될 때, 외부 관찰자의 관점에서 event.target은 Shadow Host 요소로 교체됩니다. 이는 캡슐화(encapsulation)를 보장하기 위한 설계로, composed: true인 이벤트만 Shadow 경계를 통과하며, 통과 시 각 경계에서 target이 재계산됩니다. event.composedPath() 메서드를 사용하면 리타겟팅 이전의 전체 경로를 확인할 수 있습니다.
TypeScript에서의 타입 안전성
TypeScript의 DOM 타이핑에서 event.currentTarget은 이벤트 리스너 등록 시점의 요소 타입을 정확히 추론합니다. HTMLButtonElement에 등록된 리스너의 event.currentTarget은 HTMLButtonElement로 타입이 좁혀집니다(narrowing). 반면 event.target은 EventTarget | null로 넓은 타입을 가지므로, 타입 가드(type guard)나 타입 단언(type assertion)이 필요합니다. this의 경우 addEventListener의 제네릭 시그니처에 따라 타입이 결정되지만, 화살표 함수에서는 외부 스코프의 this 타입을 따르므로 DOM 요소 타입이 아닐 수 있습니다.