본문으로 건너뛰기
김신건의 로그

[Leetcode] 2804 Array Prototype ForEach

Leetcode 2804번 Array Prototype ForEach 풀이. callback 내부 this 를 context 로 바인딩하는 다섯 가지 방법 (call, apply, bind, 객체 메서드, 그리고 화살표 함수 함정) 을 비교한다.

· 📖 약 4분 · 1,804자/단어 #Leetcode #JavaScript #this

문제 요약

2804. Array Prototype ForEach

표준 Array.prototype.forEach 의 동작을 직접 구현하는 문제다. 두 인자를 받는다.

  • callback(value, index, array) 가 배열의 각 요소마다 호출된다.
  • 두 번째 인자 context 가 주어지면, callback 내부의 thiscontext 를 가리켜야 한다.

표준 메서드를 쓰지 말고 직접 구현하라는 제약이 붙는다.

Array.prototype.forEach = function (callback, context) {
  for (let i = 0; i < this.length; i++) {
    // 여기서 callback 을 호출하면서 this 를 context 로 바꾸어야 한다
  }
};

표면적으로는 단순한 반복문이다. 다만 “함수의 this 를 외부에서 지정한다” 는 요건이 functionthis 동적 바인딩, arrow function 의 lexical this, 그리고 call / apply / bind 까지 한 자리에 모이는 좋은 출발점이라 정리할 가치가 있다.

검증용 예시:

기대 동작
node
const arr = [1, 2, 3];

function callback(val) {
console.log(this.name, val);
}

arr.forEach(callback, { name: 'context' });
console
context 1
context 2
context 3

같은 결과를 만들어내는 다섯 가지 길을 차례로 본다.


1. call 사용 (정석)

구현

Array.prototype.forEach = function (callback, context) {
  for (let i = 0; i < this.length; i++) {
    callback.call(context, this[i], i, this);
  }
};

동작 원리

Function.prototype.call(thisArg, ...args)함수를 즉시 실행하면서 thisthisArg 로 지정 한다. 인자는 일반 호출처럼 콤마로 나열한다.

call 의 기본 동작
javascript
function hello() {
console.log(this.name);
}

hello.call({ name: 'Tom' });
결과
Tom

forEach 구현에 그대로 옮기면 매 반복마다 callback 을 호출할 때 thiscontext 로 묶인다. 표준 명세 Array.prototype.forEach 도 내부적으로 Call(callbackfn, T, ...) 형태, 즉 같은 방식으로 정의돼 있다.

장단점

  • 표준 동작과 가장 닮았다.
  • 반복문 안이 한 줄로 끝나 읽기 쉽다.
  • 매 반복마다 call 한 번씩의 호출 비용이 있지만 실측으로 드러나는 수준은 아니다.

면접/코딩 테스트에서 기대되는 답이 보통 이 형태다.


2. apply 사용

구현

Array.prototype.forEach = function (callback, context) {
  for (let i = 0; i < this.length; i++) {
    callback.apply(context, [this[i], i, this]);
  }
};

동작 원리

Function.prototype.apply(thisArg, argArray)call 과 동일하게 함수를 즉시 실행하면서 this 를 지정한다. 차이는 인자를 배열로 전달 한다는 것뿐이다.

apply 의 기본 동작
javascript
function hello(a, b) {
console.log(this.name, a, b);
}

hello.apply({ name: 'Tom' }, [1, 2]);
결과
Tom 1 2

장단점

  • 인자가 동적으로 정해지는 (이미 배열 형태로 들고 있는) 경우 편리하다.
  • 이 문제처럼 인자 개수가 고정 (3 개) 이라면 매 반복마다 임시 배열을 새로 만드는 비용 만 더해진다.

TIP

ES6 의 ...spread 이후로 apply 의 쓰임이 많이 줄었다. fn.apply(ctx, args)fn.call(ctx, ...args) 로 대체할 수 있기 때문이다.


3. bind 사용

구현

Array.prototype.forEach = function (callback, context) {
  const bound = callback.bind(context);
  for (let i = 0; i < this.length; i++) {
    bound(this[i], i, this);
  }
};

동작 원리

Function.prototype.bind(thisArg)함수를 실행하지 않는다. 대신 this 가 미리 묶인 새 함수 객체 를 반환한다.

bind 의 기본 동작
javascript
function hello() {
console.log(this.name);
}

const boundHello = hello.bind({ name: 'Tom' });
boundHello();
결과
Tom

장단점

  • 반복문이 그냥 bound(...) 가 되어 가장 깔끔해 보인다.
  • 매 반복마다 call 을 부르지 않아도 된다.
  • 대신 새 함수 객체를 한 번 생성 한다. 반복 호출이 많다면 절약이 되지만, 한 번 쓰고 버리는 경우에는 오히려 손해다.
  • 만약 bound 를 반복문 안에서 만들면 (callback.bind(context) 를 매 반복마다 호출) 가장 비싼 선택이 된다.

4. 객체 메서드 방식 (call 의 내부 구현)

구현

Array.prototype.forEach = function (callback, context) {
  const key = Symbol();
  context[key] = callback;
  for (let i = 0; i < this.length; i++) {
    context[key](this[i], i, this);
  }
  delete context[key];
};

동작 원리

JavaScript 에서 함수 호출 구문 obj.fn()this호출 직전에 . 좌측이 무엇이었는지 로 결정된다 (functionthis 동적 바인딩). 즉,

context.temp = callback;
context.temp(); // 이 호출 시점에 this === context

이렇게 임시로 객체의 메서드로 만든 뒤 호출하면 call 없이도 this 를 바꿀 수 있다.

이 방식은 사실 Function.prototype.call 을 직접 구현해야 하는 면접 문제의 표준 해법으로 자주 등장한다. Symbol() 로 고유 키를 만들어 기존 속성과의 충돌을 막고, 끝난 뒤 delete 로 흔적을 지운다.

장단점

  • this 바인딩의 메커니즘을 가장 노골적으로 보여준다.
  • 인자로 받은 context 객체를 직접 변형 한다. 호출 도중 throw 가 나면 delete 까지 도달하지 못해 키가 남는다 (try / finally 로 보완 필요).
  • frozen 객체 (Object.freeze), Proxy, getter/setter 가 걸린 객체에는 통하지 않는다.
  • 실무에서는 거의 쓰지 않는다. “원리를 설명할 때” 용도.

CAUTION

입력으로 받은 객체를 임의로 수정하는 것은 사이드 이펙트의 원천이다. 라이브러리/표준 메서드 구현에서는 피해야 할 패턴이다.


5. 화살표 함수 (Arrow Function) 주의사항

위 네 가지 방식 모두 한 가지 가정에 기대고 있다. callback 이 일반 함수일 것 이다.

arrow function 은 자체 this 를 가지지 않는다. 자신이 선언된 위치의 Lexical Environment 에 있는 this 를 그대로 본다. call / apply / bind 모두 화살표 함수의 this 를 바꿀 수 없다.

화살표 함수의 this 는 call 로도 안 바뀐다
javascript
const fn = () => {
console.log(this);
};

fn.call({ name: 'Tom' });
결과
(전역 객체 또는 상위 스코프의 this, strict 모듈에서는 undefined)

즉, 문제의 검증 케이스가 callback 으로 화살표 함수를 넘긴다면 어떤 구현을 써도 context 가 반영되지 않는다.

같은 lexical vs dynamic this 차이는 arrow function 의 setInterval 예시에서도 동일하게 드러난다.

IMPORTANT

LeetCode 의 채점 케이스도 이 차이를 노린다. (val, i, arr) => arr[i] = this 같은 화살표 콜백을 넘겼을 때 this 가 정의 시점의 모듈 스코프 (테스트 러너가 화살표 함수를 평가한 환경) 로 묶이는 것을 확인하는 식이다. 구현이 잘못된 게 아니라, 화살표 함수의 정의 그 자체가 그렇게 동작한다.

call / apply / bind 는 모두 일반 함수에만 의미가 있다. 이 사실을 모르고 forEach 의 callback 으로 화살표 함수를 쓰면 디버그하기 까다로운 버그가 된다.


각 방식 비교

방식즉시 실행this 지정함수 객체 생성입력 객체 변형
callOOXX
applyOOXX
bindXOO (한 번)X
객체 메서드OOXO (Symbol 키)

callapply 는 호출 시 비용 외에 부산물이 없다. bind 는 함수 객체 하나를 새로 만들지만 반복 호출 비용은 가장 적다. 객체 메서드 방식은 호출 비용 자체는 가장 적지만 입력 객체를 변형한다는 점에서 사이드 이펙트가 가장 크다.


결론, 무엇을 골라야 하는가

Array.prototype.forEach = function (callback, context) {
  for (let i = 0; i < this.length; i++) {
    callback.call(context, this[i], i, this);
  }
};

이 풀이가 가장 무난한 선택이다.

  1. 명세 Array.prototype.forEach 의 내부 알고리즘이 사실상 Call(callbackfn, thisArg, ...) 라, 그것과 가장 가깝다.
  2. 코드가 짧고 읽기 쉽다.
  3. 입력 객체를 변형하지 않는다.
  4. 새로운 함수 객체를 만들지 않는다.

bind 는 같은 callback 을 여러 곳에서 재사용할 때 의미가 있다. 객체 메서드 방식은 call 의 동작 원리를 묻는 면접에서 별도로 등장한다. 둘 다 이 문제의 정답으로는 과한 선택이다.

화살표 함수에 관한 함정은 forEach 만의 문제가 아니다. setTimeout 의 콜백, 이벤트 리스너, Promise.then 의 핸들러 등 외부에서 this 를 지정하려는 모든 API 와 화살표 함수는 어울리지 않는다. 이 부분의 메커니즘은 비동기와 타이밍, 콜백부터 async/await까지의 발전사 의 콜백 절에서도 같은 언어로 설명된다.

이 글의 용어 (3개)
[Javascript] Arrow Functionjavascript
정의 화살표 함수 (arrow function) 는 ES2015 (ES6) 에서 도입된 함수 표현식. 모양. 더 짧은 문법뿐 아니라 가 lexical 로 바인딩 되는 본질적 차이…
[Javascript] functionjavascript
정의 은 JavaScript 에서 함수를 만드는 가장 기본적인 키워드. 호이스팅, 동적 바인딩, 객체, 호출 등 과 구분되는 고유한 특성을 가진다. 함수가 값 으로 다뤄지는 의 …
[Javascript] Lexical Environmentjavascript
정의 Lexical Environment (어휘적 환경) 는 ECMAScript 명세 (§9.1) 가 정의한 내부 객체. 함수가 호출될 때마다 새로 만들어지며, 이 호출의 변수들…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기