2021-08-23 콜백 함수

2021-08-23
  • JavaScript

callback, 제어권을 위임하다

담백하게 콜백 함수(callback function)의 정의부터 한번 보고 가자.

A callback function is 1) a function 2) passed into another function as an argument, which is then 3) invoked inside the outer function to complete some kind of routine or action.

<Callback function>, MDN

위 정의를 통해 알 수 있는 콜백 함수란,

  • 함수이다.
  • 다른 함수에 인자로서 넘겨진다.
  • 어떤 루틴이나 액션을 완료하기 위해 인자로서 넘겨진 함수 내부에서 실행된다.

예컨대 다음과 같은 코드는 콜백 함수의 예시가 될 수 있다.

function greeting(name) {
  alert("Hello " + name);
}

function processUserInput(callback) {
  const name = prompt("Please enter your name.");
  callback(name);
}

processUserInput(greeting);

가장 먼저 processUserInput 함수가 실행되고 callback이라는 이름의 인자를 받는다. 인자로는 greeting이라는 함수가 전달 되고 있다. 이 함수는 processUserInput 내부로 전달되어 실행되며, 인자로 넘겨받은 값을 경고창에 띄운다.

여기서 greeting1) 함수이자, 2) 다른 함수에 인자로서 넘겨지고 있으며, 특정 로직을 수행하기 위해 3) 넘겨진 함수 내부에서 실행되고 있다. 따라서 콜백 함수로서의 조건을 갖추고 있다고 할 수 있다.

인자로 넘겨진 greeting은 콜백 함수, 콜백 함수를 인자로 받는 processUserInput고차 함수(Higher-Order Function, HOF)라고 부른다. 고차 함수는 외부로부터 함수를 전달 받아 자신의 일부로 합성하는 함수를 의미한다.

콜백 함수 개념의 핵심은 제어권의 전달에 있다. <코어 자바스크립트>에서는 콜백 함수를 다음과 같이 정의하고 있다.

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

제어권을 위임한다는 개념을 이해하는 것은 프로그래밍적인 측면에서 아주 중요한 의미를 갖는다. 이는 곧바로 추상화, 관심사의 분리와 연결되기 때문이다. Array.prototype.map과 같은 함수 또한 고차 함수와 콜백 함수 관점에서 다시 생각해볼 수 있다.

const arr = [1, 2, 3, 4, 5];

// callback function
function addOne(num: number) {
  return num + 1;
}

// higher-order function
const addOneArr = arr.map(addOne);

고차 함수와 콜백 함수를 합성하는 방법을 통해 기능의 관심사를 분리하고, 코드와 코드를 분리시켜 강결합되지 않는 형태로 유지할 수 있게 해준다. 각 코드의 결합이 느슨해지면서 변화에 유연한 코드를 작성할 수 있게 된다는 뜻이다.

예컨대 map은 ‘배열을 순회하면서 각각의 요소에 특정한 동작을 수행한 후 그 결과물을 새로운 배열로 리턴’하는 동작에 집중한다. 바꿔 말하면 그 동작에만 관심을 갖는 함수다.

한편 addOne이라는 함수는 ‘인자로 받은 number 값에 1을 더하여 리턴’하는 동작에만 관심을 갖는다.

만약 ‘배열을 순회하면서 각 요소에 특정한 동작을 수행한 뒤 그 결과를 새로운 배열로 리턴’하고 싶은데, ‘각 요소들을 제곱’하고자 한다면 map의 인자로 addOne이 아니라 square라는 함수를 전달하면 된다는 뜻이다.

아래 예제는 코드가 강결합되어 있어 다른 로직으로 변경하기 쉽지 않은 함수의 경우다. 위 예제와 반대 케이스이니 비교해서 보면 어렵지 않게 차이를 이해할 수 있을 것이다.

// BAD
function mapArrayAddOne(arr) {
  const nextArr = [];

  for (const item of arr) {
    nextArr.push(item + 1);
  }

  return nextArr;
}

const arr = [1, 2, 3, 4, 5];

mapArrayAddOne(arr);

고차 함수와 콜백 함수의 협업에 관해 <모던 자바스크립트 Deep Dive>에서 또한 동일하게 추상화의 중요성을 강조하고 있음을 확인할 수 있다.

위 예제의 함수들은 반복하는 일은 변하지 않고 공통적으로 수행하지만 반복하면서 하는 일의 내용은 다르다. 즉, 함수의 일부분만이 다르기 때문에 매번 함수를 새롭게 정의해야 한다. 이 문제는 함수를 합성하는 것으로 해결할 수 있다. 함수의 변하지 않는 공통 로직은 미리 정의해 두고, 경우에 따라 변경되는 로직은 추상화해서 함수 외부에서 함수 내부로 전달하는 것이다.


클로저와 함께 사용하는 경우도 주요한 패턴 중 하나다. Debounce를 대표적인 예시로 들 수 있겠다. Debounce는 미리 설정한 인터벌 내에 이벤트가 동작하면 더 기다리고, 그렇지 않다면 전달 받은 함수를 실행하는 래퍼(wrapper) 함수다. 여기서 debounce 내의 중첩 함수들은 내부 변수 timeout을 참조하고 있다.

const debounce = (func, wait) => {
  let timeout;

  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };

    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
};

const f = debounce((message) => console.log(message), 300);

f("hi");
f("hi");
f("hi"); // "hi"

Asynchronous callback

이러한 콜백 패턴은 특히 자바스크립트의 비동기 상황에서 유용하게 활용된다. 다음 예제는 node.js 환경에서 파일을 비동기로 읽어오는 fs.readFile 메서드를 활용한 예제이다.

var fs = require("fs");

fs.readFile("callback.md", function (err, data) {
  if (err) {
    return console.error(err);
  }
  console.log(data.toString());
});

파일을 읽어오는 동작은 가벼울 수도 있지만, 무거울 수도 있다. 또한 성공할 수도 있고, 실패할 수도 있다. 따라서 동기적인 코드로 작성하여 전체 스크립트를 blocking 하기 보다는 비동기로 처리하여 다른 코드를 먼저 실행하고 있는 편이 효율적이다.

fs.readFile 메서드의 첫 번째 인자에는 읽어올 파일명(혹은 경로)가 들어간다. 두 번째 인자로는 콜백 함수를 넘겨준다. 파일 읽어오기 동작이 완료(성공이든 실패든) 되었을 때 실행될 함수를 fs.readFile 내부에 전달한다. 적당한 때를 기다렸다가 자체적으로 우리가 넘겨준 함수를 실행하도록 하는 것이다.

이처럼 작업의 결과를 콜백으로 돌려주는 방식을 CPS(Continuation-Passing Style, 연속 전달 방식)이라고 한다.

function add(a, b, callback) {
  callback(a + b);
}

add(1, 2, (result) => console.log(result)); // 2

앞서 언급했던 Array.prototype.map 등의 메서드에 인자로 전달되는 함수는 CPS가 아니다. 배열 내의 요소를 반복하는데 사용될 뿐 연산 결과를 전달하지 않기 때문이다. (ex. result => console.log(result))

참고로 Node.js에서 비동기 작업에 사용되는 API 중 콜백은 마지막 인자로, 콜백 함수의 인자는 (error, data) => {} 형식, 즉 오류는 첫 번째 순서로 정의하는 것을 권장하고 있다.

한편 비동기를 값으로 다루는 방식인 Promise ES6에서 등장 후 점차 표준이 되어가고 있어 대부분의 비동기 API가 Promise를 지원하는 추세다. 하지만 그 전까지는 비동기 처리를 callback으로만 처리했기 때문에 이전에 callback 패턴으로 작성된 비동기 함수들을 Promise화 하기도 한다.

Promise콜백 패턴을 대신하여 비동기 처리에 애용되는 가장 큰 이유는, 물론 콜백 헬(callback hell)이 코드의 미관을 해치고 가독성을 낮추기 때문이다. 불필요한 분기가 증가하고, 에러 핸들링이 어려워짐은 물론이다.

하지만 Promise의 가장 더욱 큰 장점은 비동기 상황을 문장이 아닌 으로써 다루면서 코드의 표현력을 높이고, 후속 분기처리를 손쉽게 만들었다는 점에 있지 않나 싶다. 이 부분은 이후 제네레이터(Generator)와 함께 비동기 & Promise 파트에서 조금 더 자세히 다뤄보는게 좋겠다.

callback 패턴과 Promise를 활용하여 delay의 타입을 체크하는 간단한 setTimeout 함수 예제다. setTimeout의 비동기 동작을 Promise로 다룰 수 있게 되었다.

function mySetTimeout(delay, callback) {
  return new Promise(function (resolve, reject) {
    if (!delay || typeof delay !== "number") {
      return reject("invalid delay");
    } else {
      return setTimeout(function () {
        callback();
        resolve("resolved");
      }, delay);
    }
  });
}

// Promise { undefined } & 'hi'
mySetTimeout(1000, function () {
  console.log("hi");
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

// Promise { <rejected> 'invalid delay' }
mySetTimeout("1000", function () {
  console.log("hi");
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

Web API를 통해 비동기 작업을 해야 하는 경우에도 빈번히 사용된다. 다음은 브라우저 렌더링 최적화 작업 때 많이 사용되는 window.requestAnimationRequest()에 대한 MDN의 예시 코드다. 종료 조건이 존재하는 재귀를 사용하여 작성되었다.

let start = null;
const element = document.body;
element.style.position = "absolute";

function step(timestamp) {
  if (!start) start = timestamp;
  const progress = timestamp - start;

  element.style.left = Math.min(progress / 10, 200) + "px";

  if (progress < 2000) {
    window.requestAnimationFrame(step);
  }
}

window.requestAnimationFrame(step);

일급 함수 (First-class function)

콜백 함수와 곁들여 알아두면 좋을 개념이 바로 일급 시민(First-class citizen)이다. 우리가 함수를 콜백으로, 마치 값처럼 인자로 넘겨주고 다룰 수 있었던 이유는 바로 자바스크립트에서 함수가 일급이기 때문이다.

자바스크립트에서 객체는 ‘일급’이기 때문에 일급 객체라고 할 수 있다. ‘일등 시민’이라고 하면 조금 더 와닿는 어감이 될 것 같다. 차별 받지 않고 언어 내에서 지원하는 무엇이든 할 수 있다는 뜻이고, 값으로서 다룰 수 있다라는 뜻으로 해석하면 좋다. 조건은 다음과 같다.

  • 변수에 담을 수 있다.
  • 함수나 메서드의 인자로 넘길 수 있다.
  • 함수나 메서드에서 리턴할 수 있다.

한편 자바스크립트에서 함수는 객체다. C와 같은 언어에서는 그렇지 않다. 객체가 일급이므로, 함수 또한 일급이다. <함수형 자바스크립트 프로그래밍>에서는 일급 함수이기 위해 다음과 같은 추가 조건이 필요하다고 설명하고 있다. 함수형 프로그래밍에서는 함수를 값으로 다루며 다양한 표현을 사용하기 때문에 일급 개념을 특히 중요하게 생각한다.

  • 아무 때나(런타임에서도) 선언이 가능하다.
  • 익명으로 선언할 수 있다.
  • 익명으로 선언한 함수도 함수나 메서드의 인자로 넘길 수 있다.
const products = [
  { name: "반팔티", price: 15000, quantity: 1 },
  { name: "긴팔티", price: 20000, quantity: 2 },
  { name: "핸드폰케이스", price: 15000, quantity: 3 },
  { name: "후드티", price: 30000, quantity: 4 },
  { name: "바지", price: 25000, quantity: 5 },
];

const map = (f, iter) => {
  let res = [];
  for (const p of iter) {
    res.push(f(a)); // 함수를 인자로 받아서 어떤 값을 수집할 것인지 함수에게 완전히 위임하도록
  }
  return res;
};

map((p) => p.name, products);
const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator](); // ?
    acc = iter.next().value;
  }

  for (const a of iter) {
    acc = f(acc, a); // 더하기 연산 자체를 직접 써주지 않고 함수에 위임
  }
  return acc;
};

// 시작값을 주고 왼쪽에서 오른쪽으로 가며 함수를 처리하도록 함
// 인자들의 리스트를 하나의 값(최종 결과)으로 축약해나가는 컨셉이기 때문에 reduce
const go = (...args) => {
  // reduce(f, [acc,] iter)
  // reduce는 iter.next()에서 넘겨받은 값을 f로 실행시킨 결과를 acc에 저장
  // 지금은 acc가 주어지지 않아 iter.next()의 첫번째 값이 acc의 초기값으로 설정됨
  reduce((a, f) => f(a), args);
};

go(
  0,
  (a) => a + 1,
  (a) => a + 10,
  (a) => a + 100,
  console.log
);

이렇듯 함수를 값으로 다루는 패턴을 적극적으로 사용하다보니 각각의 함수에 대해서도 엄밀하게 생각하려는 경향이 있는 듯 하다. 앞서 언급한 <함수형 자바스크립트 프로그래밍>의 저자 유인동님은 무작정 함수를 인자로 넘겨준다고 해서 콜백 함수라고 불러서는 안된다고 주장한다.

표현의 제약은 상상력에도 제약을 만든다. 모든 익명 함수는 콜백 함수가 아니다. 다양한 로직을 가진 각기 다른 고차 함수들을 만들 수 있고, 그 함수에서 사용될 보조 함수에게도 역할에 가장 맞는 이름이 있는 것이 좋다.

나 또한 이 의견에 동의하여 최대한 콜백 함수와 여타 보조 함수(predicate, iteratee, listener)를 용어적으로 구분하여 사용하고자 한다.

아래 인용은 유인동님이 ‘콜백 함수’로 정의하고자 하는 경우에 대한 설명이다. 함수의 제어권을 넘겨줬다가 다시 돌려받는(특히 비동기 상황에서) 경우에 한하여 callback으로 부르자고 제안하고 있다.

콜백 함수를 받아 자신이 해야 할 일을 모두 끝낸 후 결과를 되돌려 주는 함수도 고차 함수다. 보통은 비동기가 일어나는 상황에서 사용되며 콜백 함수를 통해 다시 원래 위치로 돌아오기 위해 사용되는 패턴이다. 콜백 패턴은 클로저 등과 함께 사용할 수 있는 매우 강력한 표현이자 비동기 프로그래밍에 있어 없어서는 안 될 매우 중요한 패턴이다. 콜백 패턴은 끝이 나면 컨텍스트를 다시 돌려주는 단순한 협업 로직을 가진다.

필자는 위 경우 만을 ‘콜백’ 함수라고 부르는 것이 맞다고 생각한다. 컨텍스트를 다시 돌려주는 역할을 가졌기 때문에 callback이라고 함수 이름을 지은 것이다. 인자로 사용된 모든 함수를, 혹은 익명 함수가 넘겨지고 있는 모양을 보면 무조건 모두 ‘콜백’ 함수라고 칭하는 경향이 있다. 콜백 함수는 반드시 익명 함수일 필요가 없을 뿐 아니라, 익명 함수가 넘어가는 모양을 가졌다고 반드시 콜백 함수는 아니다.

아래 인용은 위에서 언급했던 여러 보조 함수들에 대한 언급이다. predicate, iteratee, listener 등에 관하여 설명하고 있다.

button.click(function() {})과 같은 코드의 익명 함수도 콜백 함수라고 표현되는 것을 많이 보았지만, 이 익명 함수는 ‘이벤트 리스너’라고 칭하는 것이 적합하다. 함수가 고차 함수에서 쓰이는 역할의 이름으로 불러주면 된다.

_.each([1, 2, 3], function() {})에서의 익명 함수는 callback이 아니라 iteratee이며 _.filter(users, function() {})에서의 익명 함수는 predicate다. callback은 종료가 되었을 때 단 한 번 실행되지만 iteratee나 predicate, listener 등은 종료될 때 실행되지 않으며 상황에 따라 여러 번 실행되기도 하고 각각 다른 역할을 한다.


예상 면접 질문

  • 자바스크립트의 콜백 함수 개념을 함수의 제어권과 연결지어 설명해주세요.

  • 사용하고자 하는 라이브러리에 콜백 패턴으로 작성된 비동기 API가 있습니다. 이를 Promise화 하는 코드를 작성해주세요. (ex. fs.readFile(path[, options], callback))


참고 자료

  • 정재남, <코어 자바스크립트>

  • 이웅모, <모던 자바스크립트 Deep Dive>

  • 유인동, <함수형 자바스크립트 프로그래밍>

  • Mario Casciaro, Luciano Mammino, <Node.js 디자인 패턴>

Profile picture

saengmotmi

'내가 원하는 건 문학이 아닌 기쁨이다.'