9일 9월 2020

함수 바인딩

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

이번 챕터에선 이 문제를 어떻게 해결할지에 대해 알아보겠습니다.

사라진 ‘this’

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

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

let user = {
  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 메서드는 조금 특별한 방식으로 동작합니다. 인수로 전달받은 함수를 호출할 때, thiswindow를 할당합니다(Node.js 환경에선 this가 타이머 객체가 되는데, 여기선 중요하지 않으므로 넘어가겠습니다). 따라서 위 예시의 this.firstNamewindow.firstName가 되는데, window 객체엔 firstName이 없으므로 undefined가 출력됩니다. 다른 유사한 사례에서도 대부분 thisundefined가 됩니다.

객체 메서드를 실제 메서드가 호출되는 곳(예시에선 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가 변경되면, 변경된 객체의 메서드를 호출하게 됩니다.

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

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

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

// setTimeout에 또 다른 사용자!

두 번째 방법을 사용하면 이런 일이 발생하지 않습니다.

방법 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: "John"
};

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

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

여기서 func.bind(user)functhisuser로 '바인딩한 변형’이라고 생각하시면 됩니다.

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

let user = {
  firstName: "John"
};

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

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

funcUser("Hello"); // Hello, John (인수 "Hello"가 넘겨지고 this는 user로 고정됩니다.)

이제 객체 메서드에 bind를 적용해 봅시다.

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

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

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

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

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

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

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

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

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

say("Hello"); // Hello, John (인수 "Hello"가 say로 전달되었습니다.)
say("Bye"); // Bye, John ("Bye"가 say로 전달되었습니다.)
bindAll로 메서드 전체 바인딩하기

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

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

자바스크립트 라이브러리를 사용해도 대규모 바인딩을 할 수 있습니다. lodash 라이브러리의 _.bindAll(object, methodNames)이 그 예입니다.

부분 적용

지금까진 this 바인딩에 대해서만 이야기해보았습니다. 한 단계 더 나아가 봅시다.

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);

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

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

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

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

부분 적용을 사용해 3을 곱해주는 함수 triple을 만들어보겠습니다.

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

let triple = mul.bind(null, 3);

alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15

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

가독성이 좋은 이름(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() + ':' + 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을 사용하면 컨텍스트 없는 부분 적용을 직접 구현하지 않아도 됩니다.

요약

func.bind(context, ...args)thiscontext로 고정되고 인수도 고정된 함수 func을 반환합니다.

bind는 보통 객체 메서드의 this를 고정해 어딘가에 넘기고자 할 때 사용합니다. setTimeout에 넘길 때 같이 말이죠.

기존 함수의 인수 몇 개를 고정한 함수를 부분 적용(partially applied) 함수 또는 부분(partial) 함수라고 부릅니다.

부분 적용은 같은 인수를 여러 번 반복하고 싶지 않을 때 유용합니다. send(from, to)라는 함수가 있는데 from을 고정하고 싶다면 send(from, to)의 부분 함수를 구현해 사용하면 됩니다.

과제

중요도: 5

아래 코드를 실행하면 어떤 결과가 나올까요?

function f() {
  alert( this ); // ?
}

let user = {
  g: f.bind(null)
};

user.g();

정답: null

function f() {
  alert( this ); // null
}

let user = {
  g: f.bind(null)
};

user.g();

bind를 적용한 함수의 컨텍스트는 완전히 고정됩니다. 한번 고정되면 바꿀 방법이 없습니다.

따라서 user.g()를 실행했음에도 불구하고 기존 함수의 컨텍스트는 null이 되기 때문에 null이 출력됩니다.

중요도: 5

함수에 bind를 적용하고, 이어서 한 번 더 bind를 적용하면 this를 바꿀 수 있을까요?

아래 코드를 실행하면 어떤 결과가 나올까요?

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Ann" } );

f();

정답: John

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Pete"} );

f(); // John

f.bind(...)가 반환한 특수 객체인 묶인 함수(bound function)는 함수 생성 시점의 컨텍스트만 기억합니다. 인수가 제공되었다면 그 인수 또한 기억합니다.

한번 bind를 적용하면 bind를 사용해 컨텍스트를 다시 정의할 수 없습니다.

중요도: 5

함수 프로퍼티에 값을 하나 할당해봅시다. 이 함수에 bind 메서드를 적용하면 프로퍼티 값은 바뀔까요? 그렇다면 혹은 그렇지않다면 그 이유는 무엇일까요?

function sayHi() {
  alert( this.name );
}
sayHi.test = 5;

let bound = sayHi.bind({
  name: "John"
});

alert( bound.test ); // 얼럿 창엔 어떤 값이 출력될까요? 값이 나온 이유는 무엇일까요?

정답: undefined

bind를 적용하면 또 다른 객체가 반환됩니다. 새로운 객체엔 test 프로퍼티가 없으므로 undefined가 출력됩니다.

중요도: 5

아래 함수 askPassword()는 비밀번호를 먼저 확인하고 그 결과에 따라 user.loginOkuser.loginFail을 호출해야 합니다.

그런데 함수를 호출하면 에러가 발생합니다. 에러는 왜 발생했을까요?

에러가 발생하지 않도록 색칠된 줄을 고쳐보세요. 다른 줄은 바꾸지 않아야 합니다.

function askPassword(ok, fail) {
  let password = prompt("비밀번호를 입력해주세요.", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name}님이 로그인하였습니다.`);
  },

  loginFail() {
    alert(`${this.name}님이 로그인에 실패하였습니다.`);
  },

};

askPassword(user.loginOk, user.loginFail);

에러는 ask가 함수 loginOk, loginFail을 객체 없이 가지고 오기 때문에 발생합니다.

ask는 loginOk, loginFail을 호출할 때 this=undefined라고 자연스레 가정합니다.

bind 함수를 사용해 컨텍스트를 고정시켜 봅시다.

function askPassword(ok, fail) {
  let password = prompt("비밀번호를 입력해주세요.", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name}님이 로그인하였습니다.`);
  },

  loginFail() {
    alert(`${this.name}님이 로그인에 실패하였습니다.`);
  },

};

askPassword(user.loginOk.bind(user), user.loginFail.bind(user));

이제 잘 동작합니다.

이 외에도 다른 답이 있는데, 아래에서 확인 가능합니다.

//...
askPassword(() => user.loginOk(), () => user.loginFail());

이렇게 화살표 함수를 사용하는 방법 또한 대개 잘 동작하며 가독성도 좋습니다.

다만 이 방법은 askPassword가 호출됐으나 사용자가 프롬프트 대화상자에 값을 제출하고 () => user.loginOk()를 호출하기 user 변수가 바뀌는 등의 복잡한 상황에서는 오작동할 가능성이 있습니다.

중요도: 5

이 과제는 this 값이 undefined인 함수 고치기를 변형한 좀 더 복잡한 과제입니다.

여기서는 user 객체를 수정해 user가 loginOk, loginFail 대신에 오직 하나의 함수 user.login(true·false)만 가질 수 있게 해놓았습니다.

user.login(true)ok, user.login(false)fail을 호출하게 하려면 askPassword에 무엇을 넘겨줘야 할까요?

function askPassword(ok, fail) {
  let password = prompt("비밀번호를 입력해주세요.", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  login(result) {
    alert( this.name + (result ? ' 로그인 성공' : ' 로그인 실패') );
  }
};

askPassword(?, ?); // ?

색칠된 줄의 물음표 부분만 수정해야 합니다.

  1. 래퍼 함수나 화살표 함수를 사용하면 간결하게 원하는 기능을 구현할 수 있습니다.

    askPassword(() => user.login(true), () => user.login(false));

    이 방법을 사용하면 askPassword는 외부 변수에서 user를 가져오기 때문에 원하는 결과를 얻을 수 있습니다.

  2. 컨텍스트가 user이면서 올바른 첫 번째 인수가 있는 부분 적용 함수를 만들면 원하는 기능을 구현할 수 있습니다.

    askPassword(user.login.bind(user, true), user.login.bind(user, false));
튜토리얼 지도

댓글

댓글을 달기 전에 마우스를 올렸을 때 나타나는 글을 먼저 읽어주세요.
  • 추가 코멘트, 질문 및 답변을 자유롭게 남겨주세요. 개선해야 할 것이 있다면 댓글 대신 이슈를 만들어주세요.
  • 잘 이해되지 않는 부분은 구체적으로 언급해주세요.
  • 댓글에 한 줄짜리 코드를 삽입하고 싶다면 <code> 태그를, 여러 줄로 구성된 코드를 삽입하고 싶다면 <pre> 태그를 이용하세요. 10줄 이상의 코드는 plnkr, JSBin, codepen 등의 샌드박스를 사용하세요.