Chapter 4. 콜백 함수

 

4.1 콜백함수란?

 

콜백 함수(callback funciton)는 다른 코드의 인자로 넘겨주는 함수다. 콜백 함수를 넘겨받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행한다. 즉, 어떤 함수 X를 호출하면서 '특정 조건일 때 Y를 실행해서 나에게 알려달라'는 요청을 보내는 것이다.

 

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

 

 

4.2 제어권

4.2.1 호출 시점

 

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

 

위 코드를 살펴보면 count 변수에 0을 할당하고, timer 변수에 setInterval의 결과를 할당한다. 이때 setInterval을 자세히 살펴보면 두 개의 매개변수를 전달하는데, 첫 번째는 익명 함수고 두 번째는 숫자다. setInterval의 구조는 아래와 같다.

 

var intervalID = scope.setInterval(func, delay[, param1, param2, ...]);

 

우선 scope에는 Window 객체(전역 객체) 또는 Worker의 인스턴스가 들어올 수 있다. 두 객체 모두 setInterval 메서드를 제공하기 때문인데, 일반적인 브라우저 환경에서는 window를 생략해서 함수처럼 이용이 가능하다.

 

매개변수로는 func, delay값을 반드시 전달해야 하고, 세 번째 매개변수부터는 선택적이다. func는 함수이고, delay는 밀리초(ms) 단위이며, 나머지(param1, param2, ...)는 func 함수를 실행할 때 매개변수로 전달할 인자다.

 

func에 넘겨준 함수는 매 delay(ms)마다 실행되며, 그 결과 어떠한 값도 리턴하지 않는다. setInterval을 실행하면 반복적으로 실행되는 내용 자체를 특정할 수 있는 고유한 ID값이 반환된다. 이를 변수에 담는 이유는 반복 실행되는 중간에 종료(clearInterval)할 수 있게 하기 위해서다. 즉, setInterval 안에 들어가는 함수는 어떤 값도 리턴하지 않으며 대신 setInterval 자체에서 고유한 ID값을 반환한다.

 

위 코드를 보기 편하게 수정하면 아래와 같다.

 

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

 

위 코드를 실행하면 0.3초에 한번씩 숫자가 0부터 1까지 증가하며 출력되다가 4가 출력된 이후로 종료된다. setInterval이라고 하는 '다른 코드'에 첫 번째 인자로서 cbFunc 함수를 넘겨주자 제어권을 받은 setInterval이 스스로의 판단에 따라 적절한 시점에(0.3초마다) 이 익명함수를 실행했다. 이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수 호출 시점에 대한 제어권을 가진다.

 

▼ 코드 실행 방식과 제어권

 

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

 

 

 

4.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]

 

위 코드의 1번째 줄에서 newArr 변수를 선언하고 우항의 결과를 할당했다. 우항을 살펴보면 [10, 20, 30] 배열에 map 메서드를 호출하고 있다. 이때 map을 살펴보면 두 개의 매개변수를 전달하는데, 그 구조는 아래와 같다.

 

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

 

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

 

map 메서드는 메서드의 대상이 되는 배열의 모든 요소들을 처음부터 끝까지 하나씩 꺼내어 콜백 함수를 반복 호출하고, 콜백 함수의 실행 결과들을 모아 새로운 배열을 만든다. 콜백 함수의 첫 번째 인자에는 배열의 요소 중 현재값이, 두 번째 인자에는 현재값의 인덱스가, 세 번째 인자에는 map 메서드의 대상이 되는 배열 자체가 담긴다.

 

즉, 먼저 익명함수를 3회 호출한 뒤, 각 반환값을 모아 배열로 return 하므로 console.log(currentValue, index)가 먼저 3회 호출된 뒤 마지막으로 [15, 25, 35]가 담긴 newArr 값을 console.log(newArr)에서 출력한다.

 

제이쿼리(jQuery)의 메서드들은 기본적으로 첫 번째 인자에 index가, 두 번째 인자에 currentValue가 온다. map 메서드의 매개변수를 를 제이쿼리 방식처럼 순서를 바꾸어 (index, currentValue)로 사용하고, console.log(index, currentValue)로 출력해도 결국 위와 같은 결과가 나온다. 실제로 두 값의 순서가 바뀌는 것이 아닌, 값은 동일하고 변수명만 바뀐 것이기 때문이다.

 

즉, map 메서드를 호출해서 원하는 배열을 얻으려면 map 메서드에 정의된 규칙에 따라 함수를 작성해야 한다. map 메서드에 정의된 규칙에는 콜백 함수의 인자로 넘어올 값들 및 그 순서도 포함되어 있다. 콜백 함수를 호출하는 주체가 사용자가 아닌 map 메서드이므로 map 메서드가 콜백 함수를 호출할 대 인자에 어떤 값들을 어떤 순서로 넘길 것인지가 전적으로 map 메서드에게 달린 것이다. 이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수를 호출할 때 인자에 어떤 값들을 어떤 순서로 넘길 것인지에 대한 제어권을 가진다.

 

 

4.2.3 this

 

콜백 함수에서 별도로 this를 지정하는 방식 및 제어권에 대한 이해를 높이기 위해 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;
    }
};

 

메서드 구현의 핵심은 call/apply 메서드에 있다. thisArg에 값이 있을 때에는 그 값을, 없을 경우에는 전역 객체를 지정하고(thisArg || window), 첫 번째 인자에는 메서드가 this가 배열을 가리킬 것이므로 배열의 i번째 요소의 값을, 두 번째 인자에는 i 값을, 세 번째 인자에는 배열 자체를 지정해 호출한다. 그렇게 호출된 콜백 함수의 결과가 변수 mappedValue에 담겨 mappedArr의 i번째 인자에 할당된다.

 

이렇게 위 코드의 thisArg || window 부분과 같이 제어권을 넘겨받는 코드에서 call/apply 메서드의 첫 번째 인자에 콜백 함수 내부에서의 this가 될 대상을 명시적으로 바인딩 하기 때문에, this에는 다른 값이 담긴다.

 

아래는 map 함수에 thisArg를 따로 주지 않았을 때의 코드다. 이때 console.log(this)의 결과를 보면 Window의 결과를 출력한다.

 

var a = [10, 20, 30];
var b = [100, 200, 300]
var func = function(currentValue, index){
	console.log(this);	// Window { ... }
  return currentValue + index;
}
var result = a.map(func);
console.log(result);	// [10, 21, 32]

 

그러나 아래와 같이 map의 두 번째 인자로 다른 객체를 지정하면, this는 다른 값을 호출한다.

 

var a = [10, 20, 30];
var b = [100, 200, 300]
var func = function(currentValue, index){
	console.log(this);	// [100, 200, 300]
  return currentValue + index;
}
var result = a.map(func, b);
console.log(result);	// [10, 21, 32]

 

아래 코드는 Chapter 3에서 봤던 코드와 동일하다. 각 콜백 함수에서의 this에 대해 살펴보자면 (1)의 setTimeout은 콜백 함수를 호출할 때 call 메서드의 첫 번째 인자에 전역 객체를 넘기기 때문에 콜백 함수 내부에서 this가 전역 객체를 가리킨다. (2)의 forEach는 '별도의 인자로 this를 받는 경우'에 해당하지만, 따로 넘겨주지 않았으므로 this가 전역 객체를 가리킨다. (3)의 addEventListener는 내부에서 콜백 함수를 호출할 때 call 메서드의 첫번째 인자에 addEventListener 메서드의 this를 그대로 넘기도록 정의돼있기 때문에 콜백 함수 내부에서의 this가 addEventListener를 호출한 주체인 HTML 엘리먼트를 가리키게 된다.

 

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

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

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

 

 

 

4.3 콜백 함수는 함수다

 

콜백 함수는 함수이기 때문에, 콜백 함수로 어떤 객체의 메서드를 전달하더라도 그 메서드는 함수로 호출된다.

아래 코드는 메서드콜백 함수로 전달한 경우다.

 

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

 

메서드로서 호출한 경우에는 this가 메서드를 호출한 해당 객체를 가리키지만, 콜백 함수에서는 this가 전역 객체를 가리키는 것을 볼 수 있다. forEach에 의해 콜백이 함수로서 호출되고, 별도로 this를 지정하는 인자를 지정하지 않았으므로 함수 내부에서의 this는 전역 객체를 바라보게 된다.

 

 

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

 

별도로 인자를 받는 함수의 경우, 해당 인자에 원하는 값을 넘겨주면 되지만 그렇지 않은 경우에는 this의 제어권도 넘겨주게 되므로 사용자가 임의로 값을 바꿀 수 없다. 그래서 전통적으로 this를 다른 변수에 담아 콜백 함수로 활용할 함수에서는 this 대신 그 변수를 사용하게 하고, 이를 클로저(Chapter 5)로 만드는 방식이 많이 쓰였다.

 

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

 

위 코드에서 func 함수는 해당 객체의 this 값을 self라는 변수에 담고, self.name을 반환하는 함수를 리턴함으로써 console.log(self.name)에 전역 객체가 아닌 obj1 객체의 name 프로퍼티 값이 출력되도록 하였다. 만약 this를 안 쓰기 위해서는 obj1.name으로 직접 표기하는 것이 더 낫다. 다만 이 경우, obj1 이외의 다른 객체를 바라보게 할 수가 없다. 그러나 이러한 문제는 call이나 bind 메서드를 사용해 보완할 수 있다.

 

var obj1 = {
	name: 'obj1',
    func: function () {
    	console.log(this.name);
    }
};
var obj2 = { name: 'obj2' };

// call을 사용하는 경우
var obj3 = {
	name : 'obj3',
    func : obj1.func
};
var callback2 = obj1.func.call(obj2);
var callback3 = obj3.func();
setTimeout(callback2, 2000);
setTimeout(callback3, 1500);

// bind를 사용하는 경우
setTimeout(obj1.func.bind(obj1), 1000);
setTimeout(obj1.func.bind(obj2), 1500);

 

 

 

4.5 콜백 지옥과 비동기 제어

 

콜백 지옥(callback hell)은 콜백 함수를 익명 함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상으로, 자바스크립트에서 흔히 발생하는 문제다. 주로 이벤트 처리나 서버 통신과 같이 비동기적인 작업을 수행하기 위해 이런 형태가 자주 등장하곤 하는데, 가독성이 떨어질 뿐더러 코드를 수정하기도 어렵다.

 

비동기(asynchronous)는 동기(synchronous)의 반대말이다. 동기적인 코드현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식이고, 반대로 비동기적인 코드현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어가는 것이다.

 

CPU의 계산에 의해 즉시 처리가 가능한 대부분의 코드는 동기적인 코드다. 반면 사용자의 요청에 의해 특정 시간이 경과되기 전까지 어떤 함수의 실행을 보류한다거나(setTimeout), 사용자의 직접적인 개입이 있을 때 비로소 어떤 함수를 실행하도록 대기한다거나(addEventListener), 웹브라우저 자체가 아닌 별도의 대상에 무언가를 요청하고 그에 대한 응답이 돌아왔을 때 비로소 어떤 함수를 실행하도록 대기하는 등(XMLHttpRequest), 별도의 요청, 실행 대기, 보류 등과 관련된 코드 비동기적인 코드다.

 

교재 p.107쪽에 있는 커피를 리스트에 저장하는 코드는 번거롭고 복잡하며, 읽기 수월하지 않다. 해당 코드를 익명의 콜백 함수가 아니라 기명함수로 전환한 것은 교재 p.108쪽에 나와 있다.

 

그러나 이러한 부분도 일회성 함수를 전부 변수에 할당해야 한다는 문제가 발생한다. 코드명을 일일이 따라다녀야 하므로 오히려 헷갈릴 소지도 있다. 자바스크립트에서는 이러한 비동기적인 일련의 작업을 동기적으로, 혹은 동기적인 것처럼 보이게끔 처리해주는 장치를 마련하고자 노력해왔다. 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);
    });
});

 

위 코드는 Promise를 사용하는 방식으로 new 연산자와 함께 호출한 Promise의 인자로 넘겨주는 콜백함수는 호출할 때 바로 실행되지만, 그 내부에 resolve 또는 reject 함수를 호출하는 구문이 있을 경우 둘 중 하나가 실행되기 전까지는 다음(then) 또는 오류 구문(catch)으로 넘어가지 않는다. 따라서 비동기 작업이 완료될 때 비로소 resolve 또는 reject를 호출하는 방법으로 비동기 작업의 동기적 표현이 가능하다.

 

위 코드를 조금 더 간결하게 표현하면 아래와 같다.

 

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);
    	});
    };
});
addCoffe('에스프레소')()
	.then(addCoffee('아메리카노'))
	.then(addCoffee('카페모카'))
	.then(addCoffee('카페라떼'));

 

 

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

 

위 코드는 ES6의 Generator을 이용한 것이다. '*'이 붙은 함수가 바로 Generator 함수다. Generator 함수를 실행하면 Iterator가 반환되는데, Iterator는 next라는 메서드를 가지고 있다. 이 next 메서드를 호출하면 Generator 함수 내부에서 가장 먼저 등장하는 yield에서 함수의 실행을 멈춘다. 이후 다시 next 메서드를 호출하면 앞서 멈췄던 부분부터 시작해 그 다음에 등장하는 yield에서 함수의 실행을 멈춘다. 그러니까 비동기 작업이 완료되는 시점마다 next 메서드를 호출해준다면 Generator 함수 내부의 소스가 위에서부터 아래로 순차적으로 진행된다.

 

▼ 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(coffee_List);
    await _addCoffee('카페모카');
    console.log(coffee_list);
    await _addCoffee('카페라떼');
    console.log(coffee_list);
};

coffeeMaker();

 

ES2017에서는 가독성이 뛰어나면서도 작성법도 간단한 새로운 기능이 추가되었는데, 바로 async/await이다. 비동기 작업을 수행하고자 하는 함수 앞에 async를 표기하고, 함수 내부에서 실질적인 비동기 작업이 필요한 위치마다 await을 표기하는 것만으로 뒤의 내용을 Promise로 자동 전환하고, 해당 내용이 resolve된 이후에야 다음으로 진행한다. 즉, Promise의 then과 흡사한 효과를 얻을 수 있다.

'Javascript' 카테고리의 다른 글

인프런 리액트 강의 - Javascript 요약 ①  (1) 2023.12.07
Chapter 5 요약  (1) 2022.09.21
Chapter 3 요약  (0) 2022.09.13
Chapter 2 요약  (0) 2022.09.06
Chapter 1 요약  (0) 2022.09.04