암시적 바인딩 손실(Implicit Binding Loss)은 JavaScript에서 this가 예상과 다르게 동작하는 가장 흔한 원인 중 하나입니다. 객체의 메서드를 호출할 때는 this가 해당 객체를 가리키지만, 그 메서드를 변수에 저장하거나 콜백으로 넘기는 순간 this는 호출 시점의 문맥을 잃어버립니다. 이 현상은 코드의 논리가 올바른데도 런타임에서 갑작스러운 오류를 만들어내는 전형적인 JavaScript 함정으로, 왜 이런 일이 일어나는지를 원리적으로 이해하지 않으면 반복해서 같은 실수를 하게 됩니다.
핵심 문제점
- 메서드를 변수에 할당하면 객체와의 연결이 끊어져
this가 전역 객체 또는undefined를 가리킴 - 콜백 함수로 메서드를 전달할 때도 동일하게 암시적 바인딩이 사라짐
setTimeout,setInterval, 이벤트 핸들러 등 비동기 콜백 환경에서 특히 자주 발생- 고차 함수(
forEach,map,filter등)에 메서드를 그대로 전달하면this가 의도대로 동작하지 않음 - 클래스나 모듈 내부에서도 메서드를 외부로 꺼내는 순간 동일한 문제가 발생
실무에서의 영향
실제 개발에서 암시적 바인딩 손실은 “작동하다가 갑자기 안 되는” 버그의 주요 원인입니다. React 클래스 컴포넌트에서 이벤트 핸들러를 바인딩하지 않아 this.setState를 찾을 수 없다는 오류가 발생하거나, 모듈에서 가져온 메서드가 내부 상태에 접근하지 못하는 문제가 모두 이 현상에서 비롯됩니다. 이 개념을 이해하면 화살표 함수와 bind, call, apply를 언제 왜 써야 하는지 직관적으로 판단할 수 있게 됩니다. 또한 팀 코드 리뷰에서 동료의 바인딩 관련 실수를 조기에 발견하고, 오류 메시지 없이 undefined를 반환하는 조용한 버그를 빠르게 진단하는 능력을 갖추게 됩니다. 궁극적으로 this 바인딩이 어떤 규칙으로 결정되는지를 체계적으로 파악한 개발자는 더 예측 가능하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.
핵심 개념
메서드 참조와 바인딩 손실
입문
객체 안에 들어있는 함수(메서드)를 꺼내서 다른 곳에 저장하는 순간, 그 함수는 원래 주인을 잊어버려요. 왜 그런 일이 생기는지 알아볼게요.
📬 편지와 주소지 비유
택배를 배송할 때 “김철수네 집”이라는 주소지가 있어야 어디로 가야 할지 알 수 있죠. 객체 메서드도 마찬가지예요. 사람.인사하기()처럼 호출할 때는 사람이 주소지 역할을 해서 함수 안에서 this가 사람을 가리켜요.
📦 꺼내면 주소지가 사라져요
그런데 let 인사 = 사람.인사하기라고 하면, 함수 자체만 꺼내 온 거예요. 택배를 상자에서 꺼내면 주소지가 없어지듯, 함수는 이제 어느 객체의 것인지 모르는 상태가 돼요. 그래서 인사()로 호출하면 this가 엉뚱한 곳을 가리키게 돼요.
🚨 어디를 가리키게 되나요?
주인을 잃은 함수에서 this는 전역 객체(브라우저에서는 window)를 가리키거나, 엄격 모드('use strict')에서는 아예 undefined가 돼요. 그래서 this.이름 같은 코드가 오류를 일으키게 됩니다.
💡 왜 이런 설계가 됐나요?
JavaScript에서 this는 함수가 “어디에 저장되어 있는가”가 아니라 “어떻게 호출되었는가”에 따라 결정돼요. 이 규칙 때문에 메서드를 꺼내서 호출하면 바인딩이 사라지는 현상이 생기는 거예요.
중급
JavaScript의 this는 함수가 선언된 위치가 아니라 **호출되는 방식(Call Site)**에 따라 동적으로 결정됩니다. 객체의 메서드를 변수에 할당하면 함수 참조만 복사되고, 호출 컨텍스트(객체와의 연결)는 복사되지 않습니다.
암시적 바인딩(Implicit Binding)은 obj.method() 형식처럼 점(.) 연산자 앞에 객체가 있을 때만 성립합니다. 변수에 할당 후 호출하면 암시적 바인딩이 없어지고 기본 바인딩(Default Binding)이 적용됩니다.
const user = {
name: 'Alice',
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};
user.greet(); // "Hello, I'm Alice" - 암시적 바인딩 성립
const fn = user.greet;
fn(); // "Hello, I'm undefined" - 바인딩 손실 (strict: TypeError)
기본 바인딩 적용 규칙
fn() 형식의 단순 함수 호출에서는 기본 바인딩이 적용됩니다. 비엄격 모드(sloppy mode)에서는 전역 객체(window 또는 global)가, 엄격 모드에서는 undefined가 this가 됩니다.
'use strict';
const fn = user.greet;
fn(); // TypeError: Cannot read properties of undefined (reading 'name')
심화
암시적 바인딩 손실은 ECMAScript 명세의 참조 타입(Reference Type)과 실행 컨텍스트(Execution Context)의 ThisBinding 결정 메커니즘에서 비롯됩니다.
ECMAScript Reference Type과 GetThisValue
ECMAScript 2023 명세 6.2.5절(The Reference Record Specification Type)에 따르면, obj.method 표현식은 단순한 함수 값이 아니라 Reference Record를 생성합니다. 이 레코드는 [[Base]](객체), [[ReferencedName]](프로퍼티명), [[Strict]] 필드를 가집니다.
obj.method()로 호출할 때 13.3.6절(Function Calls)의 추상 연산은 참조에서 GetThisValue()를 통해 [[Base]] 값을 추출하여 this로 사용합니다. 반면 const fn = obj.method처럼 할당하면 [[GetValue]](ref) 연산이 Reference Record를 일반 함수 값으로 변환하고, 참조 정보는 소멸합니다.
V8 엔진의 Call Site 분석
V8은 호출 지점(Call Site)에서 호출 패턴을 분석하여 this 값을 결정합니다. LoadIC(Inline Cache for property load) 단계에서 수신자 객체(Receiver Object)가 기록되며, 직접 호출 패턴(fn())에서는 수신자 없이 호출되므로 글로벌 프록시(Global Proxy) 객체 또는 undefined가 바인딩됩니다. TurboFan 최적화 컴파일러는 수신자 타입이 안정적일 때 인라이닝(Inlining)을 수행하므로, 바인딩 손실이 있는 함수는 최적화 혜택을 받기 어렵습니다.
콜백 전달 시 바인딩 손실
입문
친구에게 “나 대신 문 좀 열어줘”라고 부탁할 때, 친구가 대신 열어주지만 그건 친구 행동이에요. 메서드를 콜백으로 전달하는 것도 이와 비슷해요.
🎭 대리인 문제
setTimeout(사람.인사하기, 1000)처럼 메서드를 콜백으로 넘기면, setTimeout이 나중에 그 함수를 “대신” 실행해요. 그런데 대리인(setTimeout)은 원래 어떤 객체의 메서드였는지 알 수 없어요. 그래서 this가 원래 객체를 가리키지 않게 돼요.
📞 전화번호만 알려줬어요 콜백 전달은 “나중에 이 번호로 전화해줘”라고 하는 것과 같아요. 전화를 받을 때는 누가 번호를 준 맥락이 사라지고, 단순히 전화가 오는 거예요. 함수도 마찬가지로 콜백으로 전달되면 원래 소속 정보가 없는 채로 실행돼요.
🔄 forEach, map 에서도 같은 문제가요?
네! 배열.forEach(객체.메서드)처럼 써도 동일한 문제가 생겨요. forEach는 콜백 함수를 자체적으로 호출하는데, 이때 특별한 this를 지정하지 않으면 전역 객체가 this가 돼요.
💡 어떻게 알아볼 수 있나요?
콜백으로 넘길 때 함수 이름만 쓰면 위험 신호예요. 객체.메서드처럼 점이 있어도, 괄호 없이 넘기는 순간 바인딩이 끊길 수 있어요.
중급
함수를 콜백으로 전달할 때는 메서드 참조 할당과 동일한 메커니즘으로 바인딩이 손실됩니다. 콜백을 받은 고차 함수는 단순히 callback() 형태로 호출하기 때문에, 원래 어떤 객체의 메서드였는지와 무관하게 기본 바인딩이 적용됩니다.
const timer = {
name: 'MyTimer',
start() {
setTimeout(this.tick, 1000); // this.tick을 콜백으로 전달
},
tick() {
console.log(`${this.name} ticked`); // this.name: undefined
}
};
timer.start(); // 1초 후: "undefined ticked"
forEach/map에서의 바인딩 손실
Array.prototype.forEach의 두 번째 인자로 thisArg를 제공하지 않으면, 콜백 내부의 this는 기본 바인딩을 따릅니다. 메서드 참조를 직접 전달하면 동일한 손실이 발생합니다.
const counter = {
count: 0,
increment() {
this.count++; // this가 counter가 아님
}
};
[1, 2, 3].forEach(counter.increment);
console.log(counter.count); // 0 - 증가하지 않음
심화
콜백 전달 시 바인딩 손실은 ECMAScript의 추상 연산 Call(F, V, argumentsList) 구조에서 V(thisValue) 파라미터 전달 방식의 차이에서 기인합니다.
ECMAScript 추상 연산 Call과 thisValue 전달
ECMAScript 2023 명세 7.3.14절(Call(F, V, argumentsList))에서 V는 this 값으로 사용됩니다. setTimeout이나 forEach 같은 내장 함수는 콜백을 호출할 때 V를 undefined(엄격 모드) 또는 전역 객체(비엄격 모드)로 설정합니다.
Array.prototype.forEach(22.1.3.13절)의 명세를 보면: callbackfn을 호출할 때 Call(callbackfn, T, ...) 형태를 사용하고, T는 thisArg가 제공된 경우 그 값을 사용하고 그렇지 않으면 undefined입니다. 이는 설계 의도가 반영된 것으로, 콜백의 this 컨텍스트를 호출자가 명시적으로 제어하도록 위임합니다.
실용적 함의: thisArg 파라미터 설계 패턴
ECMAScript 내장 메서드들이 thisArg 파라미터를 노출하는 것(forEach, map, filter, some, every 등)은 이 문제에 대한 언어 레벨의 해결책입니다. 그러나 서드파티 라이브러리나 사용자 정의 고차 함수는 이 규약을 따르지 않을 수 있으므로, 화살표 함수 래핑이나 bind를 통한 명시적 바인딩이 더 범용적인 해결책입니다.
비동기 환경에서의 바인딩 손실
입문
숙제를 내일 하겠다고 했을 때, 내일이 되면 어떤 상황인지 모르잖아요? 비동기 함수도 마찬가지예요. “나중에 실행”되는 코드는 실행 당시의 환경에 따라 this가 달라져요.
⏰ 나중에 실행되는 문제
setTimeout, setInterval, fetch().then() 같은 코드는 즉시 실행되지 않고 나중에 실행돼요. 그 “나중에”가 되었을 때는 원래 어떤 객체의 메서드였는지 정보가 사라져서 this가 예상과 다르게 동작해요.
🎬 배우와 대본 비유
배우가 무대에서 대사를 말할 때는 캐릭터로서 말해요(this = 캐릭터). 그런데 대본을 다른 사람에게 주고 나중에 읽어달라고 하면, 그 사람은 자기 자신으로 읽겠죠(this = 다른 사람). 비동기 콜백도 마찬가지로 나중에 다른 맥락에서 실행돼요.
🌐 브라우저 이벤트에서도 같은 일이요?
네! 버튼 클릭, 키보드 입력, 마우스 이동 등 이벤트 핸들러도 나중에 실행되는 콜백이에요. 이벤트 핸들러로 객체의 메서드를 넘기면 클릭될 때 this가 객체가 아닌 버튼 요소 자체를 가리키게 돼요.
😱 특히 조심해야 할 순간은요?
클래스를 사용할 때 특히 주의해야 해요. 클래스 안의 메서드를 이벤트 핸들러로 등록할 때 바인딩을 별도로 처리하지 않으면, this가 클래스 인스턴스가 아닌 이벤트를 발생시킨 HTML 요소를 가리키게 됩니다.
중급
비동기 콜백과 이벤트 핸들러에서의 바인딩 손실은 콜백 전달 시 바인딩 손실과 동일한 원리이지만, 실행 시점이 지연된다는 점에서 디버깅이 더 어렵습니다.
브라우저의 addEventListener는 핸들러를 호출할 때 이벤트 타깃 요소를 this로 설정하므로, 객체 메서드를 핸들러로 등록하면 this가 DOM 요소가 됩니다.
class Button {
constructor(label) {
this.label = label;
// 바인딩 손실: this가 클릭된 요소(button)를 가리킴
document.querySelector('button').addEventListener('click', this.handleClick);
}
handleClick() {
console.log(this.label); // undefined - this는 button 요소
}
}
Promise 체인에서의 바인딩
Promise .then() 핸들러도 메서드 참조 전달과 동일하게 바인딩이 손실됩니다. 비엄격 모드에서는 전역 객체, 엄격 모드(모듈 등)에서는 undefined가 됩니다.
class DataService {
constructor() { this.data = []; }
fetchAndProcess() {
fetch('/api/data')
.then(res => res.json())
.then(this.processData); // 바인딩 손실
}
processData(data) {
this.data = data; // TypeError: Cannot set properties of undefined
}
}
심화
비동기 콜백의 바인딩 손실은 JavaScript의 이벤트 루프(Event Loop)와 마이크로태스크 큐(Microtask Queue) 처리 메커니즘, 그리고 실행 컨텍스트(Execution Context)의 생명주기와 깊게 연관됩니다.
실행 컨텍스트 생명주기와 ThisBinding 소멸
ECMAScript 명세 9.4절(Execution Contexts)에 따르면, 각 함수 호출은 새로운 실행 컨텍스트를 생성하며 ThisBinding은 해당 컨텍스트에만 존재합니다. 비동기 작업이 완료되어 콜백이 큐(Queue)에서 디큐(Dequeue)될 때는 원래 호출자의 실행 컨텍스트가 이미 소멸된 상태이므로, 새로운 실행 컨텍스트는 독립적인 ThisBinding을 가져야 합니다.
HTML Living Standard의 이벤트 핸들러 thisValue
HTML Living Standard(§8.1.7.3 Processing model)에 따르면 이벤트 핸들러 콜백은 이벤트의 currentTarget을 this로 설정하여 호출합니다. 이는 addEventListener의 의도적 설계로, 핸들러가 어떤 요소에서 이벤트가 발생했는지 this를 통해 알 수 있게 합니다. 그러나 이 설계가 객체 메서드를 핸들러로 등록할 때 바인딩 충돌을 일으킵니다.
Promise 명세와 PromiseReactionJob
ECMAScript 2023 27.2.1.3절(PerformPromiseThen)에서 .then() 핸들러는 PromiseReactionJob으로 큐에 적재됩니다. PromiseReactionJob 실행 시 핸들러는 undefined를 thisValue로 하여 Call됩니다(9.5.1절, [[Call]] 내부 메서드). 이는 Promise가 실행 컨텍스트로부터 의도적으로 분리되도록 설계된 것으로, 예측 가능한 비동기 실행을 보장합니다.
바인딩 손실 해결 방법
입문
this가 엉뚱한 곳을 가리키는 문제를 해결하는 방법이 여러 가지 있어요. 각 방법이 어떤 원리인지 비유로 알아봐요.
🔗 bind - 주소지를 붙여두기
함수.bind(객체)는 “이 함수를 항상 이 객체에서 실행해”라고 못 박아두는 거예요. 편지 봉투에 발신자 주소를 영구 스탬프로 찍어두는 것처럼, 나중에 어디서 꺼내도 항상 원래 주인을 기억해요.
🏹 화살표 함수 - 주변 환경을 기억하는 메모
화살표 함수(=>)는 자신이 만들어진 위치의 this를 영원히 기억해요. 일반 함수가 “나를 누가 불렀나?”로 this를 결정한다면, 화살표 함수는 “나는 어디서 태어났나?”로 결정해요. 그래서 콜백으로 넘겨도 바인딩이 유지돼요.
📌 변수에 저장하기 - 나는 나야
const self = this처럼 this를 일반 변수에 저장해 두면, 나중에 this가 바뀌어도 self는 원래 값을 그대로 유지해요. 오래된 방법이지만 여전히 동작하는 방식이에요.
🏗️ 어떤 방법을 쓰면 좋나요?
요즘은 화살표 함수를 가장 많이 써요. 코드가 간결하고 읽기 쉬워서요. bind는 이미 만들어진 함수를 나중에 연결할 때 유용하고, self 패턴은 아주 오래된 코드에서 볼 수 있어요.
중급
바인딩 손실을 해결하는 세 가지 주요 방법: bind(), 화살표 함수, thisArg 파라미터 활용.
1. Function.prototype.bind()
새로운 함수를 반환하며, 반환된 함수는 항상 지정된 this로 실행됩니다.
class Button {
constructor(label) {
this.label = label;
// bind로 this를 인스턴스로 고정
document.querySelector('button').addEventListener('click', this.handleClick.bind(this));
}
handleClick() {
console.log(this.label); // 정상 동작
}
}
2. 화살표 함수
화살표 함수는 자신만의 this를 가지지 않고, 렉시컬 환경(Lexical Environment)의 this를 그대로 캡처합니다.
class Timer {
constructor() {
this.count = 0;
// 화살표 함수: this를 렉시컬로 캡처
setInterval(() => {
this.count++; // 항상 Timer 인스턴스를 가리킴
console.log(this.count);
}, 1000);
}
}
3. 클래스 필드 화살표 함수
클래스 프로퍼티 문법으로 메서드를 화살표 함수로 정의하면, 생성 시점에 this가 인스턴스로 바인딩된 함수가 만들어집니다.
class Button {
label = 'Click me';
// 클래스 필드 화살표 함수: 항상 인스턴스에 바인딩
handleClick = () => {
console.log(this.label); // 안전
};
}
const btn = new Button();
document.addEventListener('click', btn.handleClick); // 바인딩 손실 없음
심화
바인딩 손실의 해결 방법들은 각각 다른 ECMAScript 메커니즘을 이용하며, 성능 특성과 메모리 레이아웃에서 차이가 있습니다.
Function.prototype.bind의 내부 구현
ECMAScript 2023 명세 20.2.3.2절(Function.prototype.bind)에 따르면, bind(thisArg, ...args)는 이국적 객체(Exotic Object)인 Bound Function Exotic Object를 생성합니다. 이 객체는 [[BoundTargetFunction]], [[BoundThis]], [[BoundArguments]] 내부 슬롯을 가지며, Call 시 원본 함수를 [[BoundThis]]를 thisValue로 호출합니다.
바인딩된 함수는 새로운 함수 객체를 생성하므로 메모리를 추가로 사용합니다. React 클래스 컴포넌트에서 render() 내부에서 bind를 호출하면 매 렌더링마다 새 함수가 생성되어 불필요한 GC(가비지 컬렉션) 압력이 발생합니다.
화살표 함수의 렉시컬 ThisBinding
ECMAScript 명세 15.3절(Arrow Function Definitions)에 따르면, 화살표 함수는 OrdinaryFunctionCreate 시 [[ThisMode]]를 lexical로 설정합니다. [[Call]] 내부 메서드 실행 시 OrdinaryCallBindThis가 [[ThisMode]] === lexical을 감지하면 새 this 바인딩 없이 외부 환경의 ThisBinding을 그대로 사용합니다.
클래스 필드 화살표 함수의 트레이드오프
TC39 클래스 필드 제안(Class Fields Proposal, Stage 4)에 따라 클래스 필드 화살표 함수는 각 인스턴스 생성 시 [[DefineOwnProperty]]로 인스턴스 자체에 프로퍼티로 등록됩니다. 이는 프로토타입 체인에 등록되는 일반 메서드와 달리 인스턴스별로 별도 함수 객체가 생성됩니다. 인스턴스가 많을 경우(예: 목록 렌더링) 메모리 사용량이 선형적으로 증가하므로(O(n)), 성능에 민감한 상황에서는 constructor에서의 bind 또는 화살표 함수 래핑을 선택적으로 고려해야 합니다.