어서와, 개발은 처음이지?

자바스크립트 콜백의 문제점과 프로미스 쓰는 이유 본문

Javascript

자바스크립트 콜백의 문제점과 프로미스 쓰는 이유

오지고지리고알파고포켓몬고 2020. 6. 24. 15:02
반응형

 

 

자바스크립트는 웹페이지를 동적으로 변화시키기 위해 태어났고, 이러한 태생 덕분에 필연적으로 비동기처리 형태를 갖게됐습니다.

dom 조작을 위해 javascript 코드가 동작하는 와중에도 웹 페이지가 멈추지 않도록 해야했기 때문이죠.

 

이번 글에서는 자바스크립트에서 비동기를 처리하는 방식인 callback과 callback의 문제점, ES6에 포함된 promise가 이를 어떻게 해결하는지에 대해서 알아보겠습니다.

 

(비동기가 어떻게 동작하는지에 대한 내용은 추후에 따로 다루겠습니다. 이번 글에서는 callback과 promise에 대한 부분만 살펴보겠습니다.)

 

 

1. 비동기와 callback

 

자바스크립트는 싱글 스레드로 동작합니다. 그럼에도 javascript가 병렬적으로 비동기 코드를 실행하는 것처럼 보이는 것은 비동기 처리를 외부 api에 위임하고, 완료된 작업을 event loop를 통해서 반환받고, 다시 javascript의 실행 영역에서 callback을 실행하기 때문입니다.

 

javascript에서 흔히 볼 수 있는 비동기와 callback으로 setTimeout을 들 수 있습니다.

setTimeout(function () {   
    console.log('hi'); 
}, 1000); 

 

setTimeout을 통해 비동기처리가 외부 api(timer)에 위임되고, 1000ms가 지나면 callback 함수가 실행되어 콘솔창에 'hi'를 출력하게 됩니다.

 

또한 흔히 볼 수 있는 비동기와 callback으로 ajax 요청도 들 수 있습니다.

$.ajax({   
    type: 'POST',   
    url: 'http://something.com/members',   
    data: data,   
    error: function (error) {     
        console.log(error);   
    },   
    success: function (data) {     
        console.log(data);   
    }, 
});

 

위 코드도 마찬가지로 api를 요청하고 실행이 완료되면 그 결과에 따라 success 혹은 error callback을 실행하게 됩니다.

 

예전 작은 규모의 웹에서는 비동기 요청이 많지 않았고 각 비동기 요청간 의존성이 크지 않았지만, 웹이 발전하고 javascript가 서버를 비롯한 다양한 플랫폼으로 진출하면서 단순히 callback만으로 모든 상태를 통제하기 어렵게 됐습니다.

 

 

2. 비동기 요청(과 callback)으로 발생하는 안티패턴

 

요즘 교육과정에서는 어떻게 배우고 있을지 모르겠지만 제가 학교에서 수업을 들을때만해도 callback의 문제가 뭐냐 라고 물으면 주변 사람들은 '콜백 지옥'만을 이야기 했습니다.

 

또한 Promise를 사용하면 단순히 '콜백 지옥'을 해결하여 가독성을 높인다고 대답하는데, 참 모순적이게도 promise를 사용할 때도 우리는 then에 callback 함수를 넣어서 사용하고있습니다.

 

지금부터 callback에서 발생할 수 있는 문제, 정확히 말하자면 비동기 요청을 사용하므로써 발생할 수 있는 안티 패턴에 대해 알아보고 콜백 지옥이 아니라 promise가 이 문제들을 어떻게 해결할 수 있는지 알아보겠습니다.

 

 

2-1. 비동기 응답 의존 관계에 따른 callback 중첩

 

이 경우가 흔히 말하는 콜백 지옥의 예 입니다.

게시판에 글을쓰고 파일을 업로드하는 요청을 처리하는 서버 코드를 생각해볼 수 있습니다.

postRequest('/upload', function(req, res){
    var file = req.files[0];
    
    saveFile(file, function(originFileName){
        saveData(req.body, function(){       
            res.send("ok");     
        });   
    }); 
});

 

각 비동기 요청이 순차적으로 일어나는 것을 보장해야 하는 경우에 위와 같은 문제가 발생할 수 있습니다.

 


 

2-2. 비동기 대 비동기(혹은 비동기 대 동기) 간 경쟁관계 발생

 

이 경우는 위의 콜백 중첩패턴을 우회하기 위한 또다른 안티패턴으로 볼 수 있습니다.

var a = null; 
var b = null;  

function A(res) {   
    a = res;   
    if(a !== null && b !== null){     
        console.log(a + b);   
    } 
}); 

function B(res) {   
    b = res;   
    if(a !== null && b !== null){     
        console.log(a + b);   
    } 
});

 

여러 개의 비동기를 호출한 경우 A가 먼저 도착할수도, B가 먼저 도착할지 아무도 알 수 없습니다.

 

이를 방지하기위해 a 혹은 b 변수 값이 세팅되었는지 확인하는 작업을 각 콜백에 삽입해야하는 문제로, 관련 비동기 호출이 증가할수록 건드려야 하는 코드의 양이 기하급수적으로 증가합니다.

 


 

2-3. 비동기 결과값을 사용하는 여러 분기가 존재하는 경우

 

얼핏보면 2-1과 유사하지만 각 작업간에 의존관계는 존재하지 않는 경우입니다.

readyToBatch(function(res){   
    jobA(res);   
    jobB(res);   
    jobC(res); 
}); 

 

중첩문제를 떠나서 이런 형태는 callback이 여러 분기(jobA, jobB, jobC ...)에 대한 제어권을 갖기 때문에 좋지 않은 형태이며, 코드를 테스트를 하기 어렵게 만듭니다.

 


 

2-4. 에러 분기에 대한 처리

 

자바스크립트의 비동기 요청에서, 특히 nodejs의 api들은 callback의 첫번째 인자로 에러타입을 넣어주는 경우가 많습니다.

saveFile(file, function(err, res){   
    if(err){     
        // ...   
    }   
    // ... 
}); 

 

이런 패턴은 에러 처리 분기와 실제 로직의 결합도를 높이지만 다른 사례들에 비해 크리티컬한 안티패턴은 아닙니다.

 


 

2-5. 믿음성 문제

 

이 경우는 아주 극단적인 상황을 가정한 예시이지만 충분히 발생 가능한 문제입니다.

// 외부 라이브러리라 가정 
var payLibrary = {   
    pay: function(){     
        // 결제 진행   
    },   
    requestPay: function (product, cb){     
        // 라이브러리 개발자가 실수로 결제를 5번 진행하는 로직을 구현함     
        for(var i=0; i<5; i++){       
            var res = this.pay();       
            cb(res);     
        }   
    } 
};  

function buyProduct(product) {   
    payLibrary.requestPay(product, function (payInfo){     
        console.log(payInfo);   
    }); 
}  

buyProduct(someItem); 

 

먼저 우리는 payLibrary라는 오픈소스 결제모듈을 사용하고 있다고 가정합니다.

 

우리는 이 모듈을 사용하여 제품 구매(buyProduct) 로직을 만드려고 했는데, requestPay 요청하자 결제가 5번 진행되어버렸습니다.

 

극단적으로 보이지만 충분히 가능한 시나리오이며, 이를 방지하기위해 callback 실행 시 flag를 달아서 방어코드를 작성하는 식으로 우회할 수 있지만,

한 번 믿음이 깨졌다면 사실상 모든 경우의 수에 대비한 방어코드를 작성할 수 밖에 없게됩니다. 이는 곧 코드가 안티패턴으로 범벅이 되는 것을 의미하겠지요.

 

이제 promise에 대하여 간단하게 알아본 뒤, 위와 같은 안티패턴들을 어떻게 제어할 수 있는지 알아보겠습니다.

 

 

 

 

3. 프로미스(Promise)

 

프로미스는 비동기 처리를 제어하는, 혹은 기존 비동기 처리 코드들을 동일한(표준) api 형태로 사용할 수 있게 해주는 ES6에서 추가된 기능입니다.

 

프로미스 생성자 함수를 사용하여 다음과 같은 식으로 비동기 코드를 promise 객체화 할 수 있습니다.

function requestSomething(){   
    return new Promise(function(resolve, reject){     
        setTimeout(function(){       
            var errorFlag = Math.random() < 0.1 ? true : false;       
            
            if(errorFlag){         
                reject("error");       
            }       
            
            resolve("success");     
        }, 1000);   
    }) 
}

 

글의 처음에 소개했던 setTimeout을 사용하는 비동기 코드를 promise화 한 모습입니다.

 

간단하게 설명하면 프로미스 생성자에 넘겨주는 callback에 비동기 로직을 넣고, 정상 완료된 분기에서 resolve, 에러 상황 분기에서 reject를 호출해주도록 합니다.

 

위 프로미스는 다음과 같은 형태로 사용할 수 있습니다.

var p = requestSomething(); 

p.then(function(res){   
    console.log(res); 
}).catch(function(err){   
    console.log(err); 
}); 

 

requestSomething()을 호출하여 promise 인스턴스를 리턴받습니다.

 

이 promise인스턴스는 then과 catch 메소드를 사용하여 비동기 요청에 대한 결과를 핸들링할 수 있는데, setTimeout이 완료되고 random값이 0.1 이상이라면 then에 넘겨준 callback이 실행되고, 에러 상황(random이 0.1미만)이면 catch에 넘겨준 callback이 실행됩니다.

 

사실 이 글의 주제는 promise 사용법에 대한 내용이 아니므로, promise가 익숙치 않다면 다른 자료를 참고하시는게 좋겠습니다.

 

이제 이 promise가 2번에서 소개한 안티패턴들을 어떻게 해결할 수 있는지 보겠습니다.

 

 

3-1. 메소드 체인 형태로 callback depth를 균일화 함

 

promise의 then, catch는 callback의 return 값을 메소드 체이닝 형태로 사용할 수 있습니다.

 

다음과 같은 코드를 예로 들 수 있습니다.

p.then(function(res1){   
    console.log(res1);   
    return 10; 
}).then(function(res2){   
    console.log(res2); // 10   
    return "something"; 
}).then(function(res3){   
    console.log(res3); // something   
    // 반환 값을 명시하지 않으면 함수 종료 값(undefined)이 반환됨 
}).then(function(res4){   
    console.log(res4); // undefined 
}); 

 

이러한 원리로 2-1의 형태를 개선할 수 있습니다.

p.then(function(file){   
    return saveFile(file); 
}).then(function(filename){   
    return saveData(filename); 
}).then(function(result){   
    console.log(result); 
}); 

 

메소드 체이닝을 통해 각 요청들을 1depth에서 처리할 수 있습니다.

 

물론 실제 상황에서는 이렇게 간단하지 않을 수 있습니다.

저렇게 체이닝을 하기 위해서는 각 비동기 요청들을 promise화 하는 작업이 별도로 필요하기 때문입니다. (표준화된 규격의 중요성입니다.)

 

하지만 callback형태의 비동기를 promise화 해주는 라이브러리들이 많으므로 걱정하지 않으셔도 됩니다.

 


 

3-2. Promise.all

 

2-2의 경쟁조건은 all 메소드로 개선할 수 있습니다.

var a = Promise.resolve(10); 
var b = Promise.resolve(20); 

Promise.all([a, b]).then(function([resA, resB]){   
    console.log(resA + resB); // 30 
}); 

 

a와 b 모두 promise로 정의된 비동기일때 Promise.all 메소드에 인자로 넣어주면, 인자로 넣어준 모든 비동기(promise)가 실행완료된 시점에 callback을 실행하도록 할 수 있습니다.

 

덕분에 우리는 경쟁관계를 갖는 비동기 처리가 늘어나도 promise를 통해 편하고 직관적인 방식으로 처리할 수 있습니다.

 


 

3-3. promise는 한 번만 실행(귀결)됨

 

이 특징은 promise에서 아주 중요한 특징으로 프로미스로 생성된 비동기 인스턴스는 한 번만 실행(귀결)됩니다.

 

이 말은 한 번 생성된 promise 인스턴스에 아무리 then을 날려도 이미 결정된 promise는 같은 결과를 보여준다는 뜻입니다.

var p = Promise.resolve(Math.random()); 

p.then(function(res){   
    console.log(res); // 같은 값 
}); 

p.then(function(res){   
    console.log(res); // 같은 값 
}); 

p.then(function(res){   
    console.log(res); // 같은 값 
}); 

 

이런 특성을 이용하면 우리는 2-3의 패턴에서 비동기 요청에 대한 제어와 비즈니스 로직을 분리할 수 있습니다.

function readyToBatch(){   
    return Promise.resolve("result"); 
}  

function jobA(ready){   
    ready.then(function(batch){     
        // ...   
    }); 
}  
    
var ready = readyToBatch(); 

jobA(ready); 
jobB(ready); 
jobC(ready); 

 

단순한 예제를 통해서는 큰 공감이 안될 수 있습니다.

 

만약 기존 callback 형태를 사용했다면, 외부 모듈, 다른 비즈니스 로직을 추가해야 하는 경우 readyToBatch의 callback 안에 그 코드들을 때려넣어야 합니다. 즉, callback에게 제어권을 뺏깁니다.

 

하지만 promise를 사용하여 비동기 요청을 인스턴스로 관리한다면, callback에 로직을 추가해야되는 형태가 아니라 함수든, 외부 모듈이든 promise를 주입받아 어느곳에서나 로직을 구현할 수 있게 됩니다.

 

별것 아닌 것 같지만 아주 중요한 형태입니다.

 


 

3-4. 에러 분기는 catch로 전달

 

큰 특징은 아니지만, 프로미스는 error 분기를 catch 메소드에 위임하도록 할 수 있습니다.

var p = new Promise(function(resolve, reject){   
    saveFile(file, function(err, res){     
        if(err){       
            reject(err);     
        }     
        // ...   
    }); 
});  

p.then(/* 생략 */).catch(function(err){   
    console.log(err); 
}); 

 

만약 promise를 통해 값을 처리하던 도중 에러가 발생하면 메소드 체이닝을 통해 다음 catch 메소드에서 이를 잡아낼 수 있습니다.

 


 

3-5. promise는 한 번만 실행(귀결)됨

 

3-3과 같은 특징이지만 이번에는 인스턴스 생성 과정을 보겠습니다.

var p = new Promise(function(resolve, reject){   
    for(var i=0; i<10; i++){     
        resolve(i);   
    } 
}); 

p.then(console.log); // 0 
p.then(console.log); // 0 
p.then(console.log); // 0 

 

promise에서 resolve나 reject가 한 번 발생하면 더이상 다른 상태로 변화하지 않습니다.

 

여러가지 resolve 분기가 존재하고, 각 분기에 대한 예외를 미처 파악하지 못해서 resolve가 여러번 요청되어도, 처음 실행된 resolve로 귀결되기 때문에 2-5와 같은 상황을 예방할 수 있습니다.

 

 

4. 정리

 

위에서 살펴본 바로 promise에 대해 정리하자면 아래 키워드를 들 수 있겠습니다.

 

- ES6부터 정식으로 명시된 비동기 처리 api

- 한 번만 실행(귀결)되는 특징

- 기존 비동기 요청 형태에서 발생하는 안티패턴을 개선할 수 있도록 표준화 함

 

결과적으로 promise는 완전히 새로운 개념이 아니라, 비동기 처리에서 발생하던 안좋은 패턴들을 개선할 수 있도록 도와주는 제안된 새로운 비동기 표준이라고 볼 수 있겠습니다.

 

 

5. 마무리

 

promise를 사용하는 방법에 대한 글은 많은데, promise가 왜, 무엇을 바꿔놨는지에 대한 글은 쉽게 찾을 수 없어서 작성해봤습니다. (읽는 사람 입장에서 잘 읽혔을지 모르겠습니다.)

 

다만 우리가 어떤 기술을 사용할 때, 'why use'라는 알맹이를 알고있어야 그 기술을 바람직하게 사용할 수 있다는 것을 기억해야합니다.

 

만약 기존 비동기 처리에서 어떤 부분이 문제인지 모른다면 promise를 사용하여도 얼마든지 같은 실수를 반복할 수 있기 때문입니다.

 

순서가 좀 바뀐 것 같지만 다음 글에서는 promise와 async await에 대한 글을 작성해보도록 하겠습니다.

 

 

 

 

Comments