콜백 함수

Ordinary·2023년 5월 22일
0

콜백 함수

콜백 함수란, 다른 코드의 인자로 넘겨주는 함수입니다.

callback이라는 말의 의미를 생각해보면 "되돌아 호출해달라"라는 것으로 생각해볼 수 있습니다. 즉, 어떤 함수 X를 호출하면서 특정 조건을 만족할 때, 함수 Y를 실행해달라라고 요청을 보낸 것과 같습니다. 이 요청을 받은 함수 X의 입장에서는 해당 조건이 갖춰졌는지 여부를 스스로 판단하고 Y를 직접 호출합니다.

콜백함수는 다른 코드(함수 또는 메서드)에게 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수입니다. 콜백 함수를 위임받은 코드는 자체적인 내부 로직에 의해 콜백함수를 적절하게 실행하게 됩니다.

제어권

호출시점을 넘기는 경우

var count = 0;
var timer = setInterval(function() {
	console.log(count);
	if(count++ > 4) clearInterval(timer);
},300);
//setInterval함수 내부 구조
var intervalID = scope.setInterval(func, delay[,param1, param2, ..])

setInterval는 2개의 매개변수를 받습니다. 첫번째는 실행할 함수 func이고, 두번째는 밀리초 단위를 나타내는 숫자값인 delay입니다. 위 함수를 다시 해석하면 "func에 넘겨준 함수는 매 delay마다 할행되며 그 결과 어떠한 값도 리턴하지 않는다"라는 뜻입니다. setInterval을 실행하면서 반환되는 intervalID는 반복적으로 실행되는 내용 자체를 특정할 수 있는 고유한 ID값입니다.

여기서 익명함수 function을 cbfunc으로 바꾸어 코드를 풀어내면 아래와 같습니다.

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

//실행결과
//0
//1
//2
//3
//4

cbFunc의 제어권을 넘겨 받은 setInterval함수는 스스로의 판단에 따라 적절한 시점에 cbFunc를 실행했습니다. 이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수 호출시점에 대한 제어권을 가집니다.

콜백 함수에 넣는 인자에 대한 제어권을 넘긴 경우

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

console.log(newArr);
// 실행결과
// 10 15
// 20 25
// 30 35
// [15, 25, 35]

map 메서드는 메서드의 대상이 되는 배열의 모든 요소들을 처음부터 끝까지 하나씩 꺼내어 콜백 함수의 인자로 넣어주고, 콜백 함수의 반환값을 모아 새로운 배열을 만듭니다. 위 예제에서 볼 수 있듯이 [10, 20, 30]배열에 대해서 각각의 요소 10, 20, 30은 콜백 함수의 인자로 들어가게 됩니다. 콜백 함수의 반환값으로 각각 5가 더해진 값이 반환되면 이를 모아 새로운 배열을 만들게 됩니다.

map메서드를 통해 원하는 배열을 얻으려면 정의된 규칙에 따라 함수를 작성해야 합니다. map 메서드에 정의된 규칙에는 콜백 함수의 인자로 넘어올 값들 및 순서도 포함돼 있습니다. 즉, 콜백 함수를 사용하는 주체가 map메서드이기 때문에 인자값의 순서가 전적으로 map메서드에게 달려있다고 말할 수 있습니다. 이처럼 콜백 함수의 제어권을 넘겨받은 코드는 호출할 때 넘겨줄 인자에 대해 어떤 값들을 어떤 순서로 넘길 것인지, 제어권을 가집니다.

콜백 함수에서의 this

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

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

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

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

콜백 내부에서의 this를 각각 살펴보겠습니다.

먼저 (1) 의 setTimeout 은 내부에서 콜백함수를 호출할때 call메서드의 첫 번재 인자에 전역객체를 넘기기 때문에 콜백 함수 내부에서의 this는 전역객체가 됩니다.
(2) 의 forEach는 별도의 인자로 this를 받지만, 현재 예시에서는 별도로 this를 넘겨주지 않았기 때문에 전역객체를 가리키게 됩니다.
(3) 의 addEventListener는 내부에서 콜백함수를 호출할때 call 메서드의 첫 번째 인자로 addEventListener 메서드의 this를 그대로 넘기도록 정의돼 있습니다. 때문에 콜백 함수 내부에서의 this는 addEventListener를 호출한 주체인 HTML엘리먼트를 가리키게 됩니다.

콜백함수는 함수다

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

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

obj.logValues(1, 2); // {vals : [1, 2, 3] , logVals : f} 1 2;
[4, 5, 6].forEach(obj.logVals); // window { ... } 4 0 ...

objlogValues는 메서드로 정의되었습니다.
하지만, forEach에 콜백 함수로 넘겼을 때는 objthis로 하는 메서드를 그대로 전달한것이 아니라 obj.logValues가 가리키는 함수만 전달한 것이 됩니다. 이 함수는 obj와의 직접적인 연관이 없고, 별도로 this를 지정하는 인자를 지정하지 않았기 때문에 함수 내부에서의 this는 전역개체가 됩니다.

function(){
	obj.logVals();
}

위와 같이 넘겨준다고 간이로 생각해볼 수 있습니다.
결국, 어떤 함수 인자에 객체의 메서드를 전달하더라도 이는 메서드가 아닌 함수일 뿐입니다.

콜백 지옥과 비동기제어

콜백 지옥은 콜백 함수를 익명으로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상입니다. 주로 이벤트 처리나 서버 통신과 같이 비동기적인 작업을 구현할 때 발생하는 문제인데, 가독성이 떨어질뿐만 아니라 코드를 수정도 어렵게 만듭니다.

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,
  '아메리카노',
)

// 결과:
// 아메리카노
// 아메리카노, 카페모카
// 아메리카노, 카페모카, 카페라떼
// 아메리카노, 카페모카, 카페라떼, 에스프레소

위 예시에서 볼 수 있듯이 들여쓰기 수준이 과도하게 깊어졌을 뿐더러 값이 전달되는 순서가 아래에서 위로 향하고 있어 어색하게 느껴집니다.

가장 간단하게 해결하는 방법은 익명의 콜백 함수를 모두 기명으로 바꾸는것입니다.

var coffeeList = ''

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, '에스프레소')
}

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

setTimeout(addAmericano, 500, '아메리카노')

위와 같은 방법도 완벽한 해결책이 될 수 없습니다.
일회성 코드를 일일이 변수에 할당해야 하고, 코드명에 해당하는 부분을 찾기 위해 전체 코드를 다시 훑어봐야 할 수도 있습니다.
자바스크립트에서는 비동기적인 일련의 작업을 동기적으로, 혹은 동기적인 것처럼 보이게끔 많은 장치들이 도입되었는데 대표적인 것이 바로 ES6에서의 promise, Generator와 ES2017에서의 async/await입니다.

promise

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

new 연산자와 promise를 생성하게 되면, 인자로 넘겨준 콜백 함수는 바로 실행됩니다.
그 내부를 살펴보면, resolve 또는 reject함수를 호출하는 구문이 있는데, 둘 중 하나가 실행되기 전까지는 다음(then)또는 오류(catch)구문으로 넘어가지 않습니다.

generator

var addCoffee = function (prevName, name) {
  setTimeout(function () {
    coffeeMaker.next(prevName ? prevName + ', ' + name : name)
  }, 500)
}

var coffeeGenerator = function* () {
  var americano = yield addCoffee('', '아메리카노')
  console.log(americano)
  var mocha = yield addCoffee('', '카페모카')
  console.log(mocha)
  var latte = yield addCoffee('', '카페라떼')
  console.log(latte)
  var espresso = yield addCoffee('', '에스프레소')
  console.log(espresso)
}

var coffeeMaker = coffeeGenerator()
coffeeMaker.next()

함수를 선언할때 * 가 붙은 함수가 generator 함수 입니다.
이 함수를 실행하면 Iterator가 반환되는데 Iterator는 next라는 메서드를 가지고 있습니다. next 메서드를 호출하면 generator함수 내부에서 가장 먼저 등장하는 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를 추가합니다.
await를 표기하는 것만으로도 Promise로 자동 전환되어 해당 내용이 resolve된 이후에야 다음으로 진행할 수 있습니다. 이것은 Promise의 then과 흡사한 효과를 얻을 수 있습니다.

0개의 댓글