JS - 콜백 함수

박상준

2024년 11월 18일

1

JavaScript

callback 함수

직전 포스트에서 콜백 함수에서의 this는 어떻게 되는지 대략적으로 알아봤었다. 그래서 이번에는 따로 콜백 함수가 뭔지, 어떻게 동작하는지 알아보겠다.

callback함수란?

간략하게 콜백 함수는 다른 코드의 인자로 남겨주는 함수이다. 그래서 콜백함수를 받은 코드는 필요한 시점에 코드를 실행하게 된다. 말이 어려운데 예시를 들어 설명해보겠다.

오전 7시에 일어나야하는 상황이라고 가정해보자. 직접 시간을 계속 확인하면서 기상 시간을 맞추는 방법과 시계 알람을 맞추는 것 중에 어떤 것이 더 편할까? 당연하게 알람을 맞추는 것이다. 콜백 함수를 이 개념에 도입해보면 아래와 같다.

  • 직접 시간 확인 : 시간을 보는 행위(함수)의 제어권은 자신
  • 알람 설정 : 시간을 보는 행위(함수)의 제어권은 시계

위에서 말한대로 동작할 함수를 다른 코드의 인자로 넘겨주게 되고, 제어권도 함께 넘겨주는 것이다. 그래서 알람은 내가 시간을 보지 않아도 제어권을 가지고 있기 때문에 시간을 알리는 동작을 하는 것이다. 그래서 callback을 뜯어보면, call은 '부르다', '호출하다'의 의미를 가지고 있고, back은 '뒤돌아오다'의 의미를 가진 단어들의 합성어이다. 즉, 어떤 함수를 호출하면 특정 조건일 때 돌아와서 함수를 실행해서 알려달라는 것이다.

제어권

콜백의 의미는 알았으니 제어권에 대해서 좀더 알아보겠다.

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

setInterval은 지정한 시간마다 함수를 동작하는 메서드이다. 그래서 첫번째 인자는 동작할 함수이고, 두번째 인자는 시간을 지정하는 것이다. 이제 func함수는 내가 호출하는 시점에 동작하는 것이 아닌 timer에 할당된 setInterval에 의해서만 동작한다. Image 도표를 통해 두 차이점을 비교해보면 더 명확하다. 함수로서 호출하게 되면 호출 주체와 제어권 모두 사용자에게 있지만, 콜백 함수로 실행되면 모두 제어권을 넘겨준 주체에 있다.

인자

콜백함수도 인자를 받을수 있다. 배열 메서드를 통해서 콜백 함수 인자를 알아보겠다.

var arr = [10, 20, 30].map(function (currentValue, index){
  console.log(currentValue, index);
  return currentValue + 5;
});
console.log(arr);
// 결과
// 10, 0
// 20, 1
// 30, 2
//[15, 20, 25]

map메서드는 배열의 각 항목에 콜백함수를 적용해서 새로운 배열을 생성하는 메서드이다. 콜백 함수의 첫번째 인자는 현재 배열 요소이고, 두번째 인자는 현재 배열 요소의 인덱스 값이다.

위의 예시에서는 두번째 인자까지만 사용했지만 세번째 인자도 있다. 세번째 인자의 경우 map메서드가 바인딩할 요소이다. 이건 밑에서 다시 설명한다.

그래서 위의 코드를 보면 첫번째 인덱스는 currentValue가 10, index가 0이고 리턴하는 값은 currentValue에 5를 더한 15가 된다. 이렇게 모든 배열의 요소를 순회하면서 나온 결과가 [15, 20, 25]이다.

그렇다면 저 두개의 값을 바꾸면 어떻게 될까?

var arr = [10, 20, 30].map(function (index, currentValue){
  console.log(index, currentValue);
  return currentValue + 5;
});
console.log(arr);
// 결과
// 10, 0
// 20, 1
// 30, 2
//[5, 6, 7]

위의 코드에서 인수 값의 위치만 바꿔줬는데 완전히 다른 결과가 나오게 되었다. 왜냐하면 인자로 부여한 currentValue나 index와 같은 접근은 사람의 기준에서 단어로 접근하는 것이다. 하지만 컴퓨터는 몇번째 인자에 어떤 값들이 오는지를 판단한다. 그래서 두번째 예제에서 index로 단어를 지정해줬지만 실제로는 현재 배열 요소의 값을 가리키는 것이다.

즉, 메서드의 콜백 함수를 지정할때는 메서드가 지정한 규칙에 맞게 사용을 해줘야한다. map과 같은 메서드는 콜백 함수의 인자로 넘어올 값들과 순서까지 정해져 있어서 사용자가 임의로 변경하거나 뺄수 없는 것이다. 이렇게 제어권을 넘겨주게 되면 콜백 함수의 인자까지 제어권을 가진다.

만약 map을 사용하는데 index만 사용하고 싶다면 arr.map((_, index) => { })와 같이 정해진 규칙대로 인자의 두번째 값으로 사용할 수 있도록 첫번째 인자를 비워줘야한다. 대부분 사용하지 않는 값들은 _를 사용해 사용하지 않음을 표시해준다.

this

전에 this에 대해서 포스팅할때 콜백 함수에서 this를 간단하게 설명했었다. 콜백 함수도 결국 함수이기 때문에 this를 명시해주지 않으면 this는 전역 객체를 바라본다.

setTimeout(function () {console.log(this);}, 300);
...
[1,2,3,4,5].forEach(function(i){
  console.log(this,i);
});

이렇게 두가지 예시를 확인해보면 첫번째와 두번째는 this를 명시하지 않고 함수만 전달했기 때문에 this는 전역 객체를 출력한다. 그래서 각 메서드마다 this를 몇번째 인자로 전달해야 하는지 확인해서 this를 명시할 수 있다.

Array.prototype.forEach = function (callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if (this.hasOwnProperty(i)) {
      callback.call(thisArg, this[i], i, this);
    }
  }
};

forEach의 내부 로직이다. 콜백함수의 첫번째 인자로는 함수를 받고 두번째는 this로 바인딩할 요소이다. 만약 두번째 인자가 비어있다면 undefined 또는 전역 객체가 할당된다. 객체 메서드나 배열 메서드는 내부의 call이나 apply와 같은 내부 로직에 의해 this를 바인딩해서 사용이 가능하다.

setTimeout은 조금 다르다.

const myArray = [1, 2, 3];
myArray.myMethod = function (sProperty) {
  console.log(arguments.length > 0 ? this[sProperty] : this);
};

setTimeout(myArray.myMethod, 1.0 * 1000); // 1초 후 "[object Window]" 기록
setTimeout.call(myArray, myArray.myMethod, 2.0 * 1000); // 오류

직접 thisArg와 같은 this를 명시하지 못한다. 이런 문제를 해결하는 방법이 두개있다.

setTimeout(function () {
  myArray.myMethod("1");
}, 2.5 * 1000); // 2.5초 후 2 기록
...
setTimeout(() => {
  myArray.myMethod("1");
}, 2.5 * 1000); // 2.5초 후 2 기록
...
const myBoundMethod = function (sProperty) {
  console.log(arguments.length > 0 ? this[sProperty] : this);
}.bind(myArray);

이렇게 3가지 방법이 있다. 첫번째와 두번째는 다른 함수로 감싸줘서 this가 전역 객체를 바라보지 않도록 하는 방법이다. 세번째는 직접 setTimeout으로 사용할 함수에 bind를 사용해 직접 this를 바인딩하는 것이다.

이렇게 콜백함수에서 this를 활용하는 방법은 메서드마다 다르다. 각 사용법을 잘 숙지해야 활용이 가능하지 않을까 싶다...

콜백 함수도 결국 함수

말장난 같지만 콜백 함수로 객체의 메서드인 함수를 전달하면 어떻게 될까. 함수로 동작할지 메서드로 동작할지 고민해보자.

var obj = {
  arr: [1, 2, 3],
  func: function(v, i){
    console.log(this, v, i);
  }
};
obj.func(3,4); // 1
[4, 5, 6].forEach(obj.func); // 2

첫번째는 객체의 메서드로 호출했기 때문에 this가 obj를 가리키고 인자로 지정한 1,2가 출력된다. 반면에 forEach의 콜백함수로 전달하게 되면 함수로 호출되기 때문에 배열의 요소와 인덱스가 v, i로 지정되고 this는 전역 객체가 출력된다. 이러한 차이를 이해하면 된다.

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

그럼 위의 상황같이 콜백 함수로 객체 메서드를 전달했을때 this가 객체를 바라보게 만드는 방법은 없을까? map이나 forEach와 같은 메서드의 콜백은 this를 명시해주는 인자가 있지만 없는 경우에는 할수 없다. 그래서 예전에 사용하던 방식을 소개해보겠다.

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

수동적이 방법이긴 하지만 확실한 방법이다. func내부에 self변수에 this를 담고, self를 출력하는 함수를 리턴하는 것이다. 실제로 this를 사용하는 것도 아니고 번거로운 코드 작성 방식이지만 재사용성 측면에서는 가장 전통적이다.

var obj2 = {
  name: 'obj2',
  func: obj1.func
}
var callback2 = obj2.func();
setTimeout(callback2,1000);

이렇게 obj1의 func를 복사해 obj2의 func에 담아주게되면 callback2의 this는 obj2를 담고 obj2를 출력한다.

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

함수에 call을 사용해 직접 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),1000);

이렇게 직접 this를 바인딩하면서 간편하게 this를 활용할 수 있게 되었다.

콜백 지옥

콜백이라는 단어를 들었을때 딱 떠오르는 단어다. 이름부터 개발자들을 많이 괴롭혀서 생긴 단어라는 느낌이 강하게 느껴진다. 콜백 지옥은 콜백 함수를 익명 함수로 전달하는 과정이 반복되어 코드의 들여쓰기 수준이 깊어지는 현상이다. 특히 서버와 통신하는 비동기 작업이나 이벤트 처리를 할때 방생하는데 코드 가독성이 떨어지고 유지보수성도 좋지가 않다.

setTimeout(() => {
  console.log("1초 경과: 첫 번째 작업 완료");

  setTimeout(() => {
    console.log("2초 경과: 두 번째 작업 완료");

    setTimeout(() => {
      console.log("3초 경과: 세 번째 작업 완료");

      setTimeout(() => {
        console.log("4초 경과: 네 번째 작업 완료");

        setTimeout(() => {
          console.log("5초 경과: 다섯 번째 작업 완료");
          console.log("모든 작업 완료!");
        }, 1000);
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

setTimeout을 사용해서 비동기 동작하는 로직을 작성했을때 딱봐도 유지보수하기 어렵다는 생각이 든다. 지금은 단순히 setTimeout만 있지만 여기에 다른 매개변수까지 포함되어 있다면 거꾸로 코드를 거슬러 올라가는 방식으로 코드를 읽어야한다. 그래서 이런 콜백 지옥을 막기위해 두가지 방법이 있다.

Promise

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

console.log("작업 시작!");

delay(1000)
  .then(() => {
    console.log("1초 경과: 첫 번째 작업 완료");
    return delay(1000);
  })
  .then(() => {
    console.log("2초 경과: 두 번째 작업 완료");
    return delay(1000);
  })
  .then(() => {
    console.log("3초 경과: 세 번째 작업 완료");
    return delay(1000);
  })
  .then(() => {
    console.log("4초 경과: 네 번째 작업 완료");
    return delay(1000);
  })
  .then(() => {
    console.log("5초 경과: 다섯 번째 작업 완료");
    console.log("모든 작업 완료!");
  })
  .catch((err) => {
    console.error("오류 발생:", err);
  });

new와 함께 생성한 Promise는 내부적으로 resolve와 reject 함수를 가진다. 그래서 두가지중 한가지라도 함수가 동작되었을때 .then이나 catch구문으로 넘어가는 특징이 있다. 그래서 delay함수를 만들고 내부에 setTimeout으로 resolve를 동작시켜주는 로직을 통해 비동기 동작이지만 동기동작처럼 코드를 작성할수 있다.

async / await

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function runTasks() {
  console.log("작업 시작!");

  try {
    await delay(1000);
    console.log("1초 경과: 첫 번째 작업 완료");

    await delay(1000);
    console.log("2초 경과: 두 번째 작업 완료");

    await delay(1000);
    console.log("3초 경과: 세 번째 작업 완료");

    await delay(1000);
    console.log("4초 경과: 네 번째 작업 완료");

    await delay(1000);
    console.log("5초 경과: 다섯 번째 작업 완료");

    console.log("모든 작업 완료!");
  } catch (err) {
    console.error("오류 발생:", err);
  }
}

runTasks();

async/ await은 비동기 작업을 동기 작업처럼 만들기 위한 더 쉬운 방법이다. 동작 방법은 동일하다. 비동기 함수 앞에 async를 붙여주고 이후에 추가 동작이 필요한 곳마다 await을 붙여주면 된다.

마무리

콜백 함수에 대해서도 알아봤다. 예전부터 코드를 작성할때 콜백함수, 콜백함수 얘기는 많이 했는데 정작 제대로 개념을 이해하고 사용하진 않았던 것 같다. 그냥 함수와 콜백함수가 별 차이가 없는줄 알았던 나날들이다. 하지만 이제는 알았으니 잘 이해하고 활용하기 위해 노력해야겠다. 다음은 클로저다. 예전에 프로젝트만들면서 잠깐 들었던 개념이라 어떤 것인지는 알지만 제대로 익히고 넘어간 내용은 아니라서 확실하게 배우도록 노력해야겠다.

개의 댓글