자바스크립트 톺아보기 12

이지영·2025년 1월 23일

call/apply와 데코레이터, 포워딩

자바스크립트는 함수를 다룰 때 탁월한 유연성을 제공한다. 함수는 이곳저곳 정달될 수 있고 객체로도 사용될 수 있다. 이번 챕터에선 함수 간에 호출을 어떻게 포워딩 하는지 함수를 어떻게 데코레이팅 하는지에 대해 알아보겠다.

코드 변경 없이 캐싱 기능 추가하기

CPU를 많이 잡아먹지만 결과는 안정적인 함수 slow(x)가 있다고 가정해보자.
결과가 안정적이라는 말은 x가 같으면 호출 결과도 같다는 것을 의미한다.
slow(x)가 자주 호출된다면 결과를 어딘가에 저장(캐싱)해 재연산에 걸리는 시간을 줄이고 싶을 것이다.
아래 예시에서는 slow()안에 캐싱 관련 코드를 추가하는 대신, 래퍼 함수를 만들어 캐싱 기능을 추가할 에정이다.
곧 정리하겠지만 이렇게 래퍼 함수를 만들면 여러 가지 이점이 있다.

function slow(x) {
   // CPU 집약적인 작업
   alert(`slow${x} 츨 호출함`);
   return x;
}

function cachingDecorator(func) {
   let cache = new Map();
   
   return function(x) {
      if (cache.has(x)) {
         return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
      }   
      let result = func(x); // 그렇지 않은 경우엔 func를 호출하고,

      cache.set(x, result); // 그 결과를 캐싱(저장)합니다. 
      return result;
   }
}

cachingDecorator같이 인수로 받은 함수의 행동을 변경시켜주는 함수를 데코레이터라고 부른다.

모든 함수를 대상으로 cachingDecorator를 호출할 수 있는데 이때 반환되는 것은 캐싱 래퍼이다.
함수에 cachingDecorator을 적용하기만 하면 캐싱이 가능한 함수를 원하는 만큼 구현할 수 있기 때문이다.
데코레이터 함수는 아주 유용하게 사용된다.
캐싱 관련 코드를 함수 코드와 분리할 수 있기 때문에 함수의 코드가 간결해진다는 장점도 있다.

아래 그림에서 볼 수 있듯 cachingDecorator(func)를 호출하면 래퍼(wrapper), function(x)이 반환된다.
래퍼 function(x)는 func(x)의 호출 결과를 캐싱 로직으로 감싼다.

바깥 코드에서 봤을 때 함수 slow는 래퍼로 감싼 이전이나 이후나 동일한 일을 수행한다.
행동 양식에 캐싱 기능이 추가된 것 뿐이다.

slow본문을 수정하는 것보다 독립된 래퍼 함수를 사용할 때 생기는 이점을 정리하면 다음과 같다.

  • cahingDecorator를 재사용할 수 있다. 원하는 함수 어디에든 cachingDecorator를 적용할 수 있다.
  • 캐싱 로직이 분리되어 slow자체의 복잡성이 증가하지 않는다.
  • 필요하다면 여러 개의 데코레이터를 조합해서 사용할 수도 있다.(추가 데코레이터는 cachingDecorator뒤를 따른다)

func.call를 사용해 컨텍스트 지정하기

위에서 구현한 캐싱 데코레이터는 객체 메서드에 사용하기엔 적합하지 않다.
객체 메서드 worker.slow()는 데코레이터 적용 후 제대로 동작하지 않는다.

원인은 let result = func(x)이부분에서
래퍼가 기존함수 func(x)를 하면 this가 undefined가 되기 때문이다.

래퍼가 기존 메서드 호출 결과를 전달하려했지만 this의 컨텍스트가 사라졌기 때문에 에러가 발생하는 것이다.

먼저, this를 명시적으로 고정해 함수를 호출할 수 있게 해주는 특별한 내장 함수 메서드에 대해 알아보자.

문법은 func.call(context, arg1, arg2,...)
메서드를 호출하면 첫 번째 인수가 this, 이어지는 인수가 func의 인수가 된 후, func가 ㅗ출된다.

아래 함수와 메서드를 호출하면 거의 동일한 일이 발생한다.

func(1,2,3);
func.call(obj, 1,2,3)

둘 다 인수로 1,2,3을 받는다 유일한 차이점은 func.call에서는 this가 obj로 고정된다는 점이다.

다른 컨텍스트(다른 객체)하에 sayHi를 호출하는 예시를 살펴보자.
sayHi.call(user)를 호출하면 sayHi의 컨텍스트가 this=user로, sayHi.call(admin)을 호출하면 sayHi의 컨텍스트가 this = admin으로 설정된다.

function sayHi() {
   alert(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };

// call 을 사용해 원하는 객체가 this가 되도록 하자
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

아래 예시에선 call을 사용해 컨텍스트와 phrase에 원하는 값을 지정해 보았다.

function say(phrase) P{
   alert(this.name + ':' + phrase);
}
let user = { name: "John" };

// this엔 user가 고정되고, "Hello"는 메서드의 첫 번째 인수가 된다.
say.call( user, "Hello" )// John: Hello

래퍼 안에서 call을 사용해 컨텍스트를 원본 함수로 전달하면 에러가 발생하지 않는다.

let worker = {
   someMethod(){
      retunrn 1;
   },
   slow(x) {
      alert(`slow${x} 를 호출함`);
      return x * this.someMethod();
   }
};

fucntion cachingDecorator(func){
   let cache = new Map();
   return function(x) {
      if(cache.has(x)){
         return cache.get(x);
      }
      let result = func.call(this,x) // 이젠 this가 제대로 전달된다.
      cache.set(x,result);
      return result;
   }
}

worker.slow(cachingDecorator(worker.slow); // 캐싱 데코레이터 적용

alert(worker.slow(2)); //제대로 동작

이제 에러 없이 모든게 정상적으로 동작한다.
명확한 이해를 위해 this가 어떤 과정을 거쳐 전달되는지 자세히 살펴보자.
1. 데코레이터를 적용한 후에 worker.slow는 래퍼 function(x) {...}가 된다.
2. worker.slow(2)를 실행하면 래퍼는 2를 인수로 받고 this = worker가 된다.
3. 결과가 캐시되지 않은 상황이라면 func.call(this,x)에서 현재 this(=worker)와 인수(=2)를 원본 메서드에 전달한다.

여러 인수 전달하기

cachingDecorator를 좀 더 다채롭게 해보자.
지금 상태로는 인수가 하나뿐인 함수에만 cachingDecorator을 적용할 ㅅ 있따.

복수 인수를 가진 worker.slow를 캐싱하려면 어떻게 해야할지 생각해보자.

let worker = {
slow(min, max) {
return min + max; // CPU를 많이 쓰는 작업으로 가정
}
};

worker.slow = cachingDecoraotr(worker.slow);

지금까지는 인수가 x하나 뿐이었기 때문에 cache.set(x,result)로 결과를 저장하고
cache.get(x)으로 저장된 결과를 불러오기만 하면 됐다.
그런데 이제부턴 (min, max)같이 인수가 여러 개이고, 이 인수들을 넘겨 호출한 결과를 기억해야 한다.

해결 방법은 여러 가지이다.
1. 복수 키를 지원하는 맵과 유사한 자료 구조 구현하기
2. 중첩 맵을 사용하기.(max,result)쌍 저장은 cache.set(nim)으로, result는 cache.get(min).get(max)를 사용해 얻는다.
3. 두 값을 하나로 합치기. 맵의 키로 문자열 "min,max"를 사용한다.
여러 값을 하나로 합치는 코드는 해싱 함수에 구현해 유연성을 높인다.

세 번째 방법만으로 충분하기 때문에 이 방법으로 코드를 수정해보자.
여기에 더하여 func.call(this,x)를 func.call(this,...arguments)로 교체해
래퍼 함수로 감싼 함수가 호출될 때 복수 인수로 넘길 수 있도록 해보겠다.

let worker = {
   slow(min, max) {
      alert(`slow(${min},${max})을/를 호출함`);
       return min + max;
   }
}
function cachingDecorator(func, hash) {
   let cache = new Map();
   return function () {
      let key = hash(arguments);
      if(cache.has(key)) {
         return cache.get(key);
      }
      let result = func.call(this, ...arguments); // (**)

      cache.set(key,result);
      return result;
   }
}

function hash(args) {
   return args[0] + `,` + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert( worker.slow(3,5) ); // 제대로 동작한다.
alert("다시 호출: " + worker.slow(3,5)); // 동일한 결과 출력(캐시된 결과 출력)

이제 인수의 개수에 관계없이 래퍼가 잘 동작한다.
하지만 해시 함수가 두 개의 인수로만 동작한다. 이를 가능하게 해주는 방법은 아래서 정리하겠다.

개선 후 바뀐 것은 두 가지이다.

  • hash가 호출되면서 arguments를 사용한 단일 키가 만들어진다. 여기선 간달한 결합 함수로 인수(3,5)를 키 "3,5"로 바꿨는데 좀 더 복잡한 경우라면 또 다른 해싱 함수가 필요할 수 있다.

  • (**)로 표시한 줄에선 func.call(this, ...arguments)를 사용해 컨텍스트(this)와 래퍼가 가진 인수 전부(...arguments)를 기존 함수에 전달했다.

    func.apply

    그런데 여기서 func.call(this, ...arguments)대신 func.apply(this,arguments)를 사용해되된다.

func.apply(context,args)

apply는 func의 this를 context로 고정해주고, 유사 배열 객체인 args를 인수로 사용할 수 있게 해준다.

call과 apply의 문법적 차이는 call이 복수 인수를 따로따로 받는 대신 apply는 인수를 유사 배열 객체로 받는다는 점 뿐이다.

따라서 아래 코드 두 줄은 거의 같은 역할을 한다.

   func.call(context, ...args);
   func.apply(context, args);

그런데 약간의 차이가 있긴 하다.

  • 전개 구문 ...은 이터러블 args를 분해해 call에 전달할 수 있도록 해준다.
  • apply는 오직 유사 배열 형태의 args만 받는다.

이 차이만 빼면 두 메서드는 완전히 동일하게 동작한다. 인수가 이터러블 형태라면 call을, 유사 배열 형태라면 apply를 사용하면 된다.

배열같이 이터러블이면서 유사 배앨인 객체엔 둘 다를 사용할 수 있는데, 대부분의 자바스크립트 엔진은 내부에서 apply를 최적화 하기 때문에 apply를 사용하는 게 좀 더 빠르긴 하다.

이렇게 컨텍스트와 함꼐 인수 전체를 다른 함수에 전달하는 것을 콜 포워딩이라고 한다.

가장 간단한 형태의 콜 포워딩은 다음과 같다

let wrapper = function() {
   return func.apply(this, arguments);
};

이런 식으로 외부에서 wrapper를 호출하면, 기존 함수인 func를 호출하는 것과 명확하게 구분할 수 없다.

메서드 빌리기

위에서 구현한 해싱 함수를 개선해보자

function hash(args){
   return args[0] + ',' + args[1];
}

지금 상태에선 인수 두 개만 다룰 수 있다.
args의 요소 개수에 상관없이 요소들을 합칠 수 있으면 좋겠다.
가장 자연스러운 해결책은 배열 메서드 arr.join을 사용하는 것이다.

function hasn(args) {
   return args.join();
}

그런데 아쉽게도 이 방법은 동작하지 않는다. hash(arguments)를 호출할 때 인수로 넘겨주는 arguments는 진짜 배열이 아니고 이터러블 객체나 유사 배열 객체이기 때문이다.

배열이 아닌 것에 join을 호출하면 에러가 발생한다.

function hash() {
   alert(arguments.join()); Error: arguments.join is not a function
}
hash(1,2);

그런데 아래와 같은 방법을 사용하면 배열 메서드 join을 사용할 수 있다.

function hash() {
   alert( [].join.call(arguments) ); 
}
hash(1,2)

일반 배열에서 join메서드를 빌려오고([].join), [].join.call를 사용해 arguments를 컨텍스트로 고정한 후 join메서드를 호출하는 방법이다.

내부 알고리즘이 구현되어 있기 떄문에 어떤 유사 배열이던 this가 될 수 있다.
상당수의 메서드가 이런 관습을 따르고 있다.

데코레이터와 함수 프로퍼티

함수 또는 메서드를 데코레이터로 감싸 대체하는 것은 대체적으로 안전하다.
그런데 원본 함수에 func.calledCount등의 프로퍼티가 있으면 데코레이터를 적용한 함수에선
프로퍼티를 사용할 수 없으므로 안전하지 않는다.
함수에 프로퍼티가 있는 경우엔 데코레이터 사용에 주의해야한다.

위 예시에서 함수 slow에 프로퍼티가 있었다면 cachingDecorator(slow)호출 결과인
래퍼엔 프로퍼티가 없을 것이다.
즉, 데코레이터를 적용하면 원본 함수의 프로퍼티에 접근할 수 없으므로, 함수에 프로퍼티가 있는 경우에는 데코레이터 사용에 주의해야한다.

몇몇 데코레이터는 자신만의 프로퍼티를 갖기도 한다.
데코레이터 함수가 얼마나 많이 호출되었는지 세거나 호출시 얼마나 많은 시간이 소모되었는지 등의 정보를 래퍼의 프로퍼티에 저장할 수 있다

함수 프로퍼티에 접근할 수 있게 해주는 데코레이터를 만드는 방법도 있다.
그런데 이걸 구현하려면 Proxy라는 특별한 객체를 사용해 함수를 감싸야한다.

Summary

데코레이터는 함수를 감싸는 래퍼로 함수의 행동을 변화시킨다.
주요 작업은 여전히 함수에서 처리힌다.
데코레이터는 함수에 추가도니 기능 혹은 '상(aspect)'정도로 보면된다.
하나 혹은 여러 개의 데코레이터를 추가해도 함수의 코드는 변경되지 않는다.

cachingDecorator는 아래와 같은 메서드를 사용해 구현하였다.

  • func.call(context, arg1, arg2...): 주어진 컨텍스트오 ㅏ인수를 사용해 func를 호출한다.
  • func.apply(context,args) : this에 context가 할당되고 유사 배열 args가 인수로 전달되어 func가 호출된다.

콜 포워딩은 대게 apply를 사용해 구현한다.

let wrapper = function () {
   return original.apply(this,arguments);
}

특정 객체에서 메서드를 가져오고, 다른 객체를 컨텍스트로 고정한 후 함수를 호출(call)하는 형태인 메서드 빌리기에 대한 예제도 살펴보았다.
메서드 빌리기는 배열 메서드를 빌려서 이를 arguments에 적용할 때 흔히 사용된다.
나머지 매개변수와 배열을 함께 사용하면 유사한 기능을 구현할 수 있다.

하수 바인딩

setTimeout에 메세드를 전달할 때처럼, 객체 메서드를 콜백으로 전달할 때
this정보가 사라지는 문제가 생긴다.

사라진 'this'

앞서 다양한 예제를 통해 this정보가 사라지는 문제를 경험해보았다.
객체 메서드가 객체 내부가 아닌 다른 곳에 전달되어 호출되면 this가 사라진다.

setTimeout을 사용한 아래 예시에서 this가 어떻게 사라지는지 살펴보자

let suer = {
   firstName: "John",
   sayHi(){
      alert(`Hello, ${this.firstName}!`);
   }
};

setTimeout(user.sayHi,1000); // Hello, undefined!

this.firstName이 'John'이 되어야 하는데 얼럿창엔 undefined가 출력된다.
이렇게 된 이유는 setTimeout에 객체에서 분리된 함수인 user.sayHi가 전달되기 때문이다.

let f = user.sayHi;
setTimeout(f,1000); // user컨텍스트를 잃어버림

브라우저 환경에서 setTimeout메서드는 조금 특별한 방식으로 동작한다.
인수로 전달받은 함수를 호출할 때, this에 window를 할당한다.
(Node.js환경에서는 this가 타이머 객체가 되는데 여기선 중요하지 않음)
따라서 위 예시의 this.firstName은 window.firstName가 되는데
window객체엔 firstName이 없으므로 undefined가 출력된다.
다른 유사한 사례에서도 대부분 this는 undefined가 된다.

객체 메서드를 실제 메서드가 호출되는 곳(예시에선setTimeout스케줄러)으로 전달하는 것은 아주 흔하다.
이렇게 메서드를 전달할 때 컨텍스트도 제대로 유지하려면 어떻게 해야할까?

방법 1: 래퍼

가장 간단한 해결책은 래퍼 함수를 사용하는 것이다.

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Hello, John!
}, 1000);

위 예시가 의도한 대로 동작하는 이유는 외부 렉시컬 환경에서 user를 받아서 보통 때처럼
메서드를 호출했기 떄문이다.

또한 아래와 같이 바꿀 수 있다.

setTimeout(() => user.sayHi(), 1000); // Hello, John!

이렇게 코드를 작성하면 간결해져서 보기는 좋지만 약간의 취약성이 생긴다.
setTimeout이 트리거 되기 전에(1초가 지나기 전에) user가 변경되면 변경된 객체의 메서드를 호출하게된다.

방법 2: bind

모든 함수는 this를 수정하게 해주는 내장 메서드 bind를 제공한다.
기본 문법은 다음과 같다.
let boundFunc = func.bind(context);

func.bind(context)는 함수처럼 호출 가능한 특수 객체를 반환한다.
이 객체를 호출하면 this가 context로 고정된 함수 func가 반환된다.

따라서 boundFunc를 호출하면 this가 고정된 func를 호출하는 것과 동일한 효과를 본다.

아래 funcUser에는 this가 user로 고정된 func이 할당된다.

let user = {
   firstName: "John"
};

function func() {
   alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // John

여기서 func.bind(user)는 func의 this를 user로 바인딩한 변형이라고 생각하면 된다.
인수는 원본 함수 func에 그대로 전달된다.

객체 메서드에 bind를 적요해보자.

let user = {
   firstName: "John",
   sayHi() {
      alert(`Hello, ${this.firstName}!`);
   }
}
let sayHi = user.sayHi.bind(user); (*)

// 이제 객체 없이도 객체 메서드를 호출할 수 있다.
sayHi(); // Hello, John!

setTimeou(sayHi, 1000); //  Hello, John!

// 1초 이내에 user값이 변화해도
// sayHi는 기존 값을 사용한다.

user = {
   sayHi() {alert("또 다른 사용자!")}
}

(*)로 표시한 줄에서 메서드 user.sayHi를 가져오고, 메서드에 suer를 바인딩한다.
sayHi는 이제 묶인(bound)함수가 되어 단독으로 호출할 수 있고 setTimeout에 전달하여 호출할 수도 있다. 어떤 방식이던 컨텍스트는 원하는대로 고정된다.

아래 예시를 실행하면 인수는 그대로 전달되고 bind에 의해 this만 고정된다.

let uer = {
   fistName: "John",
   say(phrase) {
      alert(`${phrase}, ${this.firstName}!`);
   }
}
let say = user.say.bind(user);

say("Hello"); Hello, John
say("Bye");  Bye, John

bindAll로 메서드 전체 바인딩하기

객체에 복수의 메서드가 있고 이 메서드 전체를 전달하려 할 땐, 반복문을 사용해 메서드를 바인딩할 수 있다.

for(let key in user) {
   if(typeof user[key] == 'function'){
      user[key] = user[key].bind(user);
   }
}

부분 적용

지금까진 this바인딩에 대해서만 이야기해보았다.
this뿐만 아니라 인수도 바인딩이 가능하다.
인수 바인딩은 잘 쓰이진 않지만 가끔 유용할 때가 있따.

bind의 전체 문법은 다음과 같다.

let bound = func.bind(context,[arg1],[arg2],...);

bind는 컨테스트를 this로 고정하는 것 뿐만 아니라 함수의 인수도 고정해준다.

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

let double = mul.bind(null, 2);

alert(double(3));  // 6
alert(double(4)); // 8

mul.bind(null,2)를 호출하면 새로운 함수 double이 만들어진다.
double엔 컨텍스트가 null, 첫 번째 인수는 2인 mul의 호출 결과가 전달된다.
추가 인수는 그대로 전달된다.

이러한 방식을 부분 적용이라고 부른다.
부분 적용을 사용하면 기존 함수의 매개변수를 고정하여 새로운 함수를 만들 수 있따.

위 예시에선 this를 사용하지 않았다는 점에 주목
bind엔 컨텍스트를 항상 넘겨줘야 하므로 null을 사용했다.

부본 적용을 사용해 3을 곱해주는 함수 triple을 만들어 보자

function mul (a,b) {
   return a*b;
}
let triple = mu.bind(null,3);
alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12

그런데 부분 함수는 왜 만드는걸까?
가독성이 좋은 이름( double, triple ) 을 가진 독립 함수를 만들 수 있다는 이점 떄문이다.
게다가 bind를 사용해 첫 번째 인수를 고정할 수 있기 때문에 매번 인수를 전달할 필요도 없어진다.

이 외에도 부분 적용은 매우 포괄적인 함수를 기반으로 덜 포괄적인 변형 함수를 만들 수 있다는 점에서 유용하다.

함수 send(from, to, text)가 있다고 가정해보자
객체 user안에서 부분 적용을 활용하면 전송 주체가 현재 사용자인 함수 sendTo(to, text)를 구현할 수 있다.

컨텍스트 없는 부분 적용

인수 일부는 고정하고 컨텍스트 this는 고정하고 싶지 않다면?

네이티브 bind만으로는 컨텍스트를 생략하고 인수로 바로 뛰어넘지 못한다.
다행히도 인수만 바인딩해주는 헬퍼함수 partial를 구현하는 건 쉽다.

function partial(func, ...argsBound){
   return function (...args) {
      return func.call(this, ...argsBound, ...args);
   }
}
// 사용법
let user = {
   firstName : "John",
   say(time, phrase) {
      alert(`[${time}] ${this.firstName}: ${phrase}!`);
   }
}

// 시간을 고정한 부분 메서드를 추가
user.sayNow = partial(user.say, new Date().getHours());

user.sayNow("Hello"); // [10] John: Hello!

0개의 댓글