Core JavaScript #4

milkboy2564·2023년 2월 13일
0

콜백함수란?

콜백 함수(Callback Function)은 다른 코드의 인자로 넘겨주는 함수를 말한다. 콜백 함수를 넘겨받은 코드는 이 콜백 함수를 필요에 따라 적절한 시점에 실행한다.

콜백함수는 제어권과 관련이 깊다. 이 요청을 받은 함수의 입장에서는 해당 조건이 갖춰졌는지 여부를 스스로 판단하고 Y를 직접 호출한다. 이처럼 콜백 함수는 다른 코드(함수 또는 메서드)에게 인자를 넘겨줌으로써 그 제어권도 함께 위임한 함수다.

호출 시점

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

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

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

func 는 함수고, delay 는 밀리초 단위의 숫자이며, 나머지(param1, param2, ...)는 func 함수를 실행할 때 매개변수로 전달할 인자다. func 에 넘겨준 함수는 매 delay 마다 실행되며, 그 결과 어떠한 값도 리턴하지 않는다. setInterval 를 실행하면 반복적으로 실행되는 내용 자체를 특정할 수 있는 고유한 ID 값이 반환된다.

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

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

인자

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 변수를 선언하고 우항의 결과를 할당했다. 5번째 줄에서 그 결과를 확인하고자 한다. 1번째 줄의 우항은 배열 [10, 20, 30]에 map 메서드를 호출하고 있다. 이때 첫 번째 매개변수로 익명 함수를 전달한다. 우선 map 메서드의 경우 다음과 같은 구조로 이뤄져 있다.

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

map 메서드는 첫 번째 인자로 callback 함수를 받고, 생략 가능한 두 번째 인자로 콜백 함수 내부에서 this 로 인식할 대상을 특정할 수 있다. thisArg 를 생략할 경우에는 일반적인 함수와 마찬가지로 전역객체가 바인딩된다. 콜백 함수의 첫 번째 인자에는 배열의 요소 중 현재 값이, 두 번째 인자에는 현재값의 인덱스가, 세 번째 인자에는 map 메서드의 대상이 되는 배열 자체가 담긴다.

제이쿼리의 메서드들은 기본적은 첫 번째 인자에 index, 두 번째 인자에 currentValue 가 온다.

var newArr2 = [10, 20, 30].map(function(index, currentValue) {
  console.log(index, currentValue);
  return currentValue + 5;
});
console.log(newArr2);

// -- 실행 결과 --
// 10 0
// 20 1
// 30 2
// [5, 6, 7]

여기서 알 수 있는 건 컴퓨터는 그저 첫 번째, 두 번째의 순서에 의해서만 각각을 구분하고 인식할 것이다. 그렇기에 우리가 첫 번쨰 인자의 이름을 'index'로 하건 'currentValue'로 하건 'potato'로 하건 상관없이 그 냥 순회 중인 배열 중 현재 요소의 값을 배정하는 것이다.

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

this

앞서 "콜백 함수도 함수이기 때문에 기본적으로 this 가 전역객체를 참조하지만, 제어권을 넘겨받을 코드에서 콜백 함수에 별도의 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;
  }
  return mappedArr;
};

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

이제 this 에 다른 값이 담기는 이유를 정확하게 알 수 있다. 바로 제어권을 넘겨받을 코드에서 call/apply 메서드에 첫 번째 인자에 콜백 함수 내부에서의 this 가 될 대상을 명시적으로 바인딩하기 때문이다.

콜백 함수는 함수다

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

  1. 메서드를 콜백 함수로 전달한 경우
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 객체의 logvalues 는 메서드로 정의됐다. 8번째 줄에서 메서드 앞에 점이 있으니 메서드로서 호출한 것이다. 따라서 this 는 obj 를 가리키고, 인자로 넘어온 1,2가 출력된다.

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

그러니까 어떤 함수의 인자에 객체의 메서드를 전달하더라도 이는 결국 메서드가 아닌 함수일 뿐이다.

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

위에서 객체 내부의 메서드를 콜백 함수로 전달하면 해당 객체를 this 로 바라볼 수 없게 된다고 말했다. 그럼에도 콜백 함수 내부에서 this 가 객체를 바라보게 하고 싶다면 어떻게 해야 할까?

별도의 인자로 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);

obj1.func 메서드 내부에서 self 변수에 this 를 담고, 익명 함수를 선언과 동시에 반환했다. 이제 10번째 줄에서 obj1.func 를 호출하면 앞서 선언한 내부함수가 반환되어 callback 변수에 담긴다. 11번째 줄에서 이 callback 을 setTimeout 함수에 인자로 전달하면 1초 뒤 callback 이 실행되면서 obj1 을 출력할 것이다.

이 방식의 아쉬움을 보완하는 훌륭한 방법이 바로 ES5에서 등장한 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);

콜백 지옥과 비동기 제어

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

동기적인 코드는 실행이 완료된 후 다음 코드로 넘어가는 코드를 의미하고 별도의 요청, 실행 대기, 보류등과 관련된 코드는 비동기적인 코드다.

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,
  '에스프레소'
);

가독성 문제와 어색함을 동시에 해결하는 가장 간단한 방법은 익명의 콜백 함수를 모두 기명함수로 전환하는 것이다.

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

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

promise에서 에러를 처리하는 방식에는 두 가지 방법이 있다.

  1. then()의 두 번째 인자로 에러를 처리하는 방법

  2. catch()로 에러를 처리하는 방법

function getData() {
  return new Promise(function(resolve, reject) {
    reject('failed');
  });
}// 1. then()의 두 번째 인자로 에러를 처리하는 코드
getData().then(function() {
  // ...
}, function(err) {
  console.log(err);
});// 2. catch()로 에러를 처리하는 코드
getData().then().catch(function(err) {
  console.log(err);
});

Promise를 사용하여 에러를 처리할 때는 가급적 catch()를 사용하여 에러를 처리하는 것이 좋다.

// 1. then의 두 번째 인자에서 에러 처리
function getData() {
  return new Promise(function(resolve, reject) {
    resolve('hi');
  });
}getData().then(function(result) {
  console.log(result);
  throw new Error("에러 발생");
}, function(err) {
  console.log('에러 : ', err);
});

// 2. catch를 통해 에러 처리
function getData() {
  return new Promise(function(resolve, reject) {
    resolve('hi');
  });
}getData().then(function(result) {
  console.log(result); // hi
  throw new Error("에러 발생");
}).catch(function(err) {
  console.log('에러 : ', err);
});

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

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

❗️Promise의 3가지 상태

  • Pending(대기) : 비동기 처리 로직이 완료되지 않은 상태
  • Fulfilled(이행) : 비동기 처리가 완료되어 프로미스가 결과 값을 반환한 상태
  • Rejected(실패) : 비동기 처리가 실패하거나 오류가 발생한 상태

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

정리

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

  • 제어권을 넘겨받은 코드는 다음과 같은 제어권을 가진다.

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

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

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

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

  • 비동기 제어를 위해 콜백 함수를 사용하다 보면 콜백 지옥에 빠지기 쉽다. 최근 ECMASript 에는 Promise, Generator, async/await 등 콜백 지옥을 빠져나갈 수 있는 많은 방법들이 생겼다.

profile
FE 개발자

0개의 댓글