나 돌아갈래 - 콜백 함수

CallBack 함수란 이름 그대로 나중에 호출되는 함수를 말한다.

콜백함수라고 해서 그 자체로 특별한 선언이나 문법적 특징을 가지고 있지는 않다.

콜백함수도 일반적인 자바스크립트 함수일 뿐이다.

콜백 함수는 코드를 통해 명시적으로 호출하는 함수가 아니다. 개발자는 단지 함수를 동록하기만 하고, 어떤 이벤트가 발생했거나 특정 시점에 도달했을 때 시스템에서 호출하는 함수를 말한다.
즉 콜백함수는 콜백함수라는 유니크한 문법적 특징을 가지고 있는 것이 아니라, 호출방식에 의한 구분이다.

정의

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

어던 함수 x를 호출하면서 특정 조건일때 콜백함수 Y를 실행해서 알려달라는 요청을 함께 보내는 형태, x입장에서는 조건이 갖춰졌는지 판단하고 직접 Y를 호출하는 방식이다.

이처럼 콜백 함수는 다른 코드에게 인자로 넘겨주면서 제어권도 함께 위임한 함수다.


제어권

호출 시점

setInterval() 의 기본적인 구조는 다음과 같다.

var intervalID = scope.setInterval(func, delay[, param1, param2, ...]);

scope에는 Window 객체 또는 Worker의 인스턴스가 들어올 수 있다. 두 객체 모두 setInterval() 메서드를 제공한다. 일반적인 브라우저 환경에서는 window를 생략하고 함수처럼 사용할 수 있다. 매개변수로는 함수 func와 밀리초 단위 시간인 delay가 필수이고, 세 번째 매개변수부터는 함수에 넘겨줄 인자로, 선택적이다.

func로 넘겨준 함수는 매 delay마다 실행되며, 어떠한 값도 리턴하지 않는다. setInterval을 실행하면 반복적으로 실행되는 내용 자체를 특정할 수 있는 고유한 ID 값이 반환되는데, 이를 intervalID와 같은 변수에 담는 이유는 반복 실행되는 중간에 종료(clearInterval)할 수 있게하기 위해서이다.

var count = 0;
var cbFunc = function () {
  console.log(count);
  if (++count > 4) clearInterval(timer);
};
var timer = setInterval(cbFunc, 300);

위 예제를 실행하면 0.3초(300 밀리초)마다 0부터 4까지 출력한 뒤 실행을 종료한다. timer 변수에는 setInterval의 ID 값이 담긴다. setInterval의 첫 인자인 cbFunc가 두 번째 인자인 300 밀리초마다 자동으로 실랭된다. 콜백 함수 내부를 보면, count 값을 출력하고, count를 1만큼 증가시킨다음, 그 값이 4보다 크면 반복 실행을 종료한다.

코드를 실행하면 콘솔창에는 0.3초에 한 번씩 숫자가 0부터 1씩 증가하며 출력되다 4가 출력된 이후 종료됩니다. setinterval이라는 다른 코드의 인자로 cbFunc를 넘겨주자 제어권을 소유하게 된 setInterval이 스스로 판단해 함수를 실행했다. 이처럼, 특정 함수의 제어권을 넘겨 받은 코드는 콜백 함수 호출 시점에 대한 제어권을 가진다.


인자

다음은 map을 통해 알아보도록 하자. map 메서드는 어떤 방식으로 동작하는지 알아야한다. 우선 map은 대상이 되는 배열의 모든 요소를 꺼내 처음부터 끝까지 하나씩 순회하며 콜백 함수를 반복해서 호출한다. 그렇게 콜백함수의 영향을 받은 요소들을 모아 새로운 배열로 만들어 반환한다.

Array.prototype.map(callback[, thisArg])
callback: function(currentValue, index, array)

map 메서드는 첫 번쩨 인자로 callback 함수를 받고, 생략 가능한 두번째 인자로 콜백 함수 내부에서 this로 인식할 대상을 정할 수 있다. 만약 해당 인자를 생략할 경우에는 일반적인 함수와 마찬가지로 전역객체를 바인딩한다.

똑같은 이름의 코드를 인자 순서만 바꿔서 적었더니, 전혀 다른 두 결과가 나왔다. 사람은 단어의 뜻을 바탕으로 사고하지만, 컴퓨터는 어떤 단어를 사용하던, 정해진 순서에 따라 사고한다.

그렇기에 map 메서드를 호출해서 원하는 배열을 얻으려 기존에 정의된 규칙에 따라 함수를 작성해야 한다. 콜백 함수를 호출하는 주체가 사용자가 아닌 map 메서드 이므로 어떤 인자 값을 순서대로 넘길 것인지는 전적으로 해당 메서드에 달린 셈이다.

이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수를 호출할 때 인자에 어떤 값들을 어떤 순서로 넘길 것인지에 대한 제어권을 가진다.


this

콜백 함수도 함수이기 때문에 기본적으로는 this가 전역객체를 참조하지만, 제어권을 코드에서 콜백 함수에 별도로 this가 될 대상을 지정한 경우에는 해당 대상을 참조하게 된다

이를 이해하기 위해선 다음 예제를 참고해보자.

Array.prototype.map = function (callback, thisArg) {
  var mappedArr = [];
  for (var i = 0; i < this.length; i++) {
    var mappedValue = callback.call(thisArg || window, this[i], i, this);
    mappedArr[i] = mappedValue;
  }
  return mappedArr;
};

규현의 핵심은 call / apply 메서드에 있다. this에는 thisArg 값이 있을 경우에는 그 값을, 없을 경우에는 전역객체를 지정하고, 첫 번째 인자에는 메서드의 this가 배열을 가리킬 것이므로 배열의 i번째 요소 값을 두번째 인자에 i 값을 , 세번째 인자에는 배열 자체를 지정해 호출한다.

제어권을 넘겨받을 코드에서 this가 될 대상을 thisArg라고 명시적으로 바인딩하는 것을 볼 수 있다.

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

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

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

// <button id='a'>클릭</button>
// MouseEvent { ... }
  1. settimeout내부에서 함수를 호출할 땜 첫번째 인자가 전역 객체를 넘기기 때문에 this는 window를 뜻한다.
  2. forEach는 별도의 인자를 this로 받을 경우에 해당하지만, 전역객체를 가리키게 된다.
  3. 세번째 addEventListner은 메서드의 this를 그대로 넘기기때문에 html 엘리먼트를 가리키게 된다.

콜백 함수 == 함수

이는 콜백 함수로 어떤 객체의 메서드를 전달하더라도, 그 메서드는 메서드로서 호출되는 것이 아니라 함수로서 호출된다는 것을 의미한다.

어떤 함수의 인자에 객체의 메서드를 전달하더라도 이는 결국 메서드가 아닌 함수일 뿐이다.

var obj = {
  vals: [1, 2, 3],
  logValues: function (v, i) {
    console.log(this, v, i);
  },
};
obj.logValues(1, 2); // obj 1 2
[4, 5, 6].forEach(obj.logValues);

// Window 4 0
// Window 5 1
// Window 6 2

obj 객체의 logValues는 메서드로 정의됐고, obj.logValues(1, 2)는 메서드로서 호출되었다. 이 때 this는 obj를 가리킨다. 하지만 forEach의 콜백 함수로 호출된 obj.logValues는 obj를 this로 하는 메서드를 그대로 전달한 것이 아니다. obj.logValues가 가리키는 함수만 전달 한 것 이다.

즉, 메서드로 호출될 떄가 아닌 한 obj와의 직접적인 연관이 없어진다. 여기서 this를 별도로 지정해주지 않았으므로 this는 전역 객체에 바인딩 된다.

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

별도의 인자로 this를 받지 않는 함수에서 콜백 함수 내부의 this에 다른 값을 바인딩하는 방법이다. 전통적으로는 this를 다른 변수에 담아 콜백 함수로 활용할 함수에서는 this 대신 변수를 사용하게 하고, 이를 클로저로 만드는 방식을 주로 활용했다.

var obj1 = {
  name: "obj1",
  func: function () {
    var self = this;
    return function () {
      console.log(self.name);
    };
  },
};
var callback = obj1.func();
setTimeout(callback, 1000);
 // obj1

해당 코드를 실행할 경우, 객체 내부에 있는 함수 자체가 리턴된다. 리턴된 값은 callback이라는 변수에 그대로 담기게 되고, 1초뒤 setTimeout에 의해 실행된다. 이 방법은 실제로 사용되지도 않을뿐더러 사용성과 편의성에 있어서도 한참 뒤쳐진다.

그렇다면 함수 내부 this를 바인딩하는 것이 번거로운 일인걸까? 그렇진 않다. 다른 곳에서 재활용을 하는 용도로 많이 사용할 수 있다는데, 지금부터 더 편리한 방법에 대해 알아보도록 하자.

바로 bind 메서드를 활용면된다.

bind() 메소드가 호출되면 새로운 함수를 생성합니다. 받게되는 첫 인자의 value로는 this 키워드를 설정하고, 이어지는 인자들은 바인드된 함수의 인수에 제공됩니다.

var obj1 = {
  name: "obj1",
  func: function () {
    console.log(this.name);
  },
};
setTimeout(obj1.func.bind(obj1), 1000);

var obj2 = { name: "obj2" };
setTimeout(obj1.func.bind(obj2), 1000);

콜백 지옥

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

비동기적인 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어간다. CPU의 계산에 의해 즉시 처리가 가능한 대부분의 코드는, 시간이 많이 필요하더라도 동기적인 코드이다. 비동기적인 코드의 예시로는, 사용자의 요청에 의해 일정 시간 실행을 보류(setTimeout)하거나, 사용자의 직접적 개입이 있을 때 실행(addEventListener)하거나, 웹브라우저 자체가 아닌 별도의 대상에 무언가를 요청하고 그에 대한 응답이 왔을 때 실행하도록 대기(XMLHttpRequest)하는 등이다. 즉, 별도의 요청, 실행 대기, 보류 등과 관련된 코드는 비동기적인 코드이다.

현대의 자바스크립트는 웹의 복잡도가 높아진 만큼 비동기적인 코드의 비중이 훨씬 높아졌, 콜백 지옥에 빠지기도 훨씬 쉬워졌다.

다음은 간단한 콜백 지옥의 예시를 살펴보자.

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

            setTimeout(
              function (name) {
                coffeeList += ", " + name;
                console.log(coffeeList);
              },
              500,
              "카페라떼"
            );
          },
          500,
          "카페모카"
        );
      },
      500,
      "아메리카노"
    );
  },
  500,
  "에스프레소"
);

0.5 초마다 커피 목록을 수집학 출력한다. 각 콜백은 커피 이름을 전달하고 목록에 이름을 추가한다. 들여쓰기 수준이 과도하게 깊고, 값이 전달되는 순서가 아래에서 위로 향해서 어색함도 느껴진다.

이를 해결하기 위해선 기명함수로 전환하는 방법이 있다고 한다.

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

setTimeout(addEspresso, 500, "에스프레소");

해당 방식은 가독석을 높이고, 함수의 호출과 선언을 구분하며, 위에서 아래 순서대로 읽을 수 있다. 하지만 함수를 모두 변수에 할당해야한다. 이런 작업이 효율적으로 느껴지진 않는다. 이러한 비동기적인 작업을 동기적으로, 혹은 동기적인 것 처럼 보이게끔 처리해주는 장치로 ES6에선 promise,Generator등이 등장했고, ES2017dptjsms async,await가 도입됐다.

아래는 각 메서드를 활용한 예제를 볼 수 있다.

Promise

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

ES6dptj Promise를 이용한 방식이다. new 연산자와 함께 호출한 promise의 인자로 넘겨주는 콜백 함수는 호출할 때 바로 실해되지만 내부에 resolve 또는 reject 함수를 호출하는 구문이 있을 경우 둘 중하나가 실행되기 전까지는 다음 구문으로 넘어가지 않는다.
비동기 작업이 완료되 때 비로소 resolve 또는 reject를 호출하는 방법으로 비동기 작업의 동기적 표현이 가능하다


Generator

var addCoffee = function (prevName, name) {
  setTimeout(function () {
    coffeeMaker.next(prevName ? prevName + ", " + name : name);
  }, 500);
};
var coffeeGenerator = function* () {
  var espresso = yield addCoffee("", "에스프레소");
  console.log(espresso);
  var americano = yield addCoffee(espresso, "아메리카노");
  console.log(americano);
  var mocha = yield addCoffee(americano, "카페모카");
  console.log(mocha);
  var latte = yield addCoffee(mocha, "카페라떼");
  console.log(latte);
};
var coffeeMaker = coffeeGenerator();
coffeeMaker.next();

ES6의 Generator을 이용한 예제다. function*으로 시작하는 함수가 바로 Generator 함수이다. Generator 함수를 실행하면 Iterator가 반환되는데, Iterator는 next라는 메서드를 가지고 있다. next 메서드를 호출하면 Generator 함수 내부에서 가장 먼저 등장하는 yield에서 함수 실행을 멈춘다. 이후 다시 next 메서드를 호출하면 앞서 멈췄던 부분에서 시작해 다음 yield에서 멈춘다. 비동기 작업이 완료되는 시점마다 next 메서드를 호출한다면 Generator 함수 내부의 소스가 위에서 아래로 순차적으로 진행될 것이다.


async / await

var addCoffee = function (name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      resolve(name);
    }, 500);
  });
};
var coffeeMaker = async function () {
  var coffeeList = "";
  var _addCoffee = async function (name) {
    coffeeList += (coffeeList ? "," : "") + (await addCoffee(name));
  };
  await _addCoffee("에스프레소");
  console.log(coffeeList);
  await _addCoffee("아메리카노");
  console.log(coffeeList);
  await _addCoffee("카페모카");
  console.log(coffeeList);
  await _addCoffee("카페라떼");
  console.log(coffeeList);
};

coffeeMaker();

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

좋은 웹페이지 즐겨찾기