[JavaScript] 자바스크립트의 비동기 처리

DD·2021년 9월 24일
2

JavaScript

목록 보기
5/5
post-thumbnail

❓ 비동기 처리는 왜 필요할까

기술적 이유

  • 자바스크립트 엔진은 싱글 스레드기반이고, 싱글 스레드는 한 번에 한 가지 일만 순서대로 처리할 수 있다. 따라서 모든 프로세스가 동기적으로 처리되며, 먼저 시작한 일이 끝나야 다음 일을 시작할 수 있다. 하지만 상황에 따라 먼저 시작한 일이 끝나기 전에 다음 일을 시작해야하는 경우가 있고 이런 때 비동기 처리가 필요하다.

  • 서버에 데이터를 요청하는 작업을 예로 들자면 자바스크립트 엔진의 역할은 서버에 데이터를 요청하고, 응답을 받아서 후속 처리를 하는 것이다. 그 사이에 요청에 대한 처리, 가공, 응답 등은 서버가 하는 일이기 때문에 자바스크립트 엔진은 후속 처리를 위해 불필요하게 서버의 처리가 끝날 때 까지 기다려야만 한다. 이 때 서버에 요청하고 응답이 오는걸 기다리지 않고 나머지 코드를 실행하고, 응답이 왔을 때 후속 처리를 진행하면 불필요한 대기 시간을 없앨 수 있다.

  • 즉, 비동기 처리는 한 번에 한 가지 일만 순서대로 처리하는 자바스크립트 엔진이 시간이 걸리는 일(서버에 데이터 요청, 몇 초후에 동작 등)의 결과를 나중에 처리하도록 컨트롤하는 작업이다.

실용적(?) 이유

  • 과거의 웹 페이지는 서버에서 페이지를 통째로 받아와서 보여주기만 할 뿐이었다. 따라서 비동기 처리가 필요 없거나, 있더라도 복잡하지 않았다. 하지만 모던 웹 페이지는 점점 복잡해졌고, 사용자와 각종 API간의 상호작용에 따라 변화무쌍한 화면을 나타낸다.
    이 상호작용에 대한 처리와 그 결과를 모두 동기적으로 다루게 되면 사용자는 매 순간마다 그 결과를 기다려야하고, 해당 서비스를 사용하는 경험이 매우 불편해질 수 밖에 없다.

  • 예를 들어 사용자가 웹 페이지에 접속한 이후로 각종 이미지도 불러오고, 서버로부터 필요한 데이터를 불러오며, 로그인 상태라면 유저 정보도 받아오고, 채팅창을 연결해둔다던가 지도 서비스를 연결한다던가.. 이런 동작을 하나하나 순서대로 처리하면 아마 사용자는 답답해서 서비스 이용을 포기하게 될 것이다.

  • 요약하자면, 웹이 더 복잡해졌기에 처리해야할 비동기 동작들이 많아졌고, 그에 따른 처리 비용이 늘어나게 되었기에 비동기 로직들을 처리하는 게 중요해졌다.

  • 그렇다면 웹 세계에서는 이 비동기 처리를 어떻게 처리해왔는지 살펴보자.




callback pattern

  • 콜백 패턴은 자바스크립트의 함수가 일급 함수라는 특징을 활용해서 비동기 처리를 진행하는 방식이다. 즉, 비동기 동작을 수행한 후 그 결과를 인자로 받아서 후속처리하는 함수외부에서 전달하고, 비동기 동작 수행 후에 실행하는 방식으로 처리한다.

예시

requestLogin('/login', (response) => {
  console.log(`로그인이 ${response.message} 했습니다`);
});
  • 위 코드는 requestLogin 함수 내부에서 로그인 요청 후, 그 응답값 response를 전달받은 콜백 함수에 전달하며 실행하는 방식으로 진행될 것이다.

  • 하지만 여기서 의문이 든다. 어떻게 응답값을 받은 '후'에 그 값을 콜백에 넣어서 실행하는가? 자바스크립트 엔진은 block되지 않았는데 말이다. 이 또한 다른 글에서 다시 다루겠지만, XMLHttpRequest, setTimeout 같은 Web API를 사용해서 해당 로직을 Task Queue에 옮기고 이벤트루프가 이를 다시 콜스택에 옮기는 방식으로 이루어진다. 자세한 내용은 직접 검색해보거나, 언젠가 작성할 글을 참고해주길.


단점

1. 가독성이 나쁘다.

이러한 방식의 가장 대표적인 단점은 콜백지옥이 발생할 수 있다는 것이다.

requestLogin('/login', (response) => {
  getUserData('/users', response, (userData) => {
    getTodoList('/todos', userData, (todolist) => {
      console.log(todolist);
    });
  });
});
  • 로그인 요청을 하고, 응답값을 기반으로 유저의 정보를 요청하고, 받아온 유저 정보를 기반으로 todolist를 받아온 후에, console에 찍는 로직이 있다고 가정해보자. (물론 실제로 이런 비효율적인 구조를 작성할 일 없지만, 예시를 위해서)

  • 이렇게 다음, 다음, 다음의 로직을 계속 콜백으로 넘겨주면서 점점 깊어지는 현상을 콜백지옥이라 부른다. 아주 단순하게 예시를 들었지만 실제로 코드가 복잡해지면 가독성이 떨어진다.

2. 에러 핸들링

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를 가져야한다.

3. 비동기 처리 결과를 외부에 반환/할당 할 수 없다.

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

  • 콜백 패턴의 여러 문제를 해결하기 위해서 ES6에 Promise가 등장했다. Promise의 구체적인 동작 원리는 다른 글에서 자세히 다루어볼 예정이다. 이글에서는 Promise가 어떻게 콜백 패턴의 단점을 보완했는지에 초점을 맞춰보자.

콜백 지옥 해결

new Promise((resolve) => {
  resolve(requestLogin('/login'));
})
  .then((response) => getUserData('/users', response))
  .then((userData) => getTodoList('/todos', userData))
  .then((todolist) => {
    console.log(todolist);
  });
  • 위의 예시를 Promise 패턴으로 변경하면 위와 같다. then 메소드는 전달받은 콜백 함수를 처리한 후 새로운 Promise 객체를 반환하고, 그 Promise 객체가 다시 then 메소드를 사용 하는 방식으로 후속처리를 이어간다. 이렇게 depth를 유지하기 콜백 패턴보다 가독성이 좋다.

에러 핸들링

  • Promise에서 에러를 핸들링하는 방법은 크게 두 가지가 있는데, 첫 번째는 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 메소드를 사용함으로써 프로그램이 죽는 일을 방지할 수 있다

  • 하지만 catchthen을 번갈아가며 사용하기보다 마지막에만 catch 메소드를 사용하는게 일반적인데, 이 경우 모든 에러 처리를 하나의 메소드에서 처리하는 형태가 되기 때문에 코드가 중복되거나 복잡해질 수 있다.


여전히 비동기 처리 결과를 외부에 반환/할당 할 수 없다.

  • Promise는 가독성과 에러 핸들링 관점에서 콜백 패턴보다 나은 모습을 보여주지만, 여전히 비동기 처리 결과를 외부로 내보낼 수 없다. 따라서 후속 처리가 계속 이어질 경우 Promise 객체에 then, catch가 덕지덕지 붙게 되고 하나의 함수가 많은 일을 처리해야한다.

  • 이것만을 위해서는 아니지만, 이후에 등장한 async/await를 통해 드디어 비동기 처리 결과를 외부로 전달할 수 있게 되었다.




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');
  • 위 코드를 실행하면 console에는 다음과 같은 log가 남는다
before showTodoList
start request
after showTodoList
done showTodoList
  • 이 글 초반에도 설명했다시피 비동기 처리는 해당 수행의 처리를 기다리지 않기 때문에 await 이하 로직이 수행되기 전에 after showTodoListdone showTodoList보다 먼저 기록되는 것이다.

장점

  • 더 나은 가독성
  • try/catch를 통한 에러 핸들링이 용이함
  • 비동기 처리 과정에서 분기처리가 필요할 때 더 가독성이 높은 코드를 작성할 수 있다.
  • 비동기 처리 결과를 외부에 전달할 수 있기 때문에 비동기 처리 중간에 그 값을 외부에서 사용할 수 있다.
  • error stack에 대한 정보를 다룰 수 있다.
  • 디버깅이 용이하다.

위 장점에 대한 자세한 설명은 이 아티클에서 확인하자!

profile
기억보단 기록을 / TIL 전용 => https://velog.io/@jjuny546

0개의 댓글