챕터 목표: 콜백함수가 무엇인지 알고, 이를 활용한 로직을 이해한다.
콜백 함수: 어떤 함수나 메서드에게 인자로 넘겨주며 그 제어권도 함께 위임한 함수
콜백 함수를 인자로 받은 어떤 함수는 특정 조건이 갖춰졌는지 여부를 스스로 판단하여 콜백함수를 직접 호출한다.
우선 setInterval의 문법은 다음과 같다.
var intervalID = scope.setInterval(func, delay[, param1, param2, ...]);
이때 scope는 Window 객체 혹은 Worker의 인스턴스가 들어올 수 있으며, 일반적인 브라우저 환경에서는 window를 생략해서 사용할 수 있다.
setInterval의 첫 번째 인자인 함수는 delay마다 실행되고, 어떠한 값도 리턴하지 않는다.
setInterval을 실행하면 반복적으로 실행되는 내용 자체를 특정할 수 있는 고유한 ID 값이 반환된다. 이를 변수에 담는 이유는 반복 실행되는 중간에 clearInterval을 통해 종료할 수 있게 하기 위함이다.
var count = 0;
var callBackFunc = function () {
console.log(count);
if (++count > 4) clearInterval(timer);
}
var timer = setInterval(callBackFunc, 300);
// 0부터 4까지 0.3초 간격으로 출력된다.
code | 호출 주체 | 제어권 |
---|---|---|
callBackFunc(); | 사용자 | 사용자 |
setInterval(callBackFunc, 300); | setInterval | setInterval |
즉, setInterval이라는 어떤 코드의 첫 번째 인자로서 callBackFunc을 넘겨주자, 제어권을 넘겨받은 setInterval이 스스로의 판단에 따라 적절한 시점(300ms 마다)에 callBackFunc 함수를 실행했다.
이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수 호출 시점에 대한 제어권을 가진다.
우선 Array의 prototype에 담긴 map 메서드의 문법은 다음과 같다.
Array.prototype.map(callback[, thisArg])
callback: function(currentValue, index, array)
map 메서드는 첫 번째 인자로 콜백 함수를 받고, 생략 가능한 두 번째 인자로 콜백 함수 내부에서 this로 인식할 대상을 특정할 수 있다. (thisArg를 생략할 경우에는 일반적인 함수와 마찬가지로 전역객체가 바인딩된다.)
map 메서드는 메서드의 대상이 되는 배열의 모든 요소들을 처음부터 하나씩 꺼내어 콜백 함수를 반복 호출하고, 콜백 함수의 실행 결과들을 모아 새로운 배열을 만든다. 콜백 함수의 첫 번째 인자에는 배열의 요소 중 현재값이, 두 번째 인자에는 현재값의 인덱스가, 세 번째 인자에는 map 메서드의 대상이 되는 배열 자체가 담긴다.
var newArr = [10, 20, 30].map(function (currentValue, index) {
console.log(currentValue, index);
return currentValue + 5;
});
console.log(newArr);
/* 실행 결과
10 0
20 1
30 2
[ 15, 25, 35 ]
*/
콜백 함수를 호출하는 주체가 사용자가 아닌 map 메서드이므로, map 메서드가 콜백 함수를 호출할 때 인자로 넘겨줄 값들과 순서는 이미 정해져 있다.
이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수를 호출할 때 인자에 어떤 값들을 어떤 순서로 넘길 것인지에 대한 제어권을 가진다.
콜백 함수도 함수이기 때문에 기본적으로는 this가 전역객체를 참조하지만, 제어권을 넘겨받을 코드에서 콜백 함수에 별도로 this가 될 대상을 지정한 경우에는 그 대상을 참조하게 된다.
map 메서드를 통해 별도의 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 값을, 세 번째 인자에는 배열 자체를 지정해 호출한다.
그 결과가 변수 mappedValue
에 담겨 mappedArr
의 i번째 인자에 할당된다.
즉, 제어권을 넘겨받을 코드에서 call, apply 메서드의 첫 번째 인자에 콜백 함수 내부에서의 this가 될 대상
을 명시적으로 바인딩하여 this에 다른 값을 담을 수 있다.
'클릭' 버튼을 클릭하자 다음 결과가 콘솔에 출력됐다.
setTimeout은 내부에서 콜백 함수를 호출할 때 call 메서드의 첫 번째 인자에 전역객체를 넘긴다. 따라서 콜백 함수 내부에서의 this가 전역객체를 카리킨다.
forEach는 별도의 인자로 this를 받는 경우 this 값이 달라질 수 있으나, 위 코드에서는 별도의 인자로 this를 넘겨주지 않았다. 따라서 콜백 함수 내부에서의 this가 전역객체를 카리킨다.
addEventListener는 내부에서 콜백 함수를 호출할 때 call 메서드의 첫 번째 인자에 addEventListener 메서드의 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 ], logValues: ƒ logValues() } 1 2
[4, 5, 6].forEach(obj.logValues);
/*
Window { ... } 4 0
Window { ... } 5 1
Window { ... } 6 2
*/
위의 코드에서 forEach의 콜백 함수로 지정된 obj.logValues
는 obj.logValues가 가리키는 함수만 전달된 것이기 때문에 obj와 직접적인 연관이 없다.
따라서 forEach에 의해 콜백 함수가 함수로서 호출되고, 별도로 this를 지정하는 인자를 지정하지 않았으므로 콜백 함수 내부에서의 this는 전역객체를 바라보게 된다.
var obj1 = {
name: 'obj1',
func: function () {
var self = this;
return function () {
console.log(self.name);
};
}
};
var callback = obj1.func();
setTimeout(callback, 1000);
위 코드에선 self 변수에 this를 담고, 익명 함수를 선언함과 동시에 반환했다.
이 방식은 실제 this를 사용하는 것이 아니고 번거롭기 때문에, 좋은 방식이 아니다.
var obj1 = {
name: 'obj1',
func: function () {
console.log(obj1.name);
}
};
setTimeout(obj1.func, 1000); // 'obj1'
this를 사용하지 않았을 때는 코드가 훨씬 간결하고 직관적이나, 이렇게 작성한 함수는 this를 이용한 다양한 상황에 재활용할 수 없다.
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'
*/
this 값을 이용해 원하는 값을 출력하는 코드이다.
예제 1의 방법은 번거롭긴 하지만, 다양한 상황에서 원하는 객체를 바라보는 콜백 함수를 만들 수 있는 방법이다.
반면 예제 1-1의 경우는 처음부터 바라볼 객체를 명시적으로 obj1로 지정했기 때문에 어떤 방법으로도 다른 객체를 바라보게끔 할 수가 없다.
전통적인 방식의 아쉬움을 보완하는 방법이다.
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), 1500);
콜백 지옥: 콜백 함수를 익명 함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상
현대의 자바스크립트는 웹의 복잡도가 높아진 만큼 비동기적인 코드 비중이 많이 높다. 따라서 콜백 함수로 비동기처리를 할 경우, 콜백 지옥에 빠지기가 너무 쉽다.
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 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, '에스프레소');
/*
'에스프레소'
'에스프레소, 아메리카노'
'에스프레소, 아메리카노, 카페모카'
'에스프레소, 아메리카노, 카페모카, 카페라떼'
*/
해당 예제는 위에서 아래로 순서대로 읽을 수 있고 각 함수 선언과 호출이 구분되어있어 가독성을 높였다.
그러나 일회성 함수를 전부 변수에 할당하는 것이 비효율적이고, 코드명을 일일이 따라다녀야 하는 것이 헷갈릴 여지를 주기도 하는 점에서 아직 단점이 있다.
최근에는 콜백이 아닌 다른 방식으로 비동기처리를 한다. 아래 4 개의 예제는 그 방식을 적용한 예제이다.
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);
})
})
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('카페모카'));
예제 2-1의 반복적인 내용을 함수화하여 더욱 짧게 표현한 Promise 함수이다. 2, 3번째 줄에서 클로저가 등장했는데, 클로저는 다음장에서 자세히 다룰 예정이다.
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 latte = yield addCoffee(americano, '카페라떼');
console.log(latte);
var mocha = yield addCoffee(latte, '카페모카');
console.log(mocha);
};
var coffeeMaker = coffeeGenerator();
coffeeMaker.next();
/*
{ value: undefined, done: false }
'에스프레소'
'에스프레소, 아메리카노'
'에스프레소, 아메리카노, 카페라떼'
'에스프레소, 아메리카노, 카페라떼, 카페모카'
*/
Generator 함수를 실행하면 Iterator가 반환되고, Iterator는 next 메서드를 가지고 있다.
이 next 메서드를 호출하면, Generator 함수 내부에서 가장 먼저 등장하는 yield에서 함수의 실행이 멈춘다.
이후 다시 next 메서드를 호출하면, 앞서 멈췄던 부분부터 시작하여 그 다음에 등장하는 yield에서 또 함수의 실행이 멈춘다.
이런 기능을 이용하여 비동기 작업이 완료되는 시점마다 next 메서드를 호출한다면, Generator 함수 내부의 소스가 위에서부터 아래로 순차적으로 진행된다.
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();
비동기 작업을 수행하고자 하는 함수 앞에 async를 표기하고, 함수 내부에서 실질적인 비동기 작업이 필요한 위치마다 await를 표기하는 것만으로 뒤의 내용은 Promise로 자동 전환되고, 해당 내용이 resolve된 이후에야 다음 코드로 진행된다.
즉, async/await를 이용하면 Promise의 then과 흡사한 효과를 더욱 간편하게 얻을 수 있다.
콜백 함수는 다른 코드에 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수이다.
(다른 코드(보통 함수나 메서드)가 호출되는 시점에 콜백함수의 제어권이 넘어간다.)
제어권을 넘겨받은 코드는 다음과 같은 제어권을 가진다.
1) 콜백 함수를 호출하는 시점을 스스로 판단하여 실행한다.
2) 콜백 함수를 호출할 때 인자로 넘겨줄 값과 그 순서가 이미 정해져 있다. 이 순서를 따르지 않고 코드를 작성하면 엉뚱한 결과를 얻게 된다.
3) 콜백 함수의 this를 정하지 않는 경우, this는 전역객체를 바라본다. 사용자 임의로 this를 바꾸고 싶은 경우, bind 메서드를 활용하면 된다.
어떤 함수에 인자로 메서드를 전달하더라도 이는 무조건 함수로서 실행된다.
비동기 제어를 위해 콜백 함수를 사용하다 보면 콜백 지옥에 빠지기 쉽다. Promise, Generator, async/await 등 더 나은 방법을 사용하여 비동기처리를 하는 것이 좋다.