자바스크립트 엔진은 싱글 스레드기반이고, 싱글 스레드는 한 번에 한 가지 일만 순서대로 처리할 수 있다. 따라서 모든 프로세스가 동기적으로 처리되며, 먼저 시작한 일이 끝나야 다음 일을 시작할 수 있다. 하지만 상황에 따라 먼저 시작한 일이 끝나기 전에 다음 일을 시작해야하는 경우가 있고 이런 때 비동기 처리가 필요하다.
서버에 데이터를 요청하는 작업을 예로 들자면 자바스크립트 엔진의 역할은 서버에 데이터를 요청하고, 응답을 받아서 후속 처리를 하는 것이다. 그 사이에 요청에 대한 처리, 가공, 응답 등은 서버가 하는 일이기 때문에 자바스크립트 엔진은 후속 처리를 위해 불필요하게 서버의 처리가 끝날 때 까지 기다려야만 한다. 이 때 서버에 요청하고 응답이 오는걸 기다리지 않고 나머지 코드를 실행하고, 응답이 왔을 때 후속 처리를 진행하면 불필요한 대기 시간을 없앨 수 있다.
즉, 비동기 처리는 한 번에 한 가지 일만 순서대로 처리하는 자바스크립트 엔진이 시간이 걸리는 일(서버에 데이터 요청, 몇 초후에 동작 등)의 결과를 나중에 처리하도록 컨트롤하는 작업이다.
과거의 웹 페이지는 서버에서 페이지를 통째로 받아와서 보여주기만 할 뿐이었다. 따라서 비동기 처리가 필요 없거나, 있더라도 복잡하지 않았다. 하지만 모던 웹 페이지는 점점 복잡해졌고, 사용자와 각종 API간의 상호작용에 따라 변화무쌍한 화면을 나타낸다.
이 상호작용에 대한 처리와 그 결과를 모두 동기적으로 다루게 되면 사용자는 매 순간마다 그 결과를 기다려야하고, 해당 서비스를 사용하는 경험이 매우 불편해질 수 밖에 없다.
예를 들어 사용자가 웹 페이지에 접속한 이후로 각종 이미지도 불러오고, 서버로부터 필요한 데이터를 불러오며, 로그인 상태라면 유저 정보도 받아오고, 채팅창을 연결해둔다던가 지도 서비스를 연결한다던가.. 이런 동작을 하나하나 순서대로 처리하면 아마 사용자는 답답해서 서비스 이용을 포기하게 될 것이다.
요약하자면, 웹이 더 복잡해졌기에 처리해야할 비동기 동작들이 많아졌고, 그에 따른 처리 비용이 늘어나게 되었기에 비동기 로직들을 잘 처리하는 게 중요해졌다.
그렇다면 웹 세계에서는 이 비동기 처리를 어떻게 처리해왔는지 살펴보자.
requestLogin('/login', (response) => {
console.log(`로그인이 ${response.message} 했습니다`);
});
위 코드는 requestLogin
함수 내부에서 로그인 요청 후, 그 응답값 response
를 전달받은 콜백 함수에 전달하며 실행하는 방식으로 진행될 것이다.
하지만 여기서 의문이 든다. 어떻게 응답값을 받은 '후'에 그 값을 콜백에 넣어서 실행하는가? 자바스크립트 엔진은 block되지 않았는데 말이다. 이 또한 다른 글에서 다시 다루겠지만, XMLHttpRequest
, setTimeout
같은 Web API를 사용해서 해당 로직을 Task Queue
에 옮기고 이벤트루프가 이를 다시 콜스택에 옮기는 방식으로 이루어진다. 자세한 내용은 직접 검색해보거나, 언젠가 작성할 글을 참고해주길.
이러한 방식의 가장 대표적인 단점은 콜백지옥이 발생할 수 있다는 것이다.
requestLogin('/login', (response) => {
getUserData('/users', response, (userData) => {
getTodoList('/todos', userData, (todolist) => {
console.log(todolist);
});
});
});
로그인 요청을 하고, 응답값을 기반으로 유저의 정보를 요청하고, 받아온 유저 정보를 기반으로 todolist를 받아온 후에, console에 찍는 로직이 있다고 가정해보자. (물론 실제로 이런 비효율적인 구조를 작성할 일 없지만, 예시를 위해서)
이렇게 다음, 다음, 다음의 로직을 계속 콜백으로 넘겨주면서 점점 깊어지는 현상을 콜백지옥이라 부른다. 아주 단순하게 예시를 들었지만 실제로 코드가 복잡해지면 가독성이 떨어진다.
try {
requestLogin('/login', (response) => {
getUserData('/users', response, (userData) => {
getTodoList('/todos', userData, (todolist) => {
console.log(todolist);
});
});
});
} catch (e) {
console.log(e);
}
위와 같이 에러 핸들링을 위해 try catch를 사용할 때 콜백 함수에서 발생한 에러가 catch되지 않는다는 문제가 있다.
try catch는 자신이 속한 컨텍스트에서 발생하는 에러만 감지할 수 있기 때문이다. 콜백 함수는 현재 컨텍스트가 아닌 다른 곳에서 실행되기 때문에 콜백 함수가 실행되는 시점은 이미 try catch 구문이 끝난 후가 된다.
이 문제를 해결하려면 모든 비동기 처리 함수, 위의 예시에서 requestLogin
, getUserData
, getTodoList
함수가 각자의 try catch를 가져야한다.
const response = requestLogin('/login');
console.log(response); // undefined
const globalObject = {};
function requestLogin(url) {
globalObject.response = requestLogin;
}
console.log(globalObject.response); // undefined
콜백 지옥은 좋아서 생기는게 아니라 구조상 어쩔 수 없이 생기는 것이다. 비동기 처리 결과를 전역 객체에 저장을 하든, 결과값을 return하든 이미 그 시점에서 외부 소스 코드는 실행을 마친 후이다.
콜백 지옥을 만들면서 안으로 들어가지 않는 이상, 비동기 처리 결과를 비동기 함수 외부로 전달하는 방법은 없었다. 이 문제는 이후에 설명할 Promise에서도 해결되지 못 했고, 더 이후에 ES7에 등장한 async/await를 통해 해결되었다.
Promise
가 등장했다. Promise의 구체적인 동작 원리는 다른 글에서 자세히 다루어볼 예정이다. 이글에서는 Promise가 어떻게 콜백 패턴의 단점을 보완했는지에 초점을 맞춰보자.new Promise((resolve) => {
resolve(requestLogin('/login'));
})
.then((response) => getUserData('/users', response))
.then((userData) => getTodoList('/todos', userData))
.then((todolist) => {
console.log(todolist);
});
then
메소드는 전달받은 콜백 함수를 처리한 후 새로운 Promise 객체를 반환하고, 그 Promise 객체가 다시 then
메소드를 사용 하는 방식으로 후속처리를 이어간다. 이렇게 depth를 유지하기 콜백 패턴보다 가독성이 좋다. then
메소드의 두 번째 인자에 에러 핸들링 함수를 전달하는 것이고, 두 번째는 catch
메소드를 사용하는 것이다.new Promise((resolve) => {
resolve(requestLogin('/login'));
}).then(
(response) => getUserData('/users', response),
(e) => {
console.log(e);
},
);
첫 번째 방법은 Promise 객체 상태가 rejected 되면 해당 에러 헨들러를 호출하는 방식이다. 각각의 상황에 맞는 핸들러를 구분해서 등록할 수 있다는 장점이 있지만, then 메소드의 첫 번째 인자 콜백에서 발생하는 에러는 catch할 수 없다는 문제점이 있다.
이 경우 requestLogin
함수에서 에러가 발생한다면 then의 두번째 인자 콜백 함수가 호출되어 에러처리를 할 수 있고, 프로그램이 죽지 않을 것이다.
하지만 getUserData
를 실행하다가 발생하는 에러는 catch할 수 없기에 에러가 '갇혀버린다.' 따라서 자바스크립트 엔진은 전역에러를 발생시켜 프로그램이 죽어버린다.
new Promise((resolve) => {
resolve(requestLogin('/login'));
})
.then((response) => getUserData('/users', response))
.then((userData) => getTodoList('/todos', userData))
.catch((e) => {
console.log(e);
})
.then(() => {});
따라서 catch
메소드를 사용하는게 더 일반적이고, 권장되는 방식이다. Promise와 then에서 에러가 발생하면 Promise의 상태가 rejected가 되고 제어 흐름이 가장 가까운 에러 헨들러(catch
메소드의 콜백함수) 로 넘어간다.
catch
메소드에서 에러를 정상적으로 처리하면 다시 then으로 흐름을 이어갈 수 있다. 물론 이 경우에도 다시 에러가 발생할 수 있기 때문에, 가장 마지막에는 항상 catch
메소드를 사용함으로써 프로그램이 죽는 일을 방지할 수 있다
하지만 catch
와 then
을 번갈아가며 사용하기보다 마지막에만 catch
메소드를 사용하는게 일반적인데, 이 경우 모든 에러 처리를 하나의 메소드에서 처리하는 형태가 되기 때문에 코드가 중복되거나 복잡해질 수 있다.
Promise는 가독성과 에러 핸들링 관점에서 콜백 패턴보다 나은 모습을 보여주지만, 여전히 비동기 처리 결과를 외부로 내보낼 수 없다. 따라서 후속 처리가 계속 이어질 경우 Promise 객체에 then
, catch
가 덕지덕지 붙게 되고 하나의 함수가 많은 일을 처리해야한다.
이것만을 위해서는 아니지만, 이후에 등장한 async/await를 통해 드디어 비동기 처리 결과를 외부로 전달할 수 있게 되었다.
async / await
의 가장 큰 장점은 비동기 코드의 형태와 동작을 동기 코드와 유사하게 만들어준다는 것이다. await
를 사용하면 async
함수 내부의 동작을 멈추는(block) 것 처럼 보이며, async 함수 외부는 여전히 block되지 않고 진행된다. async function showTodoList() {
console.log('start showTodoList');
const response = await requestLogin('/login');
const userData = await getUserData('/users', response);
const todoList = await getTodoList('/todos', userData);
console.log('done showTodoList');
}
console.log('before showTodoList');
showTodoList();
console.log('after showTodoList');
before showTodoList
start request
after showTodoList
done showTodoList
await
이하 로직이 수행되기 전에 after showTodoList
가 done showTodoList
보다 먼저 기록되는 것이다. try/catch
를 통한 에러 핸들링이 용이함위 장점에 대한 자세한 설명은 이 아티클에서 확인하자!