콜백 지옥

callback hell

개요[편집 | 원본 편집]

콜백 지옥은 JavaScript를 이용한 비동기 프로그래밍시 발생하는 문제로서, 함수의 매개 변수로 넘겨지는 콜백 함수가 반복되어 코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상을 말한다. 이게 얼마나 혐오스러웠는지, express (프레임워크)등을 제작한 node.js의 거물 프로그래머 TJ가 js를 버리고 go로 가며 남긴 말이 callbacks suck이었을 정도.

2019년 기준으로는 Promise가 자바스크립트 생태계에서 표준처럼 사용되며 이를 이용한 async/await 패턴 역시 많이 사용하고 있어 콜백 지옥을 겪는 일이 거의 없어졌다고 봐도 무방하다.

[편집 | 원본 편집]

step1(function (value1) {
    step2(function (value2) {
        step3(function (value3) {
            step4(function (value4) {
                step5(function (value5) {
                    step6(function (value6) {
                        // Do something with value6
                    });
                });
            });
        });
    });
});

step1에서 어떤 처리 (주로 입출력) 이후 그 결과를 받아와, 인자로 전달된 익명 함수의 매개변수로 넘겨준다. 이후 step2에서 또 어떤 처리를 하고, 다음 익명 함수가 실행된다. 이를 반복하다보면 코드가 위에서 아래가 아니라 기묘한 피라미드 모양으로 기술되게 된다. 여기서 에러 처리가 포함되면 더욱 가관이다.

step1(function (err, value1) {
    if (err) {
        console.log(err);
        return;
    }
    step2(function (err, value2) {
        if (err) {
            console.log(err);
            return;
        }
        step3(function (err, value3) {
            if (err) {
                console.log(err);
                return;
            }
            step4(function (err, value4) {
                // 정신 건강을 위해 생략
            });
        });
    });
});

해결 방안[편집 | 원본 편집]

동기 함수를 사용한다[편집 | 원본 편집]

클라이언트 사이드의 ajax나, Node.js 환경의 fs.readFile과 같은 비동기 함수의 경우 동기 버전을 지원한다. Node.js에서 file.txt라는 파일을 읽어와 콘솔에 출력하는 코드는 다음과 같다.

fs.readFile('file.txt', 'utf8', function (err, result) {
    console.log(result);
});

이는 비동기 함수로, 리턴값은 없고 대신 콜백 함수에 결과를 전달한다. 동기 버전의 함수를 이용하면 다음과 같다.

var result = fs.readFileSync('file.txt', 'utf8');
console.log(result);

위에서 아래로, 아주 깔끔하고 전통적인 코드가 된다. 그러나 이 방법은 문제가 있는데, 이렇게 동기화된 입출력을 사용하면 그동안 다른 작업을 하나도 처리할 수 없게 된다는 점이다. 파일을 여는데 시간이 오래 걸린다면 특히나 문제가 크다. 브라우저 환경의 경우 JS가 실행되는 환경이 브라우저의 다른 작업들과 리소스를 공유하기 때문에 (JS의 주목적인 DOM 처리를 위해서 어쩔 수 없다.) 페이지 로딩이 잠시 멈춰 버리는 등, 사용자 경험에 아주 좋지 못한 영향을 미치게 된다. 또한 Node.js를 이용해 서버를 굴린다면, 한 사람의 요청이 끝날 때까지 다른 사람의 요청은 받을 수가 없다. 서버가 영 제 구실을 못하게 되는 것. 그래서 되도록이면 이 방법은 피하는게 좋다. 다만 서버의 초기 실행 단계와 같이 여러 작업을 병행할 필요가 딱히 없는 상황에서는 사용해도 무방하다. 또 다른 문제라면 많은 비동기 입출력 모듈 (mysql 등)이 동기 입출력을 아예 지원하지 않는 경우도 많다는 것.

콜백 함수를 분리한다[편집 | 원본 편집]

콜백 지옥이 발생하는 이유는 익명 함수를 연달아서 사용하기 때문이다. 익명 함수의 사용을 포기하고, 함수를 나눠 버리면 깔끔하다. 맨 위의 예를 다음과 같이 고칠 수 있다.

step1(afterStep1);
function afterStep1(value1) {
    step2(afterStep2);
}
function afterStep2(value2) {
    step3(afterStep3);
}
// 생략

위와 같이 하면, 콜백 함수를 계속 사용하더라도 들여쓰기가 깊어지지 않아 가독성이 향상된다. 단점은 전 단계에서 정의된 변수 등을 다음 함수에서 사용할 수 없다는 점. 완전히 바깥쪽에 변수를 선언해 사용해야 한다. 또한 이름 짓기도 영 힘들어진다.

Promise 패턴 도입[편집 | 원본 편집]

Promise는 콜백 문제를 해결하기 위해 제안된 패턴 중 하나로 여러 라이브러리를 통해 지원되어 오다가, ECMAScript 2015 표준으로 지정되었다.

somethingAsync(value1)
    .then((result) => {
        // 성공시 수행할 작업
    })
    .catch((error) => {
        // 실패시 수행할 작업
    });

then이나 catch 내에서는 어떤 값을 리턴하거나, 다른 Promise를 리턴할 수도 있다. 또한 then과 catch는 계속 붙여서 쓸 수 있다.

somethingAsync(value1)
    .then((result) => {
        return 42;
    })
    .then((result) => {
        return somethingAsync2(); //somethingAsnyc2에서 return된 값이 최종적으로 리턴된다.
    })
    .catch((error) => {
        return new Promise((resolve, reject) => {
            // 기존 방식의 비동기 작업을 수행하기 위해서는 new Promise를 이용해서 새 Promise 객체를 return하면 된다.
            // 성공할 경우 resolve에 원하는 값을 넣어 실행시키면 된다. 실패는 reject로 한다.
            resolve('성공');
            // reject('실패');
        });
    })
    .then((result) => {
        // 위의 then이나 catch가 성공했을 경우 이쪽이 실행된다.
    }, (error) => {
        // catch를 사용하는 대신에 then의 두번째 인자를 사용할 수도 있다.
    });

Promise가 return되는 경우, 비동기 작업이 끝나 resolve나 reject가 호출될 때까지는 정지해 있다가, resolve나 reject가 실행되면 then이나 catch를 호출한다.

이처럼 비동기 작업을 계속 매달아서 쓸 수 있으므로(Promise chain), 콜백이 안쪽으로 깊어지는 문제가 해결된다. 또한 then 내에서 throw하면 자동으로 가장 가까운 catch로 오류가 전달되기 때문에 에러처리가 직관적이고 실수할 가능성이 낮은 것도 장점이다.

Ajax 요청을 위해 새롭게 표준으로 지정된 fetch API는 이 Promise를 이용한다.

Async Function (async - await)[편집 | 원본 편집]

ECMAScript 2017 표준. 파이어폭스, 구글 크롬, 마이크로소프트 엣지, 사파리, 삼성 브라우저를 비롯한 대부부의 최신 브라우저에서 사용이 가능하고, node.js는 7.6.0 이후 버전에서 별도 설정 없이 사용할 수 있다. IE 등의 옛날 브라우저에서 이용하기 위해서는 Babel 등의 자바스크립트 트랜스파일러를 사용하면 된다. [1]

function f() {
    return somethingAsync(value1)
        .then((result) => {
            // 성공시 수행할 작업
        })
        .catch((error) => {
            // 실패시 수행할 작업
        });
}

이는 다음 코드와 같다.

async function f() {
    try {
        var result = await somethingAsync(value1);
        // 성공시 수행할 작업
    } catch (error) {
        // 실패시 수행할 작업
    }
}

await는 promise가 완료될 때까지 함수를 정지시키고, 완료되었을 경우 resolve되는 변수를 함수의 리턴값인 것처럼 사용할 수 있게 한다. 만약 promise가 reject된 경우, try ~ catch 구문을 이용해 잡을 수 있다. await를 사용하기 위해서는 그 코드가 실행되는 함수가 async function으로 선언되어야 하며, async function에서 리턴되는 값은 promise에서 resolve하는 것으로 취급한다.

async - await를 사용함으로써, 동기 함수와 완전히 같은 방식으로 비동기 함수를 사용하면서, 비동기 함수의 장점을 잃지 않을 수 있다. 단점이라면 아직까지 콜백 방식이나 Promise를 직접 이용하는 것보다 퍼포먼스가 낮다는 점. 그러나 async function은 자바스크립트 생태계에 빠르게 자리잡았고, 오히려 기존 콜백 방식의 코드를 보기 어려워질 지경이 되었다.

각주