Callback Hell

Callback Hell Article 번역해 보았습니다. http://callbackhell.com/

자바스크립트 비동기 프로그래밍 가이드

What is callback hell?

비동기 프로그래밍 또는 자바스크립트에서 callback을 사용하는 경우, 코드를 직관적으로 읽기가 쉽지 않습니다. callback을 많이 사용하는 경우 아래와 같은 모양을 띄는 경우가 많습니다.

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);
          aspect = values.width / values.height;
          widths.forEach(
            function(width, widthIndex) {
              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안에 callback이 계속 이어지는 모습은 callback hell이라는 이름으로 알려져 있습니다.

자바스크립트에서 Callback Hell이 나타나는 경우는 대게, 코드를 작성할 때 작업이 진행되는 순서를 위에서 아래로 쭉 작성하기 때문이며, 많은 사람들이 이 같은 실수를 합니다. C나 Ruby, Python 같은 다름 프로그래밍 언어는 어떠한 작업이 진행되던, 코드의 둘째 줄이 시작하기 전에 첫 번째 줄의 코드 실행이 종료됩니다. 여러분도 아마 짐작 하셨듯이, 자바스크립트는 이와는 다릅니다.

What are callbacks?

'Callback'이라는 이름 자체는 단순히 자바스크립트에서 관습적으로 사용하는 이름이며, 특별한 의미는 없습니다. Asynchronous(a.k.a async)는 '지금이 아닌 이후의 어느 시점에'라는 의미가 있습니다. Callback은 주로 다운로드나 파일을 읽을 때, 또는 데이터베이스와 접촉 등과 같은 I/O 진행 시 사용합니다.

일반적인 함수를 호출하면 아래와 같이 함수의 반환 값을 사용할 수 있습니다.

var result = multiplyTwoNumbers(5, 10);
console.log(result);
// 50 gets printed out

하지만, 비동기에서 함수는 callback을 사용하고 바로 결과를 반환하지 않습니다.

var photo = downloadPhoto('http://coolcats.com/cat.gif');
// photo is 'undefined'

위의 경우 사진을 다운로드 하기 위해서 시간이 필요할 것이며, 다운로드하는 시간 동안 여러분의 프로그램이 중단되길 원치 않을 것입니다.

대신, 사진 다운로드가 완료되면 실행될 함수를 보관해 두어야 할 것입니다. 이것이 바로 callback입니다. 'downloadPhoto'라는 함수에 callback을 전달하고, 이 함수는 callback을 다운로드가 완료된 이후의 어떠한 시점에 실행할 것입니다.

downloadphoto('http://coolcats.com/cat.gif', handlePhoto);

function handlePhoto(error, photo) {
  if (error) {
    console.error('Download error!', error);
  } else {
    console.log('Download finished', photo);
  }
}

console.log('Download started');

callback을 이해하기에 가장 큰 장애물은 프로그램이 실행되는 과정에서 작업이 실행되는 순서를 파악하는 것입니다. 위의 예에서, handlePhoto 함수가 선언됩니다. downloadPhoto 함수가 실행되고, handlePhoto가 callback으로 전달됩니다. 그리고 'Download started'라 프린트 됩니다.

handlePhoto 함수가 아직 실행되지 않았다는 점을 인지해야 합니다. downloadPhoto 함수가 자신의 임무를 완수할 때까지 이 함수는 실행이 되지 않으며, 사진 다운로드 작업이 완료될 때까지 시간이 많이 소요될 것입니다.

이 예는 두 가지 중요한 점을 보여줍니다.

  • handlePhoto callback은 미래의 어느 시점에 진행할 작업을 저장하는 용도로 사용합니다.
  • 위에서 아래로 코드가 쓰여진 순서대로 작업이 진행되지 않습니다.

How do I fix callback hell?

Callback hell은 코드를 작성하는 관행에서 비롯되며, 다행이 아래의 기준을 따르면 이를 바로잡기가 그리 어렵지는 않습니다.

1. Keep your code shallow

아래는 브라우저에서 서버로 AJAX 요청을 보내는 코드입니다.

var form = document.querySelector('form');

form.onsubmit = function formSubmit(submitEvent) {
  var name = document.querySelector('input').value;

  request(
    {
      url: 'http://example.com/upload',
      body: name,
      method: 'POST'
    },
    function postResponse(err, response, body) {
      var statusMessage = document.querySelector('.status');

      if (err) {
        return (statusMessage.value = err);
      }

      statusMessage.value = body;
    }
  );
};

위의 코드에서 함수에 이름을 붙여 주었는데, 함수에 이름을 지으면 아래와 같은 즉각적인 이익을 얻을 수 있습니다.

  • 함수의 용도에 알맞은 이름을 지어 주면, 코드의 가독성이 좋아집니다.
  • 프로그램에서 예외가 발생한 경우, 디버깅을 할 때 함수의 이름이 있으면 좀 더 작업이 수월해 집니다.
  • 이름을 지어줌으로써 함수를 이동시키고 참조값으로 추가할 수 있습니다.

함수에 이름이 있기 때문에, 이 함수의 위치를 이동시키도록 하겠습니다.

document.querySelector('form').onsubmit = formSubmit;

function formSubmit(submitEvent) {
  var name = document.querySelector('input').value;

  request(
    {
      url: 'http://example.com/upload',
      body: name,
      method: 'POST'
    },
    postResponse
  );
}

function postResponse(err, response, body) {
  var statusMessage = document.querySelecotr('.status');

  if (err) {
    return (statusMessage.value = err);
  }

  statusMessage.value = body;
}

함수 선언식은 호이스팅이 되기 때문에 파일의 맨 아래에 위치해 있습니다.

2. Modularize

이 부분이 가장 중요합니다. 누구나 모듈(aka libraries)을 쉽게 생성할 수 있습니다. Node.js의 Isaac Schlueter의 "Write small modules that each do one thing, and assemble them into other modules that do a bigger thing. You can't get into callback hell if you don't go there."와 같이 말했습니다.

위 코드를 몇 개의 파일로 나누어 모듈화 해 보도록 하겠습니다.

formuploader.js라는 두 개의 함수를 가지고 있는 파일을 생성했습니다.

module.exports.submit = formSubmit;

function formSubmit (submitEvent) {
  var name = document.querySelector('input').value;

  request({
    url: 'http://example.com/upload',
    body; name,
    method; 'POST'
  }, postResponse);
}

function postResponse (err, response, body) {
  var statusMessage = document.querySelctor('.status');

  if (err) {
    return statusMessage.value = err;
  }

  statusMessage.value = body;
}

'module.exports' 부분은 node.js 모듈 시스템의 일부이며, browserify를 사용하면 node, Electron, browser에서 모두 작동합니다. 모든 곳에서 작동을 하고 복잡한 설정 절차도 필요없기 때문에, 저는 이러한 방식의 모듈을 선호합니다.

이제 우리는 formuploader.js 파일을 만들었고, 이 파일은 browserified되 후 페이지의 script tag로 로드 돼 있습니다. 우리는 이것을 사용하기만 하면 됩니다.

var formUploader = require('formuploader');
document.querySelector('form').onsubmit = formUploader.submit;

우리의 애플리케이션은 두 줄의 코드로 줄었고, 아래와 같은 장점 또한 생겼습니다.

  • 신규 개발자가 코드를 접했을 때 읽기가 쉬워 졌습니다. formuploader 함수의 내용을 모두 읽어야 하는 일을 생략할 수 있습니다.
  • 같은 코드를 다시 작성하지 않고도, formuploader 함수를 재사용 할 수 있습니다.

3. Handle every single error

애플리케이션에는 다양한 에러가 발생할 수 있습니다. 이번 섹션에서는 아래의 세 번째 에러에 대해서만 다루도록 하겠습니다.

  • 개발자의 실수로 발생할 수 있는 Syntax error : 주로 프로그램을 처음 작동할 때 발생합니다.
  • 런타임 에러 : 코드가 실행이 되지만 버그로 발생할 수 있는 에러
  • 플랫폼 에러 : 유효하지 않은 파일, 하드 드라이브 및 네트워트 문제 등으로 발생하는 에러

첫 번째, 두 번째 사항은 우선적으로 여러분의 코드를 읽기 쉽게 만드는 데에 목적이 있습니다. 하지만 마지막 사항은 여러분의 코드가 안정적으로 작동할 수 있도록 하는데 초점이 있습니다. callback을 다루게 되면 background에서 어떠한 작업을 진행 및 완료하던가 또는 실패로 인해 중단해야 할 것입니다. 어떠한 숙련된 개발자라도 이러한 에러가 언제 발생할 지 알 수 없으니, 에러에 대해 계획을 세워야 한다고 말할것입니다.

에러를 처리하는데 가장 흔히 사용되는 방법은 Node.js 스타일입니다. 이 방법에서 callback의 첫 번째 인자는 error를 위해 사용됩니다.

var fs = require('fs');
fs.readFile('/Does/not/exist', handleFile);

function handleFile(error, file) {
  if (error) {
    return console.error('Uhoh, there was an error', error);
  }
  // otherwise, continue on and use 'file' in your code
}

첫 번째 argument로 error를 받는 것은 간단하지만, 우리가 에러 처리를 잊지 않을 수 있도록 하는 유용한 방법입니다. error를 첫 번째 인자로 받지 않을 경우, 에러에 대한 사항이 쉽게 무시될 수 있습니다. (ex, function handleFile (file) {} )

Summary

  1. 함수 nesting을 가능한 피하세요. callback 함수에 이름을 주고 알맞은 스코프에 위치 시키세요.
  2. 함수 hoisting을 사용하세요.
  3. 여러분의 모든 callback의 에러를 처리할 수 있도록 대비하세요. 필요할 경우 linter를 사용할 수 있습니다.
  4. 재사용 가능한 함수를 만들어 묘듈화 하세요. 여러분의 코드를 좀 더 이해하기 쉽게 만들 수 있습니다. 여러분의 코드를 작은 부분으로 나주면 에러 처리, 테스트 작성, 안정적인 API 생성 및 유지보수에도 도움이 될 수 있습니다.

callback hell을 피하는 방법 중 가장 중요한 부분은 함수를 이동시키는 것입니다. 이는 신규 개발자도 코드의 흐름을 읽는데 소요하는 시간을 줄일 수 있게 해 줍니다.

일단 시작은 함수를 파일의 마지막 부분으로 이동하는 것입니다. 그리고 이러한 함수들을 모듈화 시켜보세요. (ex, require('./photo-helpers.js') 그리고 마지막으로 독립적인 모듈을 만들 수도 있습니다. (ex, require('image-resize').

아래는 경험에 근거한 모듈을 생성하는 몇 가지 방법입니다.

  • 반복되는 코드를 함수로 작성합니다.
  • 함수가 길어지던가 또는 같은 작업을 처리하는 함수들이 쌓이면, 이러한 함수를 별도의 파일로 작성해 모듈화 할 수 있습니다.
  • 만약 다양한 프로젝트에 사용할 수 있는 코드가 있다면, 테스트를 거쳐 github과 npm에 공유하는 것도 좋은 방법입니다. 코드를 공유하는 것에는 멋지고 다양한 일들이 생길 수 있습니다.
  • 좋은 모듈이란 작고 한 문제를 다루고 있는 것입니다.
  • 모듈의 개별적인 파일은 150줄 이상의 코드가 되지 않게 작성하는 것을 추천합니다.
  • 모듈은 두 개 이상의 중첩된 폴더를 같지 않도록 해야 합니다. 만약 nesting된 폴더가 많다면 그 모듈은 너무 많은 작업을 하고 있을 가능성이 큽니다.
  • 아직 좋은 모듈에 대한 아이디어가 없다면, 주변의 숙련된 개발자에게 좋은 모듈의 예시에 대해 문의하세요. 만약 모듈을 이해하는데 몇 분이 걸린다면 그것은 좋은 모듈이 아닐 확률이 큽니다.

What about promises / generators / ES6 etc?

고급 솔루션을 찾아보기 전에 자바스크립트에서 callback이 가장 근복적인 부분이란 점을 인지해야 합니다. 상위 해법들도 모두 callback을 이해하는 것에 근간을 두고 있기 때문에, 다음 단계로 나가기 전 callback을 읽고 쓰는 방법을 배워야 합니다. 그리고 좀 더 나은 callback 코드를 쓸 수 있도록 항상 고민해야 합니다.

여러분의 비동기 코드가 위에서 아래로 읽을 수 있도록 작성하기 위해 몇 가지 좋은 기술이 있습니다. 이러한 방법들은 성능 또는 플랫폼 호환성 문제를 야기할 수 있기 때문에 꼭 사용 전 확인하고 사용하기를 제안합니다.

Promise는 top-down방식으로 비동기 코드를 작성할 수 있고 try / catch 스타일의 에러 헨들링을 할 수 있기 때문에 에러 처리에도 좋습니다.

Generator는 전체 프로그램을 멈추지 않고 각 함수의 실행을 멈출 수 있게 해 줍니다. 하지만 top-down 방식으로 작성을 하게 되면 코드 읽기가 좀 더 복잡해 질 수 있습니다.

Async functions는 ES7의 추가되었지만, 많이 사용 되고 있습니다.

개인적으로 저는 코드를 작성할 때 90%의 경우 callback을 사용합니다. 코드가 복잡해 지면 run-parallel, run-series의 개념을 사용합니다. 저는 callback, promise, 또는 다른 방식들이 큰 차이를 가지고 오지 않는다고 생각합니다. 큰 차이는 nested code, 모듈 등이 아닌 코드를 간결하게 작성하는 것에서 온다고 생각합니다.