콜백 함수

모든 내용은 책을 기반으로 작성되었습니다.

콜백 함수는 다른 코드의 인자로 넘겨주는 함수다. 콜백 함수를 넘겨받은 코드는 콜백 함수를 필요에 따라 적절한 시점에 실행할 것이다.

콜백 함수는 제어권과 관련이 깊다.

콜백 함수는 다른 코드(함수 또는 메서드)에게 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수다.

콜백 함수를 위임받은 코드는 자체적으로 내부 로직에 의해 콜백 함수를 적절한 시점에 실행할 것이다.

이전 글에서 '콜백함수도 함수이기 때문에 기존적으로는 this가 전역객체를 참조하지만, 제어권을 넘겨받은 코드에서 콜백 함수에 별도로 this가 될 대상을 지정한 경우에는 그 대상을 참조하게 된다'고 했다.

제어권

호출 시점

var count = 0;
var cdFunc = function () {
  console.log(count);
  if(++count > 4) clearInterval(timer);
}

var timer = setInterval(cdFunc, 300)

이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수 호출 시점에 대한 제어권을 가진다.

인자

map 메서드를 호출해서 원하는 배열을 얻으려면 map 메서드에 정의된 규칙에 따라 함수를 작성해야한다.

콜백 함수를 호출하는 주체가 사용자가 아닌 map 메서드이므로 map 메서드가 콜백 함수를 호출할 때 인자에 어떤 순서로 넘길 것인지가 전적으로 map 메서드에게 달렸다.

this

this에 다른 값이 담기는 이유를 정확히 알 수 있다. 바로 제어권을 넘겨받을 코드에서 call/apply 메서드의 첫 번째 이자에 콜백 함수 내부에서의 this가 될 대상을 명시적으로 바인딩하기 때문이다.

setTimeout(function () { console.log(this) }, 300); // window

[1,2,3,4,5].forEach(function (x) {
  console.log(this)
}) //  window

document.body.innerHTML += '<button id="a">클릭</button>';
document.body.querySelectore('#a').addEventListener('click', function () {
  console.log(this, e); // <button id="a">클릭</button>, MouseEvent
});

각각 콜백 함수 내에서의 this를 살펴보면, 우선 첫 번째 setTimeout은 내부에서 콜백 함수를 호출할 때 call 메서드의 첫 번째 인자에 전역객체를 넘기기 때문에 콜백 함수 내부에서의 this가 전역객체를 가리킨다. 두 번째 forEach는 별도의 인자로 this를 받는 경우에 해당하지만 별도의 인자로 this를 넘겨주지 않았기 때문에 전역객체를 가리키게 된다. 세 번째 addEventListener는 내부에서 콜백 함수를 호출할 때 call 메서드의 첫 번째 인자에 addEventListener 메서드의 this를 그대로 넘기도록 정의돼 있기 때문에 콜백 함수 내부에서의 this가 addEventListener를 호출한 주체인 HTML 엘리먼트를 가리키게 된다.

콜백 함수는 함수다.

콜백 함수로 어떤 객체의 메서드를 전달하더라도 그 메서드는 메서드가 아닌 함수로서 호출된다.

콜백 함수 내부의 this에 다른 값 바인딩하기

객체의 메서드를 콜백 함수로 전달하면 해당 객체를 this로 바라볼 수 없게 된다는 것은 위에서 알게 되었다. 그럼에도 콜백 함수 내부에서 this가 객체를 바라보게 하고 싶으면 어떻게 해야할까? 별도의 인자로 this를 받는 함수의 경우에는 여기에 원하는 값을 넘겨주면 되지만, 그렇지 않은 경우에는 this의 제어권도 넘겨주게 되므로 사용자가 임의로 값을 바꿀 수 없다.

그래서 전통적으로는 this를 다른 변수에 담아 콜백 함수로 활용할 함수에서는 this 대신 그 변수를 사용하게 하고, 이에 클로저로 만드는 방식이 많이 사용되었다.

다행히 이제는 전통적인 방식의 아쉬움을 보완하는 방법으로 bind 메서드가 있다.

콜백 지옥과 비동기 제어

콜백 지옥은 콜백 함수를 기명함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상으로, 자바스크립트에서 흔히 발생하는 문제이다. 주로 이벤트 처리나 서버 통신과 같이 비동기적인 작업을 수행하기 위해 이런 형태가 자주 등장하곤 하는데, 가독성이 떨어질뿐더러 코드를 수정하기도 어렵다.

동기적인 코드는 현재 실행중인 코드가 완료된 후에야 다음 코드를 실행하는 방식이다. 반대로 비동기적인 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어간다. CPU의 계산에 의해 즉시 처리가 가능한 대부분의 코드는 동기적인 코드이다.

계산식이 복잡해서 CPU가 계산하는 데 시간이 많이 필요한 경우라 하더라도 이는 동기적인 코드다. 반면 사용자의 요청에 의해 특정 시간이 경과되기 전까지 어떤 함수의 실행을 보류한다거나 사용자의 직접적인 개입이 있을 때 비로소 어떤 함수를 실행하도록 대기한다거나, 웨브라우저 자체가 아닌 별도의 대상에 무언가를 요청하고 그에 대한 응답이 왔을 때 비로소 어떤 함수를 실행하도록 대기하는 등, 별도의 요청, 실행 대기, 보류 등과 관련된 코드는 비동적인 코드이다.

현대의 자바스크립트는 웹의 복잡도가 높아진 만큼 비동기적인 코드의 비중이 예전보다 훨씬 높아진 상황이다. 그와 동시에 콜백 지옥에 빠지기도 훨씬 쉬어진 셈이다.

콜백 지옥 예시를 보자.

setTimeout(function (name) {

  var coffeeList = name;
  console.log(coffeeList);

  setTimeout(function (name){
    coffeeList += ', ' + name;
    console.log(coffeeList);

    setTimeout(function (name){
      coffeeList += ', ' + name;
      console.log(coffeeList);

    }, 500, '카페라떼');
  }, 500, '카페모카');
}, 500, '아메리카노')

가독성 문제와 어색함을 동시에 해경하는 가장 간단한 방법은 익명의 콜백 함수를 모두 기명함수로 전환하는 것이다.

var coffeeList = '';

var addEspresso = function (name) {
  coffeeList = name;
  console.log(coffeeList);
  setTimeout(addAmericano, 500, '아메리카노')
}

var addAmericano = function (name) {
  coffeeList += ', ' + name;
  console.log(coffeeList);
  setTimeout(addMocha, 500, '카페모카')
}

var addMocha = function (name) {
  coffeeList += ', ' + name;
  console.log(coffeeList);
  setTimeout(addLatte, 500, '카페라떼')
}

var addLatte = function (name) {
  coffeeList += ', ' + name;
  console.log(coffeeList);
}

ES6에서는 Promise, Generator 등이 도입됐고, ES2017에서는 async/await가 도입되었다. 이들을 이용해 위코드를 수정한 내용을 각각 간략하게 알아보자.

new Promise(function (resolve) {
  setTimeout(function () {
    var name = '에스프레소';
    console.log(name);
    resolve(name)
  }, 500)
}).then(function(prevName){
  return new Promise(function (resolve) {
    setTimeout(function () {
      var name = prevName + ', 아메리카노';
      console.log(name);
      resolve(name)
    }, 500)
  })
}).then(function(prevName){
  return new Promise(function (resolve) {
    setTimeout(function () {
      var name = prevName + ', 카페모카';
      console.log(name);
      resolve(name)
    }, 500)
  })
}).then(function(prevName){
  return new Promise(function (resolve) {
    setTimeout(function () {
      var name = prevName + ', 카페라떼';
      console.log(name);
      resolve(name)
    }, 500)
  })
})

한편 ES2017에서는 가독성이 뛰어나면서 작성법도 간단한 새로운 기능이 추가됐는데, 바로 async/await이다. 비동기 작업을 수행하고자 하는 함수 앞에 async를 표기하고, 함수 내부에서 실질적인 비동기 작업이 필요한 위치마다 await를 쵸기하는 것만으로 뒤의 내용을 Promise로 자동 전환하고, 해당 내용이 resolve된 이유에야 다음으로 진행한다. 즉 Promise의 then과 흡사한 효과를 얻을 수 있다.

정리

  • 콜백 함수는 다른 코드에 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수다.
  • 제어권을 넘겨받은 코드는 다음과 같은 제어권을 가진다.
    • 콜백 함수를 호추하는 시점을 스스로 판단해서 실행한다.
    • 콜백 함수를 호출할 때 인자로 넘겨줄 값들 및 그 순서가 정해져 있다. 이 순서를 따르지 않고 코드를 작성하면 이상한 결과가 나온다.
    • 콜백 함수의 this가 무엇을 바라보도록 할지가 정해져 있는 경우도 있다. 정하지 않은 경우에는 전역객체를 바라본다. 사용자 임의로 this를 바꾸고 싶은 경우 bind 메서드를 사용하면 된다.
  • 어떤 함수에 인자로 메서드를 전달하더라도 이는 결국 함수로서 실행한다(this가 전역이라는 걸 알려주려고 하는 듯)
  • 비동기 제어를 위해 콜백 함수를 사용하다 보면 콜백 지옥에 빠지기 쉽다. 최근의 ECMAScript에는 Promise, Generator, async/await 등 콜백 지옥에서 벗어날 수 있는 훌륭한 방법들이 있다.