Logic in Code,
Freedom in Travel.

인생 뭐 있나 사람 사는거 다 똑같지

Programming/노드 (NodeJS)

[Node.js] 자바스크립트 비동기 처리 - 콜백 지옥과 async 라이브러리

귀찮은 개발자2025. 5. 13. 11:07
목차 (Table of Contents)

싱글 스레드 기반의 Node.js 환경에서 동적 UI, 네트워크 통신, 파일 I/O 등과 '동시성(Concurrency)' 의 문제로 비동기 처리 전략이 항상 문제다. 

과거 법무법인에서 ERP를 개발하던 시절에 Ext.js를 사용하며 처음으로 진짜 '콜백 지옥'을 경험했다. 시간이 꽤 흐른 뒤, 우연히 마주한 레거시 코드에서 이 지옥을 다시 만났을 때 주님을 찾는건 어쩔 수 없었다. JavaScript 의 버전 업데이트가 진행되면서 불편한게 많이 개선되었지만 개선되기 전에 작성된 코드에는 Node.js 6 이라는 버전을 사용하는 프로젝트에서 콜백 지옥이 나를 기다리고 있었다. 

콜백 지옥

지금은 async/await 이 당연한 문법처럼 느껴지지만, Node.js 가 처음 등장했을 때 비동기 처리는 혁명적이었으나, 비동기 작업이 완료된 시점에 실행할 코드를 인자(Argument)로 넘겨주는 '콜백(Callback)' 은 답이 없었다. 

간단한 로직이면 문제가 없지만 코드를 작성하다 보면 가독성 등의 문제로 호락호락하지 않기 떄문이다. 

간단한 로지인 Legacy Code

const fs = require('fs');

fs.readFile('config.json', 'utf8', function(err, configData) {
    if (err) {
        console.error('설정 파일 로드 실패', err);
        return;
    }

    const config = JSON.parse(configData);
    db.connect(config.dbUrl, function(err, connection) {
        if (err) {
            console.error('DB 연결 실패', err);
            return;
        }
        connection.query('SELECT * FROM users', function(err, rows) {
            if (err) {
                console.error('쿼리 실행 실패', err);
                connection.end();
                return;
            }
            fs.writeFile('users.log', JSON.stringify(rows), function(err) {
                if (err) {
                    console.error('로그 저장 실패', err);
                } else {
                    console.log('작업 완료!');
                }
                connection.end();
            });
        });
    });
});

 

이러한 형태의 코드를 Pyramid of Doom 라고 부른다고 한다. 

이 패턴의 치명적인 문제점

  • 가독성 문제: 로직의 흐름이 아래가 아닌 오른쪽으로 간다. 닫히는 괄호의 짝을 찾는데 너무 많은 시간을 보낸다.
  • 에러 처리: 모든 콜백 단계마다 if (err) 를 반복해야한다. 예외를 누락하면 원인을 찾는데 너무 긴 시간이 걸린다.
  • 제어권의 상실: 비동기 작업의 순서를 제어하거나 병렬로 처리하기 위해 매우 복잡한 상태 관리를 직접 구현해야한다. 
  • 유지보수의 어려움: 로직 변경이나 순서 수정이 발생하면, 코드 전체를 다시 들여다봐야 한다.

caolan/async 라이브러리

콜백 지옥을 해결하기 위해 caolan/async 같은 라이브러리를 많이 사용했으며 아직도 많은 사람들이 레거시 코드의 유지보스를 위해서인지는 잘 모르겠으나 아직도 많이 사용하고 있다. 

https://www.npmjs.com/package/async

async.waterfall 의 사용 예시 

const async = require('async');
const fs = require('fs');

async.waterfall([
    function(callback) {
        fs.readFile('config.json', 'utf8', callback);
    },
    function(configData, callback) {
        const config = JSON.parse(configData);
        db.connect(config.dbUrl, callback);
    },
    function(connection, callback) {
        connection.query('SELECT * FROM users', function(err, rows) {
            callback(err, rows, connection);
        });
    },
    function(rows, connection, callback) {
        fs.writeFile('users.log', JSON.stringify(rows), function(err) {
            connection.end();
            callback(err);
        });
    }
], function (err, result) {
    if (err) {
        console.error('작업 중 에러 발생:', err);
    } else {
        console.log('모든 작업 완료');
    }
});

async.waterfall 이나 async.series를 사용하여 들여쓰기 지옥에서 어느 정도 벗어날 수 있다. 또한 마지막 콜백 함수에서 에러를 한 번에 처리할 수 있다는 점은 유지보수 관점에서 큰 장점이다. 하지만 외부 라이브러리에 강하게 의존해야 한다는점과 함수 시그니처가 복잡하다는 근본적인 한계는 해결되지 않았다. 이 문제는 ES6(ECMAScript 2015) 에서 Promise 의 도입으로 조금씩 해결이 되었다. 


 콜백 패턴은 원시적이고 불안정해 보이지만 당시 브라우저와 서버 양쪽에서 비동기 I/O를 처리할 수 있는 가장 가볍고 확실한 방법이었다. 돌이켜보면 절차적인 사고방식에서 벗어나 이벤트 기반(Event-driven) 으로의 사고를 유연하게 만들어해 준 계기였다.