본문으로 바로가기

4장 콜백함수

category 코어 자바스크립트 2020. 12. 22. 16:19

콜백함수란

콜백함수는 다른 코드의 인자로 넘겨주는 함수이다, 콜백 함수를 넘겨받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행할 것이다, 콜백함수는 제어권과 관련이 깊다

 

callback은 '부르다', '호출하다'는 의미인 call과 '뒤돌아오다', '되돌다'는 의미인 back의 합성어로, '되돌아 호출해달라'는 명령이다, 어떤 함수 X를 호출하면서 '특정 조건일 때 함수 Y를 실행해서 나에게 알려달라'는 요청을 함께 보낸다 이 요청을 받은 함수 X의 입장에서는 해당 조건이 갖춰졌는지 여부를 스스로 판단하고 Y를 직접 호출한다

 

이처럼 콜백 함수는 다른 코드(함수 또는 메서드)에게 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수이다, 콜백 함수를 위임받은 코드는 자체적인 내부 로직에 의해 이 콜백 함수를 적절한 시점에 실행할 것이다 

 

 

더 간단하게 정의하면 어떤 특정함수가 실행을 마친 후에 실행될 함수를 콜백함수라한다

자바스크립트에서 함수는 객체이다, 이러한 특징 때문에 함수는 인자로 함수를 받을 수 있고 다른 함수를 리턴할 수 있다 이러한 특징을 가지는 함수를 고차함수(higher-order-function)라고 부르고, 인자로 전달된 함수를 콜백함수라고 부른다

higher-order-function은 여기에서 이해해보도록 하자
http://medium.com/@la.place/higher-order-function-%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-1c61e0bea79

 

콜백함수 쉽게 사용한 예제1

var plus = function(a, b, callback){
    var result = a+b
    callback(result);
}
 
plus(5,10, function(res){
    console.log(res);  // 15
})

 

콜백함수 쉽게 사용한 예제2

var pm = function(a, b, callback){
  callback(a+b, a-b);
}
 
pm(5,10, function(res1, res2){
  console.log(res1); // 15
  console.log(res2); // -5
})

위의 간단한 코드를 본 뒤에 

밑에서 실제로 사용되는 콜백함수를 살펴보도록 하자


제어권

콜백함수 예제1 (setInterval, setTimeout)

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

// -- 실행 결과 --
// 0 (0.3초)
// 1 (0.6초)
// 2 (0.9초)
// 3 (1.2초)
// 4 (1.5초)

 

//setInterval의 구조
var intervalID = setInterval(func, delay[, param1, param2, ...]);

setInterval은 첫번째 매개변수로 함수를 받는데 위의 코드에서는 cbFunc가 setInterval의 콜백함수가 된 것이다 

 

중요한건 제어권이 어디에 있는가 이다

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

위의 setInterval 함수는 0.3초 마다 cbFunc를 실행하여 0부터 4까지 출력된 후 종료된다

setInterval이라고 하는 '다른코드'에 첫 번째 인자로서 cbFunc 함수를 넘겨주자 제어권을 넘겨받은 setInterval이 스스로의 판단에 따라 적절한 시점에(0.3초마다) 이 익명 함수를 실행했다

이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수 호출 시점에 대한 제어권을 가진다

 

 

콜백함수 예제2 (Array.protytpe.map)

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

// 콘솔출력
// 10 0
// 20 1
// 30 2

console.log(newArray); // [13, 23, 33]

 

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

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

 

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

콜백 함수의 첫 번째 인자에는 배열의 요소 중 현재값이, 두 번째 인자에는 현재값의 인덱스가, 세번째 인자에는 map메서드의 대상이 되는 배열 자체가 담긴다

 

 

그렇다면 콜백함수는 어떻게 만드는 것일까?

Array.prototpye.map의 동작원리에 맞게 구현해보자

 

Array.prototype.map 구현

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

여기에서의 this는 해당 함수를 호출한 배열이 된다

그리고 두번째 인자를  thisArg는 call/apply 메서드를 호출하여 값이 있는 경우에는 그 값을, 없는 경우에는 window라는 전역객체를 참조한다

 

3장 this에서 setTimeout과 forEach에서의 this가 window(전역객체)를 가리키는 이유를 여기에서 발견할 수 있다

제어권을 넘겨받을 코드에서 call/apply 메서드의 첫 번째 인자에 콜백 함수 내부에서의 this가 될 대상을 명시적으로 바인딩 하기 때문이다

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], logValues: f}, 1, 2
[4, 5, 6].forEach(obj.logValues);  // Window { ... }, 4, 0
                                   // Window { ... }, 5, 1
                                   // Window { ... }, 6, 2

obj 객체의 loValues는 메서드로 정의되었다

처음 호출시에는 메서드로서 호출 했으니 this는 obj를 가리키고, 인자로 넘어온 1, 2가 출력된다

두번째 호출시에는 forEach 함수의 콜백함수로서 전달되었다, obj를 this로 하는 메서드를 그대로 전달한 것이 아니라, obj.logValues가 가리키는 함수만 전달한 것이다, 이 함수는 호출할 때가 아닌 한 obj와의 직접적인 연관이 없어진다

forEach의 두번째 인자로 this를 지정하는 인자를 지정하지 않았으므로 함수 내부에서의 this는 전역객체를 바라보게 된다

 

 

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

객체의 메서드를 콜백함수로 전달하면 해당 객체를 this로 바라볼 수 없다

그럼에도 콜백 함수 내부에서 this가 객체를 바라보게 하는 방법을 알아보자

var obj1 = {
    name: 'obj1',
    func: function () {
        console.log(this.name);
    }
};
setTimeout(obj1.func.bind(obj1), 1000);  // 1초후 obj1 출력

var obj2 = { name: 'obj2'};
setTimeout(obj1.func.bind(obj2), 1500);  // 1.5초후 obj2 출력

이처럼 작성한 함수를 this를 이용해 다양한 상황에 재활용할 수 있다

 

 

콜백 지옥과 비동기 제어

콜백 지옥이란 콜백 함수를 익명 함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들정도로 깊어지는 현상으로 자바스크립트에서 흔히 발생하는 문제이다, 주로 비동기적인 작업을 수행하기 위해 이런 형태가 등장한다

 

비동기와 동기

동기적인 코드 비동기적인 코드
현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식이다

CPU의 계산에 의해 즉시 처리가 가능한 대부분의 코드
현재 실행중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어간다

사용자의 요청에 의해 특정 시간이 경과되기 전까지 어떤 함수의 실행을 보류한다거나, 사용자의 직접적인 개입이 있을 때 비로소 어떤 함수를 실행하도록 대기한다거나, 웹 브라우저 자체가 아닌 별도의 대상에 무언가를 요청하고 그에 대한 응답이 왔을 때 비로소 어떤 함수를 실행하도록 대기하는 등 별도의 요청, 실행 대기, 보류 등과 관련된 코드

 

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

간단한 콜백지옥 예시를 살펴보자

 

콜백지옥예시

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초 뒤) 에스프레소
// (1초 뒤)   에스프레소, 아메리카노
// (1.5초 뒤) 에스프레소, 아메리카노, 카페모카
// (2초 뒤)   에스프레소, 아메리카노, 카페모카, 카페라떼

위와 같은 코드는 들여쓰기 수준이 과도하게 깊어졌을 뿐더러 값이 전달되는 순서가 '아래에서 위로' 향하고 있어 어색하게 느껴진다

 

가장 간단한 해결방법은 익명의 콜백함수를 모두 기명함수로 전환하는 것이다

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 등이 도입됐고, ES2017에서는 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); 
    });    
});

new 연산자와 함께 호출한 Promise의 인자로 넘겨주는 콜백함수는 호출할 때 바로 실행되지만 그 내부에 resolve 또는 reject 함수를 호출하는 구문이 있을 경우 둘 중 하나가 실행되기 전까지는 다음(then) 또는 오류구문(catch)로 넘어가지 않는다

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

 

다음으로는 Generator를 살펴보자

 

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

6번째 줄의 '*' 이 붙은 함수가 바로 Generator 함수이다

Generator 함수를 실행하면 Iterator가 반환되는데, Iterator는 next라는 메서드를 가지고 있다, 이 next 메서드를 호출하면 Generator 함수 내부에서 가장 먼저 등장하는 yield에서 함수의 실행을 멈추고 이후 다시 next 메서드를 호출하면 앞서 멈췄던 부분부터 시작해서 그 다음에 등장하는 yield에서 함수의 실행을 멈춘다

그러니까 비동기 작업이 완료되는 시점마다 next 메서드를 호출해준다면 Generator 함수 내부의 소스가 위에서부터 아래로 순차적으로 진행되게 된다

 

다음으로는 ES2017의 async/await 를 살펴보자

 

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) {
        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된 이후에야 다음으로 진행한다

즉 Promise의 then과 흡사한 효과를 얻을 수 있다

'코어 자바스크립트' 카테고리의 다른 글

7장 클래스  (0) 2020.12.28
6장 프로토타입  (2) 2020.12.24
5장 클로저  (0) 2020.12.23
3장 this  (2) 2020.12.21
1장 메모리와 데이터 타입  (0) 2020.12.21
2장 실행 컨텍스트  (0) 2020.12.21