명시적 바인딩(call, apply, bind)의 차이는?

call, apply, bind 메서드를 사용하여 this를 명시적으로 지정하는 방법을 비교 학습합니다 - 즉시 호출과 영구 바인딩의 차이점을 이해합니다

중급 15분 call apply bind 명시적 바인딩

JavaScript에서 this가 어떤 객체를 가리킬지는 기본적으로 함수가 호출되는 방식에 의해 결정됩니다. 그런데 개발자가 원하는 객체를 직접 지정해서 this를 확정할 수 있는 세 가지 메서드가 있습니다. 바로 call, apply, bind입니다. 이 세 메서드는 모두 this를 명시적으로 고정한다는 공통 목적을 가지지만, 함수를 실행하는 시점과 인수를 전달하는 방식이 서로 달라 사용 맥락에 따라 적절히 선택해야 합니다. 암시적 바인딩 손실처럼 예측할 수 없는 this 문제를 근본적으로 제어하기 위한 핵심 도구이므로, 세 메서드의 차이를 정확히 이해하는 것이 중요합니다.

🔍 핵심 특징

  • callthis와 인수를 쉼표로 나열해 전달하며, 함수를 즉시 실행함
  • applythis를 지정하고 인수를 배열 형태로 묶어 전달하며, 마찬가지로 즉시 실행함
  • bind는 함수를 즉시 실행하지 않고, this가 고정된 새로운 함수를 반환함
  • bind로 반환된 함수는 이후 어떤 방식으로 호출되더라도 this가 변하지 않음
  • 세 메서드 모두 화살표 함수에는 적용되지 않으며, 화살표 함수의 this는 어떠한 방법으로도 재정의할 수 없음

실무에서의 영향

명시적 바인딩은 this를 완전히 통제해야 하는 상황에서 결정적인 역할을 합니다. 예를 들어, 서로 다른 객체의 메서드를 임시로 빌려 재사용할 때 call이나 apply를 활용하면 코드 중복 없이 동일한 로직을 적용할 수 있습니다. apply는 배열 형태의 인수를 그대로 함수에 펼쳐 넣을 수 있어, 가변 인수를 다루는 유틸리티 함수에서 특히 유용합니다. bind는 이벤트 핸들러나 타이머 콜백처럼 함수 실행 시점이 나중으로 미뤄지는 상황에서 this를 안전하게 보장하는 데 쓰입니다. React 클래스 컴포넌트에서 생성자 안에 this.handleClick = this.handleClick.bind(this)를 작성하는 패턴이 바로 이 원리를 활용한 것입니다. 이 세 메서드의 차이를 명확히 이해하면, 코드베이스 안에서 this 문제가 발생했을 때 원인을 빠르게 파악하고 적합한 해법을 즉시 선택할 수 있는 실력을 갖추게 됩니다.


핵심 개념

call 메서드 - this 지정 후 즉시 호출

입문

call은 함수를 호출할 때 “이 함수의 주인은 바로 이 객체야!”라고 직접 알려주는 방법이에요. 함수를 즉시 실행하면서 this를 지정할 수 있답니다.

📞 전화 연결처럼 즉시 연결! call은 마치 전화를 거는 것과 같아요. 버튼을 누르는 순간 바로 연결되죠. “저 지금 A에게 전화 걸어서 바로 말할게요!”처럼, call도 함수를 부르는 즉시 실행하면서 this를 정해줍니다.

🏷️ 주인을 정해주는 이름표 call을 쓰면 함수 안의 this가 가리킬 객체를 직접 정할 수 있어요. 예를 들어 ‘철수의 소개 기능’을 ‘영희에게 빌려서’ 실행할 때, call을 쓰면 그 기능 안에서 this는 영희를 가리키게 됩니다.

📋 인수는 하나씩 쉼표로 call을 쓸 때 추가 정보(인수)는 쉼표로 구분해서 하나씩 건네줘요. 마치 편의점 주문에서 “아메리카노, 샌드위치, 물” 하고 하나씩 말하는 것처럼요.

🔄 빌려쓰기의 핵심 도구 다른 객체의 기능을 잠깐 빌려 쓸 때 call이 아주 유용해요. 비슷한 일을 하는 기능을 매번 새로 만들지 않고, 이미 있는 기능을 가져다 쓸 수 있거든요.

중급

call 메서드는 함수를 즉시 호출하면서 첫 번째 인자로 전달한 값을 this로 바인딩합니다. 인수는 쉼표로 구분해 하나씩 나열합니다.

Function.prototype.call(thisArg, arg1, arg2, ...)

call의 주요 활용 시나리오는 메서드 차용(method borrowing)입니다. 한 객체에 정의된 메서드를 다른 객체에서 재사용할 때 코드 중복 없이 this만 교체할 수 있습니다.

function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const user = { name: '철수' };

// call: this=user, 인수는 쉼표로 나열
console.log(greet.call(user, '안녕', '!')); // "안녕, 철수!"
const cat = {
  name: '나비',
  sound: '야옹',
  introduce() {
    return `${this.name}: ${this.sound}`;
  }
};

const dog = { name: '바둑', sound: '멍멍' };

// dog에는 introduce 메서드가 없지만 cat의 것을 빌려 쓸 수 있음
console.log(cat.introduce.call(dog)); // "바둑: 멍멍"

심화

call은 ECMAScript 명세 Section 20.2.3.3 Function.prototype.call(thisArg, ...args)에 정의된 내장 메서드입니다. 실행 시 추상 연산 OrdinaryCallEvaluateBody가 새로운 실행 컨텍스트(Execution Context)를 생성하고 해당 컨텍스트의 ThisBinding을 thisArg로 설정합니다.

ECMAScript 명세 기반 바인딩 메커니즘 call이 호출되면 내부적으로 [[Call]] 내부 메서드가 실행됩니다. ECMAScript 10.2.1절에 따르면 [[Call]](thisArgument, argumentsList)는 새로운 함수 실행 컨텍스트를 구성하고 thisArgument를 현재 컨텍스트의 ThisBinding으로 설정합니다. 이 값은 ResolveThisBinding() 추상 연산을 통해 함수 본문 내에서 참조됩니다.

thisArg와 엄격 모드(Strict Mode)의 차이 비엄격 모드에서 thisArgnull 또는 undefined이면 전역 객체(Global Object)로 대체됩니다. 반면 엄격 모드('use strict')에서는 thisArg 값이 그대로 this에 바인딩됩니다. 이 차이는 레거시 API와의 호환성을 위한 의도적 설계이며, 코드베이스에서 엄격 모드 여부에 따른 this 값 차이를 디버깅할 때 반드시 고려해야 합니다.

V8 엔진에서의 인라인 캐싱(Inline Caching) V8은 동일한 함수에 대해 반복적으로 call이 호출될 때 인라인 캐시(IC, Inline Cache)를 활용합니다. thisArg의 히든 클래스(Hidden Class)가 동일한 형태(shape)를 유지하면 IC 히트율이 높아져 프로퍼티 조회 비용이 감소합니다. 그러나 매번 다른 형태의 객체를 thisArg로 전달하면 다형성 인라인 캐시(Polymorphic IC)가 형성되어 성능 저하가 발생할 수 있습니다.

apply 메서드 - 배열로 인수 전달

입문

apply는 call과 거의 같은데, 한 가지 차이가 있어요. 추가 정보(인수)를 하나씩 건네는 대신 상자에 담아서 한꺼번에 건네준답니다. 상자 하나에 모든 걸 넣어서 전달하는 방식이에요!

📦 상자에 담아 한 번에 전달 call이 “아메리카노, 샌드위치, 물” 하고 하나씩 말하는 거라면, apply는 “이 봉투에 다 담았어요”라고 봉투째로 건네는 방식이에요. 담긴 내용물은 같지만 전달 방식이 달라요.

📊 목록(배열)을 그대로 넘길 때 편리해요 이미 목록 형태로 모아둔 정보를 함수에 넘겨야 할 때 apply가 아주 편해요. 목록을 하나씩 꺼내서 다시 나열하지 않고 그냥 통째로 넘길 수 있거든요.

🔢 숫자 중 가장 큰 값 찾기 예시 숫자들이 목록으로 있을 때 가장 큰 수를 찾으려면 원래는 한 개씩 넣어야 했어요. 그런데 apply를 쓰면 목록을 그대로 넣을 수 있어서 훨씬 간편해요.

🤝 call과 형제 관계 apply와 call은 하는 일이 같아요. 함수를 즉시 실행하고 this를 지정한다는 점에서 완전히 같습니다. 딱 한 가지, 추가 정보를 전달하는 방식만 달라요.

중급

apply 메서드는 call과 동일하게 함수를 즉시 실행하고 this를 바인딩하지만, 두 번째 인자로 배열 또는 유사 배열 객체(array-like object)를 받습니다.

Function.prototype.apply(thisArg, argsArray)

ES6 스프레드 연산자(...) 등장 이후 apply의 배열 펼치기 역할이 상당 부분 대체되었지만, 런타임에 인수 목록이 동적으로 결정되는 경우나 레거시 코드에서 여전히 많이 사용됩니다.

function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const user = { name: '영희' };
const args = ['반가워', '~'];

// apply: this=user, 인수는 배열로 전달
console.log(greet.apply(user, args)); // "반가워, 영희~"
const numbers = [3, 1, 4, 1, 5, 9, 2, 6];

// apply 방식 (ES5 이전부터 사용)
const maxOld = Math.max.apply(null, numbers); // 9

// 스프레드 방식 (ES6 이후 권장)
const maxNew = Math.max(...numbers); // 9

// apply는 this 지정이 동시에 필요할 때 여전히 유용
const obj = { values: [10, 20, 30] };
function sumWithBase(a, b, c) {
  return this.base + a + b + c;
}
const objWithBase = { base: 100 };
console.log(sumWithBase.apply(objWithBase, obj.values)); // 160

심화

apply는 ECMAScript 명세 Section 20.2.3.2 Function.prototype.apply(thisArg, argArray)에 정의됩니다. 내부적으로 CreateListFromArrayLike(argArray) 추상 연산을 호출해 배열 또는 유사 배열 객체를 인수 목록(Arguments List)으로 변환한 뒤, call과 동일하게 [[Call]] 내부 메서드를 실행합니다.

유사 배열 객체(Array-like Object) 처리 CreateListFromArrayLikelength 프로퍼티와 인덱스 접근이 가능한 모든 객체를 처리할 수 있어, DOM의 NodeList나 함수 내부의 arguments 객체도 apply의 두 번째 인자로 직접 사용할 수 있습니다. 이는 레거시 코드에서 Array.prototype.slice.call(arguments)와 함께 apply를 조합하는 패턴의 근거가 됩니다.

스프레드 연산자와의 내부 동작 차이 ES6 스프레드 연산자(...)는 이터러블 프로토콜(Iterable Protocol)을 기반으로 동작하며, Symbol.iterator가 구현된 모든 객체를 인수로 펼칠 수 있습니다. 반면 apply는 유사 배열 객체에 의존하므로 이터러블이지만 유사 배열이 아닌 객체(예: Set, Map)에는 직접 사용할 수 없습니다. 성능 관점에서 V8 TurboFan은 스프레드 구문을 특별하게 최적화하므로, apply보다 스프레드 연산자가 대부분 더 빠릅니다.

스택 오버플로 위험(Maximum Call Stack) apply로 매우 큰 배열을 전달하면 JavaScript 엔진의 콜 스택 크기 한계(arguments.length 제한, 엔진마다 다르나 V8은 약 65,536개)를 초과해 RangeError: Maximum call stack size exceeded가 발생할 수 있습니다. 이 경우 반복문과 call을 조합하거나 청크 단위 분할 처리를 사용해야 합니다.

bind 메서드 - 영구 바인딩 함수 생성

입문

bind는 call, apply와는 다르게 함수를 바로 실행하지 않아요. 대신 “이 함수는 앞으로 항상 이 객체를 주인으로 삼아서 실행해!”라고 약속이 새겨진 새 함수를 만들어서 돌려줍니다.

🔒 자물쇠처럼 잠그는 약속 bind는 마치 자물쇠처럼 this를 특정 객체에 꽁꽁 묶어두는 거예요. 한 번 bind로 묶으면 나중에 어떤 방식으로 불러도, 심지어 다른 곳에 전달해도 this는 절대 바뀌지 않아요.

📬 나중에 쓸 편지봉투 준비 call이 전화라면, bind는 편지예요. 편지를 미리 써서 봉투에 담아두고 나중에 보내는 것처럼, bind는 나중에 실행할 함수를 미리 this와 함께 준비해두는 방식이에요.

⏰ 나중에 실행되는 상황에서 필수 버튼을 클릭했을 때 실행되는 기능이나, 시간이 지난 후 자동으로 실행되는 기능을 만들 때 this가 엉뚱한 것을 가리키는 문제가 생겨요. bind를 미리 써두면 나중에 실행될 때도 this가 정확한 객체를 가리킵니다.

🆕 새 함수가 반환된다 bind를 쓰면 원래 함수가 실행되는 게 아니라, this가 고정된 새로운 함수가 만들어져요. 원래 함수는 그대로 남아있고, 새로운 친구가 생기는 셈이에요.

중급

bind 메서드는 call, apply와 달리 함수를 즉시 실행하지 않고, this와 선택적으로 초기 인수가 고정된 새 함수를 반환합니다. 반환된 함수는 이후 어떤 방식(일반 호출, 메서드 호출, 다른 call/apply)으로 호출되어도 this가 변경되지 않습니다.

Function.prototype.bind(thisArg, ...partialArgs)

bind는 이벤트 핸들러나 비동기 콜백처럼 실행 시점이 지연되는 상황에서 this를 안전하게 고정할 때 핵심적으로 사용됩니다.

const timer = {
  count: 0,
  start() {
    // 1초 후 실행될 콜백에서 this가 timer를 가리키도록 bind
    setInterval(this.tick.bind(this), 1000);
  },
  tick() {
    this.count++;
    console.log(this.count); // 1, 2, 3, ...
  }
};
timer.start();
class Button {
  constructor(label) {
    this.label = label;
    // bind하지 않으면 클릭 시 this가 undefined (strict mode)
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    console.log(`${this.label} 클릭됨`); // this.label이 안전하게 참조됨
  }
}

부분 적용(Partial Application) bindthis 고정 외에도 인수를 미리 부분적으로 채워두는 부분 적용(Partial Application) 패턴을 구현할 수 있습니다.

function multiply(a, b) {
  return a * b;
}

const double = multiply.bind(null, 2); // a=2 고정
console.log(double(5));  // 10
console.log(double(10)); // 20

심화

bind는 ECMAScript 명세 Section 20.2.3.2 Function.prototype.bind(thisArg, ...args)에 정의되며, 호출 시 내부 추상 연산 BoundFunctionCreate를 통해 새로운 바운드 함수 이그조틱 객체(Bound Function Exotic Object)를 생성합니다.

바운드 함수 이그조틱 객체(Bound Function Exotic Object) ECMAScript 명세 10.4.1절에 따르면 바운드 함수는 일반 함수와 달리 세 가지 내부 슬롯을 가집니다:

  • [[BoundTargetFunction]]: 원본 함수 참조
  • [[BoundThis]]: 고정된 this
  • [[BoundArguments]]: 부분 적용된 인수 목록

바운드 함수의 [[Call]]이 실행되면 원본 함수의 [[Call]][[BoundThis]][[BoundArguments]] + 새 인수를 합산한 목록으로 위임(delegate)합니다. 이 체이닝 구조로 인해 bind를 여러 번 호출해도 [[BoundThis]]는 최초 bind에서 지정한 값으로 고정됩니다.

화살표 함수와의 근본적 차이 화살표 함수는 [[Call]] 내부 메서드에서 OrdinaryCallEvaluateBody 수행 시 렉시컬 환경(Lexical Environment)의 this 값을 그대로 캡처합니다. 이 값은 생성 시점에 정해지므로 bind로 덮어쓸 수 없습니다. 명세상 화살표 함수 환경 레코드에는 [[ThisValue]] 슬롯이 없으며, ResolveThisBinding()은 외부 환경으로 탐색을 위임합니다. 따라서 arrowFn.bind(obj)()를 호출해도 bind는 무시되고 렉시컬 this가 유지됩니다.

메모리 비용과 최적화 고려 bind는 호출 시마다 새 함수 객체를 할당합니다. React 클래스 컴포넌트 render 메서드 안에서 bind를 반복 호출하면 렌더링마다 새 함수가 생성되어 자식 컴포넌트의 불필요한 리렌더링을 유발합니다. 이를 방지하기 위해 생성자에서 한 번만 바인딩하거나, 클래스 필드 + 화살표 함수 패턴을 사용합니다. V8 TurboFan은 바운드 함수 호출을 최적화하지만, 과도한 클로저 체인이 형성되면 히든 클래스(Hidden Class) 안정성이 저하될 수 있습니다.

화살표 함수와 명시적 바인딩의 한계

입문

화살표 함수는 call, apply, bind를 모두 무시해요. 화살표 함수는 태어날 때부터 자기 주변의 this를 기억하고, 나중에 아무리 바꾸려 해도 절대 바뀌지 않아요.

🧲 태어날 때부터 붙어있는 자석 일반 함수는 this가 “누가 불렀느냐”에 따라 달라지는 변덕쟁이예요. 화살표 함수는 달라요. 만들어질 때 주변을 딱 붙잡아서, 이후로는 절대 안 바뀌는 자석 같은 존재예요.

🚫 call, apply, bind가 통하지 않아요 call, apply, bind로 “이 객체를 주인으로 해!”라고 말해도 화살표 함수는 듣지 않아요. 이미 태어날 때 결정된 주인이 있기 때문에 나중에 바꿀 수 없어요.

🏠 태어난 집이 영원한 집 화살표 함수는 자기가 만들어진 장소의 this를 그대로 기억해요. 마치 “나는 항상 내가 태어난 집 사람이야”라고 하는 것처럼요. 어디에 가져다 놓아도 태어난 집의 this를 가리킵니다.

✅ 언제 쓰면 좋을까요? this가 절대 바뀌지 않아야 하는 상황에서는 화살표 함수가 오히려 좋아요. 예를 들어 어떤 객체 안에서 비동기 작업을 할 때, 화살표 함수를 쓰면 this가 자동으로 그 객체를 가리켜서 편리해요.

중급

화살표 함수는 this를 바인딩하지 않고, 선언된 위치의 렉시컬 스코프(lexical scope)에서 this를 캡처합니다. 이 값은 생성 시점에 결정되며 이후 변경이 불가능합니다. 따라서 call, apply, bind를 화살표 함수에 적용해도 this는 바뀌지 않습니다.

const obj = { name: '홍길동' };

const arrowFn = () => {
  console.log(this); // 전역(또는 undefined) - 선언 시 this 캡처
};

arrowFn.call(obj);   // 여전히 전역 (obj로 바뀌지 않음)
arrowFn.apply(obj);  // 여전히 전역
const bound = arrowFn.bind(obj);
bound();             // 여전히 전역
const counter = {
  count: 0,
  start() {
    // 화살표 함수: this는 start()가 실행되는 시점의 this (= counter)
    setInterval(() => {
      this.count++;
      console.log(this.count); // 정상 동작: counter.count 증가
    }, 1000);
  }
};
counter.start();

화살표 함수의 렉시컬 this 특성은 bind 없이도 콜백 내부의 this를 안전하게 유지하는 현대적 대안입니다. 단, 메서드로 직접 사용하는 경우에는 주의가 필요합니다.

심화

화살표 함수는 ECMAScript 명세 14.2절에 정의된 ArrowFunction으로, 일반 함수와 달리 [[ThisValue]] 내부 슬롯을 환경 레코드(Environment Record)에 보유하지 않습니다. 대신 생성 시점에 외부 렉시컬 환경의 thisOrdinaryFunctionCreate 과정에서 [[ThisMode]]lexical로 설정해 캡처합니다.

[[ThisMode]]와 ResolveThisBinding 분기 ECMAScript 명세 10.2.1.1절 OrdinaryCallEvaluateBody는 함수 실행 시 [[ThisMode]]를 확인합니다:

  • lexical: 현재 렉시컬 환경에서 this를 탐색 (화살표 함수)
  • strict: thisArgument를 그대로 바인딩
  • global: thisArgumentundefined이면 전역 객체로 대체

화살표 함수는 lexical 모드이므로 [[Call]](thisArgument, ...)에서 thisArgument를 완전히 무시하고 외부 환경으로 탐색을 위임합니다. 이것이 bind가 화살표 함수에서 작동하지 않는 명세 수준의 근거입니다.

메서드에서의 화살표 함수 오용 패턴 객체 리터럴 내 메서드를 화살표 함수로 정의하면 this가 객체가 아닌 외부 스코프(보통 전역 또는 모듈 스코프)를 가리킵니다. 이는 직관적 예상과 다르기 때문에 ESLint의 no-invalid-this 규칙 및 TypeScript의 엄격한 this 타입 검사(strictBindCallApply, noImplicitThis)로 컴파일 타임에 감지할 수 있습니다.

클래스 필드(Class Field) 화살표 함수 패턴의 트레이드오프

class Foo {
  handleClick = () => { this.doSomething(); };
}

이 패턴은 명세상 클래스 필드가 인스턴스 초기화(Instance Field Initialization) 단계에서 평가되므로 this가 생성자 컨텍스트를 캡처합니다. 그러나 각 인스턴스마다 별도의 함수 객체가 생성되어 prototype 공유를 통한 메모리 절약이 불가능합니다. 반면 bind 방식도 동일하게 인스턴스마다 새 함수를 생성하므로 메모리 비용은 동등합니다. V8 기준으로 두 패턴의 실질적 성능 차이는 벤치마크에서 무시할 수준(< 0.1%)입니다.