Study JavaScript 0620 - 함수 바인딩

변승훈·2022년 6월 20일
0

Study JavaScript

목록 보기
32/43

함수 바인딩

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

이 문제를 어떻게 해결할까?

사라져버린 this

객체 메소드가 객체 내부가 아닌 다른 곳에 전달되어 호출되면 this가 사라진다.

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

let user = {
  firstName: "Hun",
  sayHi() {
    console.log(`Hello, ${this.firstName}!`);
  }
};

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

this.firstName이 "Hun"이 되어야 하는데, console에서는 undefined가 출력된다.

이렇게 된 이유는 setTimeout에 객체에서 분리된 함수인 user.sayHi가 전달되기 때문이다. 위 예시의 마지막 줄은 다음 코드와 같다.

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

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

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

1. 래퍼

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

let user = {
  firstName: "Hun",
  sayHi() {
    console.log(`Hello, ${this.firstName}!`);
  }
};

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

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

1.로 표시한 줄은 아래와 같이 변경할 수도 있다.

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

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

let user = {
  firstName: "Hun",
  sayHi() {
    console.log(`Hello, ${this.firstName}!`);
  }
};

setTimeout(() => user.sayHi(), 1000);

// 1초가 지나기 전에 user의 값이 바뀜
user = { sayHi() { alert("또 다른 사용자!"); } };

// setTimeout에 또 다른 사용자!

두 번째 방법 bind를 사용하면 이런 일이 발생하지 않는다.

2. bind

모든 함수는 this를 수정하게 해주는 내장 메서드 bind를 제공한다.

기본 문법은 다음과 같다.

// 더 복잡한 문법은 뒤에 나온다
let boundFunc = func.bind(context);

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

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

아래 funcUser에는 thisuser로 고정된 func이 할당된다.

let user = {
  firstName: "Hun"
};

function func() {
  console.log(this.firstName);
}

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

여기서 func.bind(user)는 func의 this를 user로 '바인딩한 변형’이라고 생각하면 된다.

인수는 원본 함수 func에 ‘그대로’ 전달된다.

let user = {
  firstName: "Hun"
};

function func(phrase) {
  console.log(phrase + ', ' + this.firstName);
}

// this를 user로 바인딩
let funcUser = func.bind(user);

funcUser("Hello"); // Hello, Hun (인수 "Hello"가 넘겨지고 this는 user로 고정)

이제 객체 메서드에 bind를 적용해보자!

let user = {
  firstName: "Hun",
  sayHi() {
    console.log(`Hello, ${this.firstName}!`);
  }
};

let sayHi = user.sayHi.bind(user); // (*)

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

setTimeout(sayHi, 1000); // Hello, Hun!

// 1초 이내에 user 값이 변화해도
// sayHi는 기존 값을 사용
user = {
  sayHi() { console.log("또 다른 사용자!"); }
};

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

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

let user = {
  firstName: "Hun",
  say(phrase) {
    console.log(`${phrase}, ${this.firstName}!`);
  }
};

let say = user.say.bind(user);

say("Hello"); // Hello, Hun (인수 "Hello"가 say로 전달)
say("Bye"); // Bye, Hun ("Bye"가 say로 전달)

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

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

_.bindAll(object, methodNames)로 대규모 바인딩을 할 수 있다.

3. 부분 적용

this 뿐만 아니라 인수도 바인딩이 가능하다. 인수 바인딩은 잘 쓰이진 않지만 가끔 유용할 때가 있다.

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

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

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

곱셈을 해주는 함수 mul(a, b)를 예시로 보자!

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

bind를 사용해 새로운 함수double을 만들어보자!

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

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

console.log( double(3) ); // = mul(2, 3) = 6
console.log( double(4) ); // = mul(2, 4) = 8
console.log( double(5) ); // = mul(2, 5) = 10

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

이런 방식을 부분 적용(partial application)이라고 부르는데, 부분 적용을 사용하면 기존 함수의 매개변수를 고정하여 새로운 함수를 만들 수 있다.

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

그런데 부분 함수는 왜 만드는 걸까?

가독성이 좋은 이름(double, triple)을 가진 독립 함수를 만들 수 있다는 이점과 bind를 사용해 첫 번째 인수를 고정할 수 있기 때문에 매번 인수를 전달할 필요도 없어지기에 만들어 사용한다.

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

4. 컨텍스트 없는 부분 적용

인수 일부는 고정하고 컨텍스트 this는 고정하고 싶지 않다면 어떻게 해야 할까?

네이티브 bind만으로는 컨텍스트를 생략하고 인수로 바로 뛰어넘지 못한다.

다행히도 인수만 바인딩해주는 헬퍼 함수 partial를 구현하는 건 쉽다.

function partial(func, ...argsBound) {
  return function(...args) { // (*)
    return func.call(this, ...argsBound, ...args);
  }
}

// 사용법:
let user = {
  firstName: "Hun",
  say(time, phrase) {
    console.log(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};

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

user.sayNow("Hello");
// 출력값 예시:

// [10:00] John: Hello!
partial(func[, arg1, arg2...])을 호출하면 래퍼((*))가 반환되며 래퍼를 호출하면 func이 다음과 같은 방식으로 동작한다.

  • 동일한 this를 받는다(user.sayNowuser를 대상으로 호출됩니다).
  • partial을 호출할 때 받은 인수("10:00")는 ...argsBound에 전달된다.
  • 래퍼에 전달된 인수("Hello")는 ...args가 된다.

전개 문법 이나 lodash 라이브러리의 _.partial을 사용하면 컨텍스트 없는 부분 적용을 직접 구현하지 않아도 된다.

profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글