Async를 사용하여 콜백 정리하기

Javascript의 비동기 콜백 함수를 사용하다보면 인덴트로 인해 코드가 복잡해지는 경우가 많이 있다. async 라이브러리를 통해서 비동기 콜백 함수를 정리하여 순차적으로 실행되도록 할 수 있다.

비동기 콜백이란?

Javascript를 사용하다보면 비동기 콜백으로 인해 코드의 순서가 꼬이는 경우가 많다. Javascript의 비동기 콜백은 Java나 C 에서 Thread를 생성하여 메인 함수와 별도의 프로세스로 작업을 진행하는 것과 같다. 콜백을 사용하면 Thread를 신경쓰지 않고 코딩을 할 수 있어서 편리하지만, 단순한 순차적 작업을 진행할 때는 이러한 비동기 콜백으로 인해서 코드가 복잡해지고 흐름을 이해하기 힘들어지는 경우가 생긴다. (아래 코드 참조)

callback1(function () {
    callback2(function () {
        callback3(function () {
            console.log('end');
        });
    });
});

nodejs의 fs 모듈(filesystem)이나, http 모듈 등이 대표적으로 비동기 콜백 함수에 속한다. 데이터를 수집하여 저장할 때는 비동기보다는 동기로 코드를 작성하는 것이 편리한데 Sync 함수를 지원하지 않는 모듈들이 많이 있다. (mysql 같은…) 이럴 경우 재귀적인 호출을 통해 루프를 관리하여 코딩을 하여야 하는데 코드의 흐름이 한눈에 들어오지 않는다.


Async 모듈

Async 모듈을 사용하면 콜백들을 순차적으로 파이프라이닝 하면서 실행 할 수 있다. 또한 parallel 함수 등을 통해 병렬로 작업을 진행하여 통합할 수도 있고, 비동기 루프를 동기적으로 만들어 실행 할 수도 있다.

$ npm install async // Node.js
$ bower install async // bower

일반적인 웹 브라우져에서 사용할 때는 bower 등을 통해 async 모듈을 다운받아 html에서 script로 불러와 사용하면 되고, nodejs에서 사용할 때는 npm 으로 설치하여 모듈을 불러오면 된다.


Series

series 함수는 비동기 함수들을 순차적으로 실행하도록 도와주는 함수이다. 이 함수는 독립적인 작업을 순차적으로 실행하기 위해 사용된다. (이전 작업의 결과물에 상관없이 수행되는 작업일 경우)

async.series(tasks, done)

series 함수는 위와 같이 정의된다. 수행할 작업의 목록을 함수의 형태로 배열에 담아 tasks에 입력하여 주고, 작업을 모두 수행한 후 done이라는 callback 형태로 반환하여준다.

var async = require('async');

var tasks = [
    function (callback) {
        setTimeout(function () {
            console.log('one');
            callback(null, 'one-1', 'one-2');
        }, 200);
    },
    function (callback) {
        setTimeout(function () {
            console.log('two');
            callback(null, 'two');
        }, 100);
    }
];

async.series(tasks, function (err, results) {
    console.log(results);
    // [ ['one-1', 'one-2'], 'two' ]
});

tasks의 첫번째 함수에서는 0.2초 후에  one 이라는 메시지를 출력하고, 두번째 함수에서는 0.1초 후에 two 라는 메시지를 출력하도록 해놓았다. 이 함수를 asyncseries 함수로 실행하면 작업 시간은 첫번째 함수가 실행된 후 두번째 함수가 실행되어 총 0.3초가 걸린다. 각 수행의 결과는 results에 배열 형태로 반환된다.

작업 도중 에러가 발생했을때에는 각 함수의 callback의 첫번째 매개변수에 에러를 리턴해주면 다음 작업을 수행하지 않고 done에 error를 넘겨주어 작업이 중단되도록 되어있다. 각 작업의 에러를 무시하고 작업하려면 위의 코드와 같이 에러를 null으로 넘기면 에러가 발생하더라도 다음 작업으로 넘어가도록 할 수 있다.


Waterfall

waterfall 함수는 series와 같이 비동기함수를 순차적으로 실행하지만 각 작업의 결과를 다음 작업으로 넘겨줄 수 있다.

async.waterfall(작업리스트, 완료)

waterfall 함수는 위와 같이 정의된다. series와 동일한 구성이지만 각 콜백에서 넘긴 변수들이 마지막 완료 시점에 리턴되는 것이 아니라 다음 작업으로 전달된다.

아래와 같은 상황을 가정해보자.

  1. 데이터베이스의 movie 테이블에서 제목이 인사이드 아웃 인 영화의 코드를 찾는다.
  2. 1에서 찾은 영화 코드를 사용하여 review 테이블에서 인사이드 아웃 의 리뷰들을 찾는다.
  3. 2에서 찾은 리뷰를 insideout.txt로 저장한다.

위와 같은 작업을 하기 위해서는 code 라는 전역변수를 선언하여 series를 사용할 수 도 있지만 waterfall을 사용하면 전역변수를 사용하지 않고 구현 할 수 있다.

var async = require('async');
var fs = require('fs');
var mysql = require('mysql');
var connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: '',
    database: 'movie'
});

var tasks = [
    function (callback) {
        connection.query('select * from movie where title=? and year=?;', ['인사이드 아웃', 2015], function (err, row) {
            if (err) return callback(err);
            if (row.length == 0) return callback('No Result Error');
            callback(null, row[0]);
        })
    },
    function (data, callback) {
        connection.query('SELECT * FROM review WHERE movie_unique_id=?;', [data.code], function (err, rows) {
            if (err) return callback(err);
            callback(null, rows);
        });
    },
    function (reviews, callback) {
        fs.writeFile('insideout.txt', JSON.stringify(reviews), function (err) {
            if (err) return callback(err);
            callback(null)
        });
    }
];

async.waterfall(tasks, function (err) {
    if (err)
        console.log('err');
    else
        console.log('done');
    connection.end();
});

각 작업에서 callback에 데이터를 넘기면 series에서와 달리 다음 함수의 파라미터로 전달되어 다음 작업에서 사용할 수 있다.


Whilist 및 During

반복적인 작업을 한 후 특정 조건에서 작업을 끝낼 수 있도록 도와주는 함수이다. whilist는 조건문에서 다른 비동기 작업을 할 수 없어서 during을 쓰는 것이 더 편리하다.

async.whilist(조건, 반복수행작업, 완료)
async.during(조건, 반복수행작업, 완료)

whilistduring 의 함수는 위와 같이 정의된다. 두 함수가 같아보이지만 조건 함수에서 차이가 있다. whilist는 조건을 함수에서 boolean 형태로 리턴해서 루프를 제어하는 방식이고, during은 조건을 callback 함수로 리턴하여 루프를 제어하는 방식이다. 따라서 whilist 에서는 비동기 작업이 불가능하고, during 에서는 비동기 작업이 가능하다.

waterfall 예제에서 확장하여 아래와 같은 상황을 가정해보자.

  1. 데이터베이스의 movie 테이블에서 전체 영화의 목록을 불러온다..
  2. 1에서 찾은 영화 코드를 사용하여 review 테이블에서 각 영화의의 리뷰들을 찾는다.
  3. 2에서 찾은 리뷰를  movie_code.txt로 저장한다.

waterfall 함수를 사용할 경우 리뷰의 목록을 비동기로 패턴으로 처리할 때 다소 막히는 부분이 있다. 아래의 코드는 during 을 사용하여 위의 내용을 코드로 작성한 것이다.

var movie = [];
var index = 0;

async.during(
    function (reviews, callback) {
        if (!callback) {
            callback = reviews;
            connection.query('select * from movie;', function (err, rows) {
                if (err) return callback(err);
                movie = rows;
                callback(null, true);
            });
            return;
        }

        fs.writeFile('reviews/' + reviews.code + '.txt', JSON.stringify(reviews.reviews), function () {
            console.log(reviews.code + '.txt is saved');
            index++;
            callback(null, movie[index]);
        });
    },
    function (callback) {
        connection.query('SELECT * FROM review WHERE movie_unique_id=?;', [movie[index].code], function (err, rows) {
            if (err) return callback(err);
            callback(null, {code: movie[index].code, reviews: rows});
        });
    },
    function (err) {
        if (err) console.log(err);
        else console.log('done');
        connection.end();
    }
);

during의 조건 함수는 첫번째 루프에서 데이터가 넘어오지 않는다. 프로세스가 실행되는 순서가 check → 작업 → check → 작업 → ... → 완료 의 순서로 실행되기 때문에 맨 처음 함수에서는 데이터가 넘어오지 않는다. 이러한 구조를 이용하여 위의 함수에서는 맨처음 조건 함수 실행시에 movie 데이터를 불러오는 작업을 수행하도록 하였다.

during이나 whilist 함수를 사용하면 전역변수를 선언해야하고, 흐름을 보기 좋게 정리하는데에 아쉬움이 남는 부분들이 있긴 하지만, waterfall 작업의 콜백안에서 during을 중첩 실행하는 방식 보다는 코드를 정리하기에 편리한 듯 하다.

개인적으로는 during, whilist를 사용하는 것 보다는 waterfall을 사용하여 아래와 같은 재귀를 통한 비동기 콜백 패턴을 사용하는 것이 더 편리하다고 생각한다.

var tasks = [
    function (callback) {
        connection.query('select * from movie;', function (err, row) {
            if (err) return callback(err);
            callback(null, row);
        })
    },
    function (movie, callback) {
        var save = function (code, review, _callback) {
            fs.writeFile('reviews/' + code + '.txt', JSON.stringify(review), function () {
                console.log(code + '.txt is saved');
                _callback()
            });
        };

        var load = function (index) {
            if (!movie[index]) return callback(null);

            var data = movie[index];
            connection.query('SELECT * FROM review WHERE movie_unique_id=?;', [data.code], function (err, rows) {
                save(data.code, rows, function () {
                    load(index + 1);
                });
            });
        };

        load(0);
    }
];

async.waterfall(tasks, function (err) {
    if (err)
        console.log('err');
    else
        console.log('done');
    connection.end();
});

Forever

forever는 during, whilist와 마찬가지로 루프를 생성하여 작업을 수행하는 것이지만 중단을 해주는 조건 함수가 들어가지 않아 영구적으로 루프의 작업을 수행하는 함수이다.

async.forever(
    function(next) {
        // next is suitable for passing to things that need a callback(err [, whatever]);
        // it will result in this function being called again.
    },
    function(err) {
        // if next is called with a value in its first parameter, it will appear
        // in here as 'err', and execution will stop.
    }
);

next를 통해서 무한 반복을 수행하고 에러가 발생하면 작업이 중단되도록 구현 할 수 있다.


Parallel

개인적으로 async에서 가장 쓸만한 기능이라고 생각한다. 비동기 작업을 동시에 수행 한 후, 모든 작업이 종료 된 후에 완료 함수를 수행하여 준다. waterfall 이나 whilist 등은 재귀 패턴으로 간단하게 구현이 가능하지만 병렬 처리 후 동기 작업은 다소 복잡한데 async에서 parallel 함수를 사용하면 간단하게 해결이 된다.

parallel

시간별로 프로세스 작업을 구성해보면 위의 그림과 같다. 메인에서 async를 통해 각 작업을 호출하고 모든 작업이 끝나면 완료 시점에 콜백을 호출하여 메인 쓰레드로 결과물들을 돌려준다. 웹 데이터 수집 시 다중 쓰레드로 빠른 속도로 수집하고 싶을 때 사용하면 좋을 듯 하다.

var async = require('async');
var timestamp = new Date().getTime();

async.parallel([
    function (callback) {
        setTimeout(function () {
            console.log('one');
            callback(null, 'one');
        }, 2000);
    },
    function (callback) {
        setTimeout(function () {
            console.log('two');
            callback(null, 'two');
        }, 1000);
    },
    function (callback) {
        setTimeout(function () {
            console.log('three');
            callback(null, 'three');
        }, 3000);
    }
], function (err, results) {
    console.log(results, 'in ', new Date().getTime() - timestamp, 'ms');
});

위의 코드는 parallel 함수의 작업 순서를 확인하기 위해 작성해본 코드이다. 가장 오래걸리는 작업은 세번째 함수로 3초가 걸리도록 하였다. 만약 각 작업이 동시에 진행된다고 하면 총 작업 시간은 세 작업의 합인 6초가 아닌, 가장 긴 작업의 시간인 3초 일 것이다. 위의 함수를 실행해보면 총 시간이 3초인 것을 확인 할 수 있다.


참고자료

댓글 남기기