[모던 자바스크립트 튜토리얼] 6.9 call/apply와 데코레이터, 포워딩

개발견 배도르만·2023년 5월 4일
0
post-thumbnail

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

포워딩(forwarding)데코레이팅(decorating)에 대해서 알아보자.

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

연산 속도가 느리지만 결과는 안정적인 함수 slow(x)를 캐싱하는 예시를 살펴보자

function slow(x) {
  // CPU 집약적인 작업이 여기에 올 수 있습니다.
  alert(`slow(${x})을/를 호출함`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) {    // cache에 해당 키가 있으면
      return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
    }

    let result = func(x);  // 그렇지 않은 경우엔 func를 호출하고,

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

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1)이 저장되었습니다.
alert( "다시 호출: " + slow(1) ); // 동일한 결과

alert( slow(2) ); // slow(2)가 저장되었습니다.
alert( "다시 호출: " + slow(2) ); // 윗줄과 동일한 결과

slow() 안에서 캐싱을 처리하지 않고, 래퍼 함수*를 만들어 캐싱 기능을 구현하였다.

*래퍼 함수 : 다른 함수를 감싸거나 래핑하여 추가적인 동작을 수행하거나 수정하는 함수

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

모든 함수를 대상으로 cachingDecorator를 호출 할 수 있는데, 이때 반환되는 것은 캐싱 래퍼*이다. 함수에 cachingDecorator를 적용하기만 하면 캐싱이 가능한 함수를 원하는 만큼 구현할 수 있기 때문에 데코레이터 함수는 아주 유용하게 사용된다.

*캐싱 래퍼 : 함수 호출의 결과를 캐싱하여 이후 동일한 인자로 호출될 때 캐시된 결과를 반환하는 래퍼 함수

캐싱 관련 코드를 함수 코드와 분리할 수 있기 때문에 함수의 코드가 간결해진다는 장점도 있다.

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


slow는 동일한 기능을 하지만 캐싱 기능이 추가된 것이다.

이와 같은 방식으로 독립된 래퍼 함수 cachingDecorator를 사용할 때의 이점은 다음과 같다.

  • cachingDecorator 재사용으로 인해 원하는 함수마다 캐싱 기능 추가 가능
  • 캐싱 로직이 분리되어 slow복잡도 감소
  • 필요에 따라 여러 데코레이터 조합 가능

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

위에서 구현한 캐싱 데코레이터는 객체 메서드에 사용하기엔 적합하지 않습니다.

다음 예시의 객체 메서드 worker.slow()는 데코레이터 적용 후 제대로 동작하지 않는다.

// worker.slow에 캐싱 기능을 추가해봅시다.
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // CPU 집약적인 작업이라 가정
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

// 이전과 동일한 코드
function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // 기존 메서드는 잘 동작합니다.

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

alert( worker.slow(2) ); // 에러 발생!, Error: Cannot read property 'someMethod' of undefined

(*)로 표시한 줄에서 this.someMethod 접근에 실패했기 때문에 에러가 발생했다.

원인은 (**)로 표시한 줄에서 래퍼가 기존 함수 func(x)를 호출하면 thisundefined가 되기 때문이다.
(this에는 this를 호출한 Object가 바인딩되는데, Object가 아닌 함수에서 호출 시 전역객체가 바인딩되며, 전역객체에서 someMethod를 찾으려고 하니 undefined인 것)

아래 코드를 실행해도 비슷한 증상이 나타난다.

let func = worker.slow;
func(2);

래퍼가 기존 메서드 호출 결과를 전달하려 했지만 this의 컨텍스트가 사라졌기 때문에 에러가 발생하는 것이다.
(this에는 this를 호출한 Object가 바인딩된다. Object가 아닌 function에서 this를 호출하면 Object를 찾지 못해 에러가 발생한다. 이 때 기본적으로는 전역 객체가 바인딩되고, 엄격 모드로 실행 시 undefined가 바인딩된다.)

그렇다면 에러가 발생하지 않으려면 코드를 어떻게 작성해야 할까?
=> this를 명시적으로 고정해주는 특별한 내장 함수 메서드 func.call(context, …args)을 사용할 수 있다.

func.call(context, arg1, arg2, ...)

해당 메서드를 호출하면 메서드의 첫 번째 인수가 this, 이어지는 인수가 func의 인수가 된 후, func이 호출된다.

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

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

유일한 차이점은 func.call에선 thisobj로 고정된다는 점이다.

다른 컨텍스트(다른 객체) 하에 sayHi를 호출하는 예시:

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) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

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

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

let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

function 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) ); // 제대로 동작합니다.
alert( worker.slow(2) ); // 제대로 동작합니다. 다만, 원본 함수가 호출되지 않고 캐시 된 값이 출력됩니다.

여러 인수 전달하기

cachingDecorator에 여러 함수를 전달해 보자.

복수 인수를 가진 메서드, worker.slow를 캐싱하는 것이다.

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

// 동일한 인수를 전달했을 때 호출 결과를 기억할 수 있어야 합니다.
worker.slow = cachingDecorator(worker.slow);

cachingDecorator에서는 Map 객체 cacheset, get 메서드를 사용하여 캐싱을 구현했지만, Map은 단일 키만 받기 때문에 여러 인자를 받을 경우에는 다른 방법을 고려해 보아야 한다.

해결 방법은 여러 가지이다.

  1. 복수 키를 지원하는 과 유사한 자료 구조 구현하기(서드 파티 라이브러리 등을 사용해도 됨)
  2. 중첩 맵을 사용하기
    : (max, result) 쌍 저장은 cache.set(min)으로, result는 cache.get(min).get(max)을 사용해 얻는다.
  3. 두 값을 하나로 합치기
    : 의 키로 문자열 "min,max"를 사용한다. 여러 값을 하나로 합치는 코드는 해싱 함수(hashing function) 에 구현해 유연성을 높인다.

세 번째 방법만으로 충분하기 때문에 이 방법을 사용해 코드를 수정해 보자.

여기에 더하여 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를 사용한 단일 키가 만들어진다. 좀 더 복잡한 경우라면 또 다른 해싱 함수가 필요할 수 있다.
(**)로 표시한 줄에선 func.call(this, ...arguments)를 사용해 컨텍스트(this)와 래퍼가 가진 인수 전부(...arguments)를 기존 함수에 전달했다.

func.apply

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

내장 메서드 func.apply의 문법은 다음과 같다.

func.apply(context, args)

apply 또한 마찬가지로 functhiscontext로 고정해준다. 하지만 파라미터를 개별적으로 받는 call과 달리 유사 배열 객체args를 인자로 사용할 수 있게 해준다.

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

func.call(context, ...args); // 전개 문법을 사용해 인수가 담긴 배열을 전달하는 것과
func.apply(context, args);   // call을 사용하는 것은 동일합니다.

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

전개 문법 ...은 이터러블 args을 분해 해 call에 전달할 수 있도록 해준다.
...문법은 유사 배열에서만 사용할 수 있기에 apply는 오직 유사 배열 형태의 args만 받는다.
이 차이만 빼면 두 메서드는 완전히 동일하게 동작한다.
인수가 이터러블 형태라면 call을, 유사 배열 형태라면 apply를 사용하면 된다.

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

call, apply처럼 컨텍스트와 함께 인수 전체를 다른 함수에 전달하는 것콜 포워딩(call forwarding) 이라고 한다.

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

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

이런 식으로 외부에서 wrapper를 호출하면, 기존 함수인 func를 호출하는 것과 명확하게 구분할 수 없다(의도된 동작을 제외하면 동일하고 정확한 결과가 나온다).

메서드 빌리기

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

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

이번엔 두 개 이상의 요소를 가진 args에 대해 개수에 상관없이 요소들을 합치는 함수이다.

간단하게 arr.join을 사용하면 될 것이다.

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

그런데 아쉽게도 이 방법은 동작하지 않는다.
인자로 전달한 arguments는 진짜 배열이 아니고 이터러블 객체유사 배열 객체인데, join은 배열에서만 사용하는 문법이기 때문이다.

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

hash(1, 2);

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

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

hash(1, 2);

The trick is called method borrowing.

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

이게 가능한 이유는 arr.join(glue)의 내부 알고리즘이 아주 간단하기 때문이다.

스펙을 ‘그대로’ 차용해 설명해 보겠다.

  1. glue가 첫 번째 인수가 되도록 한다. 인수가 없으면 ","가 첫 번째 인수가 된다.
  2. result는 빈 문자열이 되도록 초기화한다.
  3. this[0]을 result에 덧붙인다.
  4. gluethis[1]result에 덧붙인다.
  5. gluethis[2]를 result에 덧붙인다.
  6. this.length개의 항목이 모두 추가될 때까지 이 일을 반복한다.
  7. result를 반환한다.

기존에 call을 사용했던 방식처럼 this를 받고 this[0], this[1] 등이 합쳐진다. 이렇게 내부 알고리즘이 구현되어있기 때문에 어떤 유사 배열이든 this가 될 수 있다. 상당수의 메서드가 이런 관습을 따르고 있죠. thisarguments가 할당되더라도 잘 동작하는 이유가 여기에 있다.

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

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

=> 함수에 프로퍼티가 있는 경우엔 데코레이터 사용에 주의해야 한다.

위 예시에서 함수 slow에 프로퍼티가 있었다면 cachingDecorator(slow) 호출 결과인 래퍼엔 프로퍼티가 없을 것이다.

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

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

정리

  • 데코레이터 : 함수를 감싸는 래퍼로 함수의 행동을 변화시키는 함수. 함수에 추가된 '기능' 정도의 개념
  • cachingDecorator는 아래와 같은 메서드를 사용해 구현하였다.
    • func.call(context, arg1, arg2…)thiscontext로 고정, 여러 인자를 전달하여 func를 호출
    • func.apply(context, args)thiscontext로 고정, 유사 배열을 인자로 전달하여 func이 호출
    • 포워딩 : 위와 같이 컨텍스트와 인자를 다른 함수에 전달하는 것
    • 콜 포워딩은 대개 apply를 사용해 구현한다.
    • apply 사용 시 유사배열에 join을 사용하기 위해 배열을 빌려 join 메서드를 사용할 수 있다.
profile
네 발 개발 개

0개의 댓글