[javascript] 콜백함수

6 minute read

코어 자바스크립트 책에 있는 내용과 예제를 요약한 포스트입니다.

1. 콜백 함수란?

다른 코드의 인자로 넘겨주는 함수. 다른 함수 또는 메서드에게 인자로 넘겨줌으로서 제어권을 함께 위임한 함수. 콜백 함수를 위임받는 코드는 자체적인 내부 로직에 의해 적절한 시점에 콜백함수를 실행한다.

2. 제어권

2.1. 호출 시점

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

// 실행결과
// 0 (0.3s)
// 1 (0.6s)
// 2 (0.9s)
// 3 (1.2s)
// 4 (1.5s)
var intervalId = scope.setInterval(func, delay[, param1, param2, ...]);

setInterval의 구조는 위와 같다. 값을 리턴하지 않으며, setInterval를 실행하면 고유 ID값이 반환된다. (clearInterval를 하기 위해.)

code 호출 주체 제어권
cbFunc() 사용자 사용자
setInterval(cbFunc, 300) setInterval setInterval

2.2. 인자

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]
Array.prototype.map(callback[, thisArg]) // thisArg 생략시 전역객체 바인딩
callback: function(currentValue, index, array)

map 메서드는 메서드의 대상이 되는 배열의 모든 요소들을 처음부터 끝까지 하나식 꺼내어 콜백함수를 호출, 콜백 함수의 실행 결과들을 모아 새로운 배열을 만든다.

콜백함수의 인자 순서는 고정인 점에 주의한다.

2.3. 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); 
        // call 메서드를 통해 this바인딩, 이후 인자 currentValue, index, array
        mappedArr[i] = mappedValue;
    }
    return mappedArr;
};
  • 콜백 함수 내부에서의 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">Click</button>';
document.body.querySelector('#a').addEventListener('click', function(e){
    console.log(this);  // <button id="a">Click</button>
});

9번째 line의 경우, addEventListener를 호출한 주체인 HTML element를 가리키게 된다.

3. 콜백함수는 함수다.

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

위 예제처럼 콜백 함수로 객체의 메서드를 전달하더라도, 함수로서 호출된다는 점을 기억하자.

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

콜백 함수로 객체의 메서드를 전달할 경우, 함수로서 호출되므로 this는 해당 객체를 참조하지 않는다.

이 때 this가 해당 객체를 바라보게 하고 싶을 경우, thisArg를 받는 함수의 경우 값을 넘기면 되지만 아닌 경우는 임의로 바꿀 수 없기 떄문에 전통적으로는 this를 다른 변수에 담아 콜백함수에서 변수를 사용하게 하고, 이를 클로저로 만드는 방식을 사용했다.

콜백함수 내부 this 바인딩 - 전통적 방식

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

콜백함수 내부에서 this를 사용하지 않은 경우

var obj1 = {
    name: 'obj1',
    func: function(){
        console.log(obj1.name);
    }
};
setTimeout(obj1.func, 1000); // 'obj1'

이 경우 간결해 보이더라도 재사용을 할 수 없게 된다.

func 함수 재활용

var obj1 = {
    name: 'obj1',
    func: function(){
        var self = this;
        return function(){
            console.log(self.name);
        };
    }
};
var obj2 = {
    name: 'obj2',
    func: obj1.func
};
var callback2 = obj2.func();
setTimeout(callback2, 1500); // obj2

var obj3 = { name: 'obj3' };
var callback3 = obj1.func.call(obj3);
setTimeout(callback3, 2000); // obj3

전통적 방식으로 만들었던 방법을 재활용하는 예제이다. 번거로우나 this를 우회적으로 활용하여 사용할 수 있다.


bind메서드를 활용한 방법.

ES5 부터 등장한 bind메서드를 이용하면 전통적인 방법을 보완할 수 있다.

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

var obj2 = { name: 'obj2'};
setTimeout(obj.func.bind(obj2), 1500) // 'obj2'

5. 콜백 지옥과 비동기 제어

콜백 지옥이란 콜백을 익명 함수로 전달하는 과정이 반복되며 코드의 들여쓰기 수준이 깊어지는 현상으로, 주로 이벤트 처리나 서버 통신 같이 비동기적인 작업을 수행할 때 자주 등장한다.

비동기적인 코드란, 요청에 의해 특정 시간이 경과되기 까지 함수 실행을 보류하거나(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부터 도입되었다. ES6에서는 Promise, Generator가 도입되었고, ES2017에서는 async/await가 도입되었다.

Promise(1)

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

ES6에서 도입된 Promise를 이용한 방식이다. new 연산자와 함께 호출한 Promise의 인자로 넘겨주는 함수는 호출할 때 바로 실행되지만, 내부에 resolve 또는 reject 함수를 호출하는 구문이 있을 경우 둘 중 하나가 실행되기 전까지는 then 또는 catch로 넘어가지 않는다.

따라서 비동기 작업이 완료될 때 비로소 resolve / reject 를 호출하는 방법으로 비동기 작업의 동기적 표현이 가능하다.

Promise(2)

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

(1) 예제의 반복적인 내용을 함수화 하여 짧게 표현했으며, 2-3 번째 줄에서 클로저가 등장했다. (다음 장에서 살펴볼 것임.)

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

*가 붙은 함수가 Generator 함수이며 이를 실행하면 Iterator가 반환되는데, 이 Iteratornext()메서드를 가지고있다. 이를 호출하면 Generator 함수 내부에 가장 먼저 등장하는 yield에서 함수 실행을 멈춘다.

Promise + 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){
        coffeList += (coffeList? ',': '') + 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된 이후에야 다음으로 진행한다. 즉 then과 흡사한 효과를 얻는다.

이를 사용하면 가독성이 뛰어나면서 작성법도 간단한 코딩이 가능하다.


정리

  • 콜백 함수는 다른 코드에 인자로 넘겨주면서 제어권도 함께 위임한 함수.
  • 제어권을 넘겨 받은 코드는 다음과 같은 제어권을 가진다.
    • 콜백을 호출하는 시점을 스스로 판단
    • 콜백 함수를 호출할 때 인자로 넘겨줄 값들과 순서가 정해져있다.
    • 콜백 함수에 thisArg가 있는 경우도 있다. 지정하지 않은 경우 전역객체를 참조, 임의로 지정할때는 bind 메서드를 활용한다.
  • 콜백 함수로 객체의 메서드를 전달하더라도 함수로서 호출된다.
  • 비동기 제어 시 가독성이 좋은 코드를 만들기 위해서 Promise, Generator, async/await 등을 사용한다. (최신 ECMAScript)

Comments