Callback과 Callback Hell

Intro

Node를 공부중이어서 로그인 구현을 위해 Passport를 사용하려고 했다.
그런데 Passport 이 라이브러리는 개인적으로 익숙했던 Promise 스타일의 라이브러리가 아닌, callback을 이용하기에, 처음 봤을 때 적잖이 당황했다. 물론 읽는데도 오랜 시간이 걸렸다.

var passport = require('passport');
var LocalStrategy = require('passport-local');
var crypto = require('crypto');

passport.use(new LocalStrategy(function verify(username, password, cb) {
  db.get('SELECT * FROM users WHERE username = ?', [ username ], function(err, user) {
    if (err) { return cb(err); }
    if (!user) { return cb(null, false, { message: 'Incorrect username or password.' }); }

    crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', function(err, hashedPassword) {
      if (err) { return cb(err); }
      if (!crypto.timingSafeEqual(user.hashed_password, hashedPassword)) {
        return cb(null, false, { message: 'Incorrect username or password.' });
      }
      return cb(null, user);
    });
  });
});

Passport 공식 문서에서 발췌. 링크

그래서 이를 이해하기 위해서 callback 스타일을 반강제적으로 공부하게 되었다. 그러면서 예전에 보았던 관련 글들도 더 와닿아서 이렇게 글을 작성하게 되었다.

Callback 관련 배경 내용을 안 쓰기 뭣해서 주저리주저리 쓰게 되었는데, 건너뛰고자 한다면 다음 포스트부터 읽으면 무난할 것 같다.

Callback 소개

Javascript 실행 환경(Browser, Node 등)에서 함수를 Non-blocking하게 처리하고 싶을 때 callback 함수를 인자로 갖는 함수를 사용할 수 있다.

가장 기본적인 콜백 함수의 사용 예시는 다음과 같다. (이 예제는 non-blocking한 처리와 상관이 없다.)

// Without callback
function sum(a, b) { return a + b; }
console.log(sum(1, 2)); // 3

// With callback
function sumCb(a, b, next) { return next(a + b); }
sumCb(1, 2, console.log); // 3

일반적으로 보는 함수의 경우 함수의 리턴값(3)을 받아서 다음 작업(console.log)를 수행하는 반면, 콜백함수를 사용하여 같은 함수를 구현하면 다음에 수행할 작업(console.log)을 인자로 받는 것을 알 수 있다.

이 예시의 sumCb는 blocking하는 함수이다. sumCb의 작업(a + b)이 끝날 때까지 기다린 후에 다음 작업이 수행되기 때문이다.

다음으로는 non-blocking하는 콜백 함수 활용 예시를 살펴보면 다음과 같다.

// With callback, asynchronous
function sumCb(a, b, next) {
  setTimeout(function () { next(a + b); }, 0);
}
sumCb(1, 2, console.log);
console.log("Test");
// `Test` 출력 이후 `3`이 출력

sumCb가 오래 걸린다고 가정하면, 이것이 끝날 때까지 기다리지 않고도 메인 스레드는 다음 작업을 수행할 수 있다.

Node의 error callback convention

Node의 함수의 경우 callback 함수는 다음과 같은 convention을 따른다.

function nodeCallback(error, result) {
  if (error === null) {
    // Error handling
  } else {
    // Result processing
  }
}

// 호출 예시
nodeCallback(new Error()) // 에러 발생 시
nodeCallback(null, "Hello World") // 성공 시

노드 공식 문서의 콜백 소개글
노드 공식 문서의 콜백 함수 컨벤션 소개글

Callback hell

non-blocking하는 작업을 진행하기 위하여 콜백을 쓰는 것까지는 좋은데, 이 스타일은 코드 작성자가 주의를 기울이지 않으면 피라미드 형태의 읽기 힘든 코드가 생성된다는 치명적인 문제가 존재한다. 이 문제가 얼마나 유명한지 관련된 사이트도 있다. 그 사이트의 예시를 살펴보자.

const fs = require("fs");
const gm = require("gm");

const source = "./images/in/"
const widths = [100, 200, 300]
const dest = "./images/out/"

fs.readdir(source, function (err, files) {
  if (err) {
    console.log("Error finding files: " + err);
  } else {
    files.forEach(
      function (filename, fileIndex) {
        console.log(filename);
        gm(source + filename).size(
          function (err, values) {
            if (err) {
              console.log("Error identifying file size: " + err);
            } else {
              console.log(filename + " : " + values);
              const aspect = values.width / values.height;
              widths.forEach(
                function (width, widthIndex) {
                  const height = Math.round(width / aspect);
                  console.log("resizing " + filename + " to " + height + "x" + height);
                  this.resize(width, height).write(
                    dest + "w" + width + "_" + filename,
                    function (err) {
                      if (err) console.log("Error writing file: " + err);
                    }
                  );
                }.bind(this)
              );
            }
          }
        );
      }
    );
  }
});

Refactoring의 한계

위 callback hell을 해결하기 위해 callbackhell.com에서는 콜백 함수들에 이름을 붙이고 function hoisting을 활용하여 함수들을 읽기 편한 순서로 배치하는 것을 권장하고 있다.

그런데 예시의 코드를 단순하게 함수를 분리하는 식으로 리팩토링하다 보면 봉착하게 되는 문제가 있다.

const fs = require("fs");
const gm = require("gm");

const source = "./images/in/";
const widths = [100, 200, 300];
const dest = "./images/out/";

fs.readdir(source, function (err, files) {
  if (err) {
    console.log("Error finding files: " + err);
  } else {
    files.forEach(resizeImageFile);
  }
});

function resizeImageFile(filename, fileIndex) {
  console.log(filename);
  gm(source + filename).size(resizeWithValues);

  function resizeWithValues(err, values) {
    if (err) {
      console.log("Error identifying file size: " + err);
    } else {
      console.log(filename + " : " + values);
      const aspect = values.width / values.height;
      widths.forEach(resizeWithWidth.bind(this));

      function resizeWithWidth(width, widthIndex) {
        const height = Math.round(width / aspect);
        console.log("resizing " + filename + " to " + height + "x" + height);
        this.resize(width, height).write(
          dest + "w" + width + "_" + filename,
          function (err) {
            if (err) console.log("Error writing file: " + err);
          }
        );
      }
    }
  }
}
  1. 매개변수 이름 짓기가 어렵지만 이건 필자의 문제인 것 같으니 넘어가자.

  2. resizeImageFile함수까지는 깔끔하게 분리가 되는 반면, resizeWithValues는 로컬 변수 filename을 참고하고 있어서 분리가 어렵다. 사실 이건 로컬 변수들을 모아 따로 파라미터로 전달해주면 해결되는 문제이다. 그렇게 분리하면 더 깔끔해질 것 같다.

  3. 가장 중요한 문제는, 각 작업이 뒤의 작업에 의존하고 있으며, 그 의존성이 함수 본문에 hard-coding되어 있다. 따라서 앞의 작업을 재사용하고 싶어도 뒤의 작업이 달라진다면 함수 자체를 갈아엎어야 한다.

  4. 역시 중요한 문제로, Error handling 역시 함수 본문에 hard-coding되어 있어, 에러를 다른 식으로 해결하고 싶어도 함수를 재사용하기 어렵다.

이 문제들을 해결하는 방법에 대해서는 다다음 포스트에서 계속.

좋은 웹페이지 즐겨찾기