본문 바로가기

[자바스크립트] 콜백 함수란? (feat 코어 자바스크립트)

 

 

들어가며

많은 기업들이 타입 스크립트와 nest.js를 활용해서 서버 개발을 하곤 합니다. 취업을 하려면, 타입 스크립트와 nest.js를 공부해서 실무를 익히는 것이 중요할 것입니다. 하지만 아직 자바스크립트의 기초도 없는 상태에서 타입 스크립트와 nest.js를 공부하는 것이 맞을까 하는 생각이 들었습니다. 빠르게 기술변화를 적응하고, 러닝 커브를 줄이기 위해 빠르게 공부해야 하는 것도 맞겠지만, 그전에 언어의 기반이 되는 자바스크립트부터 제대로 알아야 하지 않을까 하는 생각이 들었습니다. 이 기회에 자바스크립트의 기본에 대해 정리해보고자 합니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

콜백 함수란?

콜백 함수는 다른 코드의 인자로 넘겨주는 함수입니다. 콜백 함수를 넘겨받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행합니다. 예를 들어 콜백 함수를 살펴보겠습니다.

 

 

1. 제어권

1-1. 호출 시점

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

 

1번째 줄에서 count 변수를 선언하고 0을 할당했습니다. 2번째 줄에서는 timer 변수를 선언하고 여기에 setInterval을 실행한 결과를 할당했습니다. setInterval을 호출할 때 두 개의 매개변수를 전달했는데, 그중 첫 번째는 익명 함수이고 두 번째는 300이라는 숫자입니다.

위의 코드를 아래와 같이 적을 수 있습니다.

 

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

var timer = setInterval(cbFunc, 300);

 

timer 변수에는 setInterval의 ID 값이 담깁니다. setInterval에 전달한 첫 번째 인자인 콜백 함수는 0.3초마다 자동으로 실행될 것입니다. 콜 백 함수 내부에서는 count 값을 출력하고, count를 1만큼 증가시킨 다음, 그 값이 4보다 크면 반복 실행을 종료하라고 합니다.

 

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

 

 

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

 

 

 

 

 

2. 콜백 함수는 함수다

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

 

 

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

 

 

obj 객체의 logValues는 메서드로 정의됐습니다. 7번째 줄에서는 이 메서드의 이름 앞에 점이 있으니 메서드로서 호출한 것입니다. 따라서 this는 obj를 가리키고, 인자로 넘어온 1, 2가 출력됩니다. 

 

한편 8번째 줄에서는 이 메서드를 forEach 함수의 콜백 함수로서 전달했습니다. obj를 this로 하는 메서드를 그대로 전달한 것이 아니라, obj.logValues가 가리키는 함수만 전달한 것입니다. 이 함수는 메서드로서 호출할 때가 아닌 한 obj와의 직접적인 연관이 없어집니다. forEach에 의해 콜백이 함수로서 호출되고, 별도로 this를 지정하는 인자를 지정하지 않았으므로 함수 내부에서의 this는 전역 객체를 바라보게 됩니다. 

 

그러니까 어떤 함수의 인자에 객체의 메서드를 전달하더라도 이는 결국 메서드가 아닌 함수일 뿐입니다. 이 차이를 정확히 이해하는 것이 중요합니다. 

 

 

 

 

 

 

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

콜백 함수 내부에서 this가 객체를 바라보게 하고 싶다면 어떻게 해야 할까요? 전통적으로는 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.func 메서드 내부에서 self 변수에 this를 담고, 익명 함수를 선언과 동시에 반환했습니다. 10번째 줄에서 obj1.func를 호출하면 앞서 선언한 내부 함수가 반환되어 callback 변수에 담깁니다. 11번째 줄에서 이 callback을 setTimeout 함수에 인자로 전달하면 1초(1000ms) 뒤 callback이 실행되면서 'obj1'을 출력할 것입니다.

 

또한 callback2에는 obj1의 func를 복사한 obj2의 func를 실행한 결과를 담아 이를 콜백으로 사용했습니다. callback3의 경우 obj1의 func를 실행하면서 this를 obj3가 되도록 지정해 이를 콜백으로 사용했습니다. 예제를 실행해보면 실행 시점으로부터 1.5초 후에는 'obj2'가, 실행 시점으로부터 2초 후에는 'obj3'이 출력됩니다. 이처럼 this를 우회적으로나마 활용함으로써 다양한 상황에서 원하는 객체를 바라보는 콜백 함수를 만들 수 있는 방법이 있습니다. 하지만 위의 전통적인 방식의 코드를 개선하려면 어떻게 할 수 있을까요? 바로 bind 메서드를 활용하면 됩니다. 

 

 

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

 

 

 

 

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

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

 

비동기는 동기의 반대말입니다. 동기적인 코드는 현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식입니다. 반대로 비동기적인 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어갑니다. CPU의 계산에 의해 즉시 처리가 가능한 대부분의 코드는 동기적인 코드입니다. 계산식이 복잡해서 CPU가 계산하는 데 시간이 많이 필요한 경우라 하더라도 이는 동기적인 코드입니다. 반면 사용자의 요청에 의해 특정 시간이 경과되기 전까지 어떤 함수의 실행을 보류한다거나 (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에서는 Promise, Generator 등이 도입됐고, ES2017에서는 async/await가 도입됐습니다. 이들을 이용해 위 코드를 수정한 내용을 각각 간략하게 소개하겠습니다. 

 

 

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

 

 

 

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

 

 

 

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 coffeeMaker = coffeeGenerator();
coffeeMaker.next();

 

 

위의 코드는 ES6의 Generator를 이용했습니다. 6번째 줄의 '*'이 붙은 함수가 바로 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);
}
coffeeMaker();

 

 

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

 

 

 

 

 

 

 

5. 정리

- 콜백 함수는 다른 코드에 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수입니다.

 

- 제어권을 넘겨받은 코드는 다음과 같은 제어권을 가집니다.

  1. 콜백 함수를 호출하는 시점을 스스로 판단해서 실행합니다.

  2. 콜백 함수를 호출할 때 인자로 넘겨줄 값들 및 그 순서가 정해져 있습니다. 이 순서를 따르지 않고 코드를 작성하면 엉뚱한 결과를 얻게 됩니다.

  3. 콜백 함수의 this가 무엇을 바라보도록 할지가 정해져 있는 경우도 있습니다. 정하지 않은 경우에는 전역 객체를 바라봅니다. 사용자 임의로 this를 바꾸고 싶을 경우 bind 메서드를 활용하면 됩니다. 

 

 

- 어떤 함수에 인자로 메서드를 전달하더라도 이는 결국 함수로서 실행됩니다.

 

 

- 비동기 제어를 위해 콜백 함수를 사용하다 보면 콜백 지옥에 빠지기 쉽습니다. 최근에는 Promise, Generator, async/await 등 콜백 지옥에서 벗어날 수 있는 훌륭한 방법들이 속속 등장하고 있습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

마치며

자바스크립트의 기본에 대해 공부하면서, 기본도 정확하게 알지 못하고, 앞으로 나아가려 했구나 하는 생각이 들었습니다. 기초이기에 지금 공부하는 내용을 더 잘 이해해야겠다고 생각했습니다. 기본에 충실할 수 있는 개발자가 되고 싶습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처

 

싸니까 믿으니까 인터파크도서

 

book.interpark.com