프로그래밍-학습기록/Javascript

자바스크립트 콜백 함수 이해하기

leesche 2021. 2. 13. 18:16

콜백 함수가 뭐에요?

콜백(callback) 함수는 다른 코드의 인자로 넘겨주는 함수입니다. 콜백 함수를 넘겨 받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행합니다. 예를 들면, 어떤 함수 X를 실행합니다. 함수 X는 다음과 "특정 조건이 되면 함수 Y를 호출"과 같은 명령을 실행합니다. 함수 X는 특정 조건이 됐는지 스스로 판단하고 Y를 호출합니다. 다시 말해, Y 함수를 제어할 권리는 X 함수에게 있습니다. 이때 적절한 시점에 호출되는 Y 함수가 콜백 함수입니다.

'제어할 권리'을 구체적으로 살펴보기

var count = 0;
var countFunction = function () {
  console.log(count);
  if (++count > 4) { // count에 먼저 1이 더해지고 비교 연산을 수행한다.
    clearInterval(timer);
  }
};
var timer = setInterval(countFunction, 300);
// setInterval()의 반환 값인 ID 값을 timer에 담아 
// setInterval() 함수를 종료(clearInterval()) 할 수 있도록 함.

/* 출력 결과
0
1
2
3
4
*/

호출 시점

콜백(callback) 함수다른 코드의 인자로 넘겨주는 함수입니다. 콜백 함수(이 함수의 제어권도 포함하여)를 넘겨 받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행합니다.

위 예제에 대하여 위 인용문은 아래와 같습니다.

countFunctionsetInterval의 인자로 넘겨주는 함수입니다. countFunction(이 함수의 제어권도 포함하여)를 넘겨 받은 setInterval이 를 countFunction을 필요에 따라 0.3초마다 실행합니다.

인자

var newArray = [10, 20, 30].map(function (currentValue, index) {
  console.log(currentValue, index);
  return currentValue + 5;
});
console.log(newArray);

/* 실행 결과
10 0
20 1
30 2
[ 15, 25, 35 ]
*/

map 메서드는 첫 번째 인자로 callback 함수를 받고, 생략 가능한 두 번째 인자로 콜백 함수 내부에서 this로 인식할 대상을 특정할 수 있습니다. 두 번째 인자가 생략되면 this는 전역 객체를 가리킵니다. map메서드의 첫 번째 인자인 callback 함수의 첫번째 인자로 배열의 요소 중 현재 값이, 두번째 인자로 현재 값의 (배열에서의) 인덱스가, 세 번째 인자에는 map 메서드의 대상인 배열 그 자체가 담깁니다.

당연하겠지만, 인자의 순서를 바꾸어 사용할 수 없다는 것을 명심해야 합니다. 순서는 중요합니다. 위 예제에서 명명된 인자의 이름, currentValueindex는 사람이 작성한 것이고 그것이 각각 carrot, melon이 되어도 함수는 그저 정해진 규칙대로 작동합니다. 컴퓨터는 자바스크립트 언어의 규칙을 그대로 실행할 뿐입니다. map 메서드는 (어딘가에 작성된) 자신의 코드에 따라 인자의 순서를 구별해 결과를 반환합니다.

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

this

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

별도의 this를 지정하는 방식 및 제어권에 대한 이해를 높이기 위해 책에서는 map 메서드를 (핵심적인 내용만) 직접 구현했습니다.

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

메서드 구현의 핵심은 call, apply 메서드에 있습니다. call 메서드의 첫번째 인자인 this가 가리키는 값은 thisArg 인자가 있을 경우 thisArg, 없으면 window를 가리키도록 합니다. 두 번째 인자로 this 객체의 i번째 값, 세 번째 인자로 callback 함수를 호출하는 this 객체 자체입니다. 이 때 this 객체는 map 함수 내부의 this 객체이고, map 함수는 어떤 배열의 메서드로서 호출되는 것이 때문에 this가 가리키는 것은 배열 그 자체가 됩니다. 즉, 두 번째 인자는 배열의 i번째 값, 세 번째 인자는 배열 자체입니다.

너만 함수냐? 콜백 함수도 함수다!

콜백함수도 함수입니다. 함수로서 호출할 때와 메서드로서 호출할 때를 구분해야 합니다.

콜백 함수로 어떤 객체의 메서드를 전달하더라도 그 메서드는 메서드가 아닌 함수로서 호출됩니다. 즉, 그 함수의 this는 전역 객체를 가리키게 됩니다.

var obj = {
  values: [1, 2, 3],
  logValues: function (value, index) {
    console.log(this, value, index);
  },
};

obj.logValues(1, 2); // (1)
[4, 5, 6].forEach(obj.logValues); // (2)

/* 실행 결과
{ values: [ 1, 2, 3 ], logValues: [Function: logValues] } 1 2
Window {...} 4 0
Window {...} 5 1
Window {...} 6 2
*/

(1) 명령은 logValues 함수를 obj의 메서드로서 호출한 것입니다. 따라서 logValues 내부의 this는 obj를 가리킵니다.

(2) 명령에서 [forEach](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach)의 콜백 함수로 전달된 obj.logValues는 obj의 메서드로서 '호출'된 것이 아니라, obj의 프로퍼티인 logValues라는 함수를 그대로 전달한 것입니다. 따라서 '특정한 시점과 조건'에 콜백 함수가 호출될 때 가리키는 logValuesthis는 (forEach의 특별한 규칙이 없었으므로) 전역 객체를 가리키게 됩니다.

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

그럼에도 불구하고 콜백 함수 내부에서 this가 특정 객체를 가리키게 하고 싶다면 어떻게 해야 할까요? 별도의 인자로 this를 받는 함수의 경우, 여기에 원하는 값을 넘겨주면 됩니다. 하지만 그렇지 않은 경우, this의 제어권도 넘겨주므로, 사용자가 임의로 값을 바꿀 수 없습니다. 그래서 전통적으로, this를 다른 변수에 담아 콜백 함수로 활용할 함수에서는 this 대신 그 변수를 사용하게 합니다. 그리고 이를 클로저로 만드는 방식이 많이 쓰였습니다.

  • 콜백 함수 내부 this에 다른 값을 바인딩하는 전통적인 방법

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

    callback 변수에 메서드로서 호출된 func 함수의 반환 값인 익명 함수가 담깁니다. 이 익명함수는 obj1의 프로퍼티인 함수 func안에서 호출된 self 객체의 name 프로퍼티를 출력합니다. self는 메서드로서 호출됐기 때문에 this가 가리키는 obj1을 가리킵니다. 따라서 self.nameobj1.name이 되고 최종적으로 myNameObj1이 호출됩니다.

    실제 this를 사용하지도 않고 번거로운 방법입니다.

  • 콜백 함수 내부에서 this를 사용하지 않는 방법

      var obj1 = {
        name: "myNameObj1",
        func: function () {
          console.log(obj1.name);
        },
      };
      setTimeout(obj1.func, 1000);
      /* 실행 결과
      myNameObj1
      */

    훨씬 간결하고 직관적인 방법이지만, this를 활용할 수 없게 됐습니다. 어떤 방법으로도 다른 객체를 바라보게 할 수 없습니다.

  • 첫 번째 방법에서 func 함수의 재활용

      var obj1 = {
        name: "obj1",
        func: function () {
          var self = this;
          return function () {
            console.log(self.name);
          };
        },
      };
      var callback = obj1.func();
      setTimeout(callback, 1000);
    
      var obj2 = {
        name: "obj2",
        func: obj1.func,
      };
      var callback2 = obj2.func();
      setTimeout(callback2, 1500);
    
      var obj3 = { name: "obj3" };
      var callback3 = obj1.func.call(obj3);
      setTimeout(callback3, 2000);
    
      /* 실행 결과
      obj1
      obj2
      obj3
      */

    callback2에는 obj2func를 메소드로서 호출한 결과를 담았습니다. obj2.funcobj1func 함수입니다. 따라서 callback2가 전달되어 호출되면, obj2의 메소드로서 func가 호출되고, obj1의 func가 메서드가 아닌 함수로서 호출됩니다. obj1func 함수에서 thisobj2를 가리킵니다. 따라서 self.nameobj2가 됩니다.

    callback3에는 obj1func 함수에 call 메서드를 호출했고, this로 사용할 인자로 obj3을 전달한 함수를 담았습니다. 즉, callback3에는 thisobj3를 사용한 obj1func의 반환 값이 담깁니다. 따라서 callback3가 콜백 함수로 전달되어 호출되면 obj3name이 출력됩니다.

  • ES5에서 등장한 bind 메서드를 활용해 전통적 방법의 아쉬움을 보완하는 방법

      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);
    
      /* 실행 결과
      obj1
      obj2
      */

    bind 메서드는 call 비슷하지만 즉시 함수를 호출하는 것이 아니라 넘겨 받은 this 및 인수들을 바탕으로 새로운 함수를 반환하기만 합니다.

func

콜백 지옥과 비동기 제어

콜백 함수를 호출하는 콜백 함수를 호출하는 콜백 함수를 ...

용어 정의

  • 콜백 지옥(callback hell)은 콜백 함수를 익명 함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상입니다. 가독성이 떨어지고 코드를 수정하기 어렵습니다. 콜백 지옥은 피해야 합니다.
  • 비동기는 동기의 반댓말로, 동기적인 코드는 현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식입니다. 비동기적 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어갑니다.
    대부분의 코드는 동기적 코드입니다. 반면 사용자의 요청에 의해 특정 시간이 경과되기 전까지 어떤 함수의 실행을 보류한다거나, 사용자의 직접적 개입이 있을 때 비로소 어떤 함수를 실행하도록 대기를 한다거나, 웹브라우저 자체가 아닌 별도의 대상에 무언가를 요청하고 그에 대한 응답이왔을 때 비로소 어떤 함수로 실행하도록 대기하는 등, 별도의 요청, 실행 대기, 보류 등과 관련된 코드는 비동기적 코드입니다.

이런 비동기 코드에서 사용되는 콜백 함수를 익명 함수로 작성하고, 그 안에서 비동기 코드를 작성하고 또 콜백 함수를 익명 함수로 작성한다면 콜백 지옥에 쉽게 빠지게 됩니다.

콜백 지옥의 가독성 문제와 수정 어려움을 해결하는 가장 간단한 방법은 익명의 콜백 함수를 모두 기명 함수로 전환하는 것입니다.

이후 자바스크립트에서 비동기적인 일련의 작업을 동기적으로, 혹은 동기적인 것처럼 보이게끔 처리해주는 장치를 마련하고자 노력했습니다. (이 내용은 이후 포스팅에 담겠습니다!)

  • ES6에서는 Promise, Generator 등이 도입됐습니다.
  • ES2017에서는 async/await가 도입됐습니다.

정리

  • 콜백 함수는 다른 코드에 인자를 넘겨줌으로써 그 제어권도 함께 위임한 함수입니다.
  • 제어권을 넘겨 받은 코드는 다음과 같은 제어권을 가집니다.
    1. 콜백 함수를 호출하는 시점을 스스로 판단해 실행합니다.
    2. 콜백 함수를 호출할 때 인자로 넘겨줄 값들 및 그 순서가 정해져 있습니다. 이 순서를 따르지 않고 코드를 작성하면 엉뚱한 결과를 얻게 됩니다.
    3. 콜백 함수의 this가 무엇을 바라보도록 할지가 정해져 있는 경우도 있습니다. 정하지 않은 경우에는 전역 객체를 바라봅니다. 사용자 임의로 this를 바꾸고 싶을 경우 bind 메서드를 활용하면 됩니다.
  • 어떤 함수에 인자로 메서드를 전달하더라도 이는 결국 함수로서 실행됩니다.
  • 비동기 제어를 위해 콜백 함수를 사용하다 보면 콜백 지옥에 빠지기 쉬우니 대처 방법을 알고 있어야 합니다. 그 방법에는 Promise, Generator, async/await 가 있습니다.

참고 및 출처