Callback Hell 리팩토링하기

Intro

앞에서 Callback Hell의 다음 예시를 보았고,

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

Callback을 빼주는 것만으로는 함수들의 재사용이 어렵다는 것을 보았다.

그 이유 2가지는 다음과 같았다.

  • 각 작업이 뒤의 작업에 의존하고 있으며, 그 의존성이 함수 본문에 hard-coding되어 있음.
  • 각 작업의 error handling이 함수 본문에 hard-coding되어 있음.

이를 리팩토링하고자 한다.

Continuation-Passing Style로 정리하기

먼저 files.forEach(callback)까지만 앞에 따로 빼 보자.

function forEachFilenameInDir({ sourceDir }, next) {
  fs.readdir(sourceDir, function (err, filenames) {
    if (err) {
      console.log("Error finding files: " + err);
    } else {
      filenames.forEach(next);
    }
  });
}

forEachFilenameInDir({ sourceDir: source }, function (filename) {
  console.log(filename);
  ...
}

그 다음 size를 구하는 부분만 따로 떼면 좋을 것 같다.
여기서 안쪽에서 this를 사용하기 때문에, 그에 해당되는 값도 같이 전해주자.

function getImageSize({ filename, sourceDir }, next) {
  console.log(filename);
  const image = gm(sourceDir + filename);
  image.size(function (err, values) {
    if (err) {
      console.log("Error identifying file size: " + err);
    } else {
      next({ image, values });
    }
  });
}

그 다음 나머지 부분을 통째로 빼주면 다음과 같다.

function saveImage({ filename, values, destDir, image }, next) {
  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);
    const destFilename = destDir + "w" + width + "_" + filename;
    image.resize(width, height).write(destFilename, next);
  });
}

함수를 실행해주는 부분은 이렇게 된다.

const next = function (err) {
  if (err) {
    console.log(err);
  }
};

forEachFilenameInDir({ sourceDir: source }, function (filename) {
  getImageSize({ filename, sourceDir: source }, function ({ image, values }) {
    saveImage({ filename, values, destDir: dest, image }, next);
  });
});

각 작업은 뒤의 작업에 의존하고 있지 않고 서로 분리가 가능하다.

추가 리팩토링

지금은 error handling 코드가 고정되어 있다고 가정하고 함수 내에 고정시켜놓았다. 이를 함수와 분리시켜 보자.

forEachFilenameInDir는 다음과 같이 분해가 가능하다.

function forFilenamesInDir({ sourceDir }, next) {
  fs.readdir(sourceDir, next);
}

function handleFileError(err, next) {
  if (err) {
    console.log("Error finding files: " + err);
  } else {
    next();
  }
}

호출 부분은 이렇게 되는데...

forFilenamesInDir({ sourceDir: source }, function (err, filenames) {
  handleFileError(err, function () {
    filenames.forEach(function (filename) {
      getImageSize(
        ...
      );
    });
  });
});

여기서 forFilenamesInDir는 하는 일이 없기 때문에 다시 fs.readdir로 바꾸어주자.

fs.readdir(source, function (err, filenames) {
  handleFileError(err, function () {
    filenames.forEach(function (filename) {
      getImageSize(
        { filename, sourceDir: source },
        function ({ image, values }) {
          saveImage({ filename, values, destDir: dest, image }, next);
        }
      );
    });
  });
});

마찬가지로 다른 보조함수들도 쪼개줄 수 있으며 그러면 다음과 같게 된다.

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

const sourceDir = "./images/in/";
const newWidths = [100, 200, 300];
const destDir = "./images/out/";

function handleFileError(err, next) {
  if (err) {
    console.log("Error finding files: " + err);
  } else {
    next();
  }
}

function getImageSize({ filename, sourceDir }, next) {
  console.log(filename);
  const image = gm(sourceDir + filename);
  image.size(function (err, values) {
    next(err, { image, values });
  });
}

function handleImageSizeError(err, next) {
  if (err) {
    console.log("Error identifying file size: " + err);
  } else {
    next();
  }
}

function resizeAndSaveImage({ image, aspectRatio, newWidth, filename }, next) {
  const newHeight = newWidth / aspectRatio;
  const message = "resizing " + filename + " to " + newHeight + "x" + newHeight;
  console.log(message);
  const destFilename = destDir + "w" + newWidth + "_" + filename;
  image.resize(newWidth, newHeight).write(destFilename, next);
}

fs.readdir(sourceDir, function (err, filenames) {
  handleFileError(err, function () {
    filenames.forEach(function (filename) {
      getImageSize({ filename, sourceDir }, function (err, { image, values }) {
        handleImageSizeError(err, function () {
          console.log(filename + " : " + values);
          const aspectRatio = values.width / values.height;
          newWidths.forEach(function (newWidth) {
            resizeAndSaveImage({ image, aspectRatio, newWidth, filename }, function (err) {
              if (err) {
                console.log(err);
              }
            });
          });
        });
      });
    });
  });
});

안쪽의 함수들을 inline해주었더니 약간 지저분해지기는 했지만 공통 부분은 다시 잘 조합하면 해결되는 문제인 것 같다.

어쨌든 이제는 Continuation-passing style을 따라서 읽으면 자연스럽게 읽히는 것을 확인할 수가 있다.

좋은 웹페이지 즐겨찾기