자바스크립트 비동기 처리 방식

김혜진·2020년 1월 21일
88

javascript

목록 보기
5/9
post-thumbnail

blocking, non-blocking, callback hell, Promise, async await.. 등 비동기와 관련해 머릿 속에 파편적으로 흩어진 것들을 조금씩 모아두려 작성한다.

잘못된 사항들이 있다면 지적바라며.. 사용된 예시 MDN 예제에서 조금씩 덧붙였습니다.


동기 vs 비동기

동기와 비동기를 나누는 가장 큰 차이점을 어떻게 실행 순서를 가지는 지에 있다.

위에 첨부한 이미지와 같이 Syncronous 동기는 요청을 보낸 후 해당 요청의 응답을 받아야 다음 동작을 실행하는 방식을,
Asynchronous 비동기는 요청을 보낸 후 응답과 관계없이 다음 동작을 실행할 수 있는 방식을 의미한다.

간단하게 동기적인 이벤트는 은행으로, 비동기적인 이벤트는 카페에서 주문하는 것을 예로 들어 설명하자면,,

 

동기적 방식

은행 업무를 보러 가면 번호표를 뽑고 기다린 후 차례가 되었을 때 한 명의 행원이 나의 업무가 다 끝날 때까지 처리를 맡아주어 해당 행원은 다른 업무를 보지 못하게 된다. 나의 업무가 모두 끝나야 다음 차례의 업무를 볼 수 있다.

이렇게 발생하는 하나의 이벤트가 모두 끝날 때까지 다른 이벤트를 처리하지 못하고 이벤트가 모두 완료 된 후 다음 이벤트를 동작하는 실행 순서가 확실한 것을 동기적 방식이라 부른다.

 

비동기적 방식

카페에서 주문을 하게되면 지속적으로 주문을 받고 제조되는 순서대로 커피를 받게 된다. 은행 업무와 달리 먼저 주문한 사람의 커피가 다 제조될 때까지 다음 사람이 기다릴 필요가 없기 때문에 카페에 '들어온 순서' 보다 커피가 '먼저 제조된 순서'가 중요하게 된다.

이처럼 연속적으로 발생하는 이벤트를 담은 후 완료되는 순서대로 일을 처리하는 실행 순서가 확실하지 않는 것을 비동기적 방식이라 한다.

 

자바스크립트는 Single Thread이다.

자바스크립트는 Single Thread 언어이다.
즉, 이벤트를 처리하는 Call Stack이 하나뿐인 언어이다.
따라서 여러가지 이벤트를 처리할 때에 동기적으로 처리하게 된다면 하나의 이벤트라 모-두 처리될 때까지 다른 어떤 업무도 수행하지 못하는 현상이 일어나게 된다.

따라서 자바스크립트는 즉시 처리하지 못하는 이벤트들을 Web API로 보내 콜스택이 비었을 때에 먼저 처리된 이벤트들을 줄세워 다시 이벤트 큐에 줄을 세워놓게 된다. Event Loop...

Web API로 들어오는 순서는 중요하지 않고, 어떤 이벤트가 먼저 처리되느냐가 중요하다. 실행 순서가 불명확한 비동기!!

그런데 만약 비동기 처리 이벤트들의 순서가 중요해지게 된다면 어떻게 될까?
예를 들어서 서버에 로그인 사용자 아이디를 요청하는 비동기 처리 후 사용자의 아이디를 이용해 프로필 사진 정보를 재요청해야하는 상황이라면..?

순차적으로 진행되야하는 비동기를 처리하는 몇 가지의 방식들이 있다.

 

비동기 흐름을 처리하는 방식들

1. 콜백 함수 사용 (일명 콜백 지옥)

일단 그 첫번째로는 처리되어야 하는 이벤트들을 순차적으로 콜백 함수로 넣어주는 방식, 일명 콜백 지옥이다.

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

함수의 반환 값을 받아서 다음 비동기 처리를 해야하는 경우에 위와 같이 다음 함수를 콜백으로 받아 비동기 처리를 할 수 있다.

요즘도 많이 사용된다고는 하지만 고전적인 방식이며 지옥이라 불리울 만큼 치명적인 단점들을 가지고 있다.

  1. 우선 가독성이 매우 떨어진다.
    만약 비동기 처리가 예제처럼 3개로 끝나지 않는다면 끝없이 옆으로 누운 피라미드를 그리게 될 것...

  2. 에러처리를 한다면 모든 콜백에서 각각 에러 핸들링을 해주어야 한다.
    콜백의 깊이만큼이나 복잡해지는 에러처리... 만약 에러 처리 없이 진행한다면 콜백 함수가 호출되지 않아도 왜- 어디서- 에러가 발생했는지 알아내기가 쉽지 않다.

 
이러한 불편함을 해소하기 위해 ES6에서 비동기 흐름을 컨트롤하는 방법으로 Promise 객체가 등장한다.

 

2. Promise 객체

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.
출처 MDN

Promise를 사용하여 비동기 작업이 (성공 혹은 실패로) 완료된 후의 결과 값을 받을 수 있다.
결과 값을 돌려받을 수 있기 때문에 이후 처리를 컨트롤 할 수 있게 된다.

Promise의 상태 값

Promise 객체는 new 키워드로 생성할 수 있으며 총 4개의 상태 값을 가진다.

Pending: 아직 결과 값이 반환되지 않은 진행 중인 상태

fulfilled: 성공

Rejected: 실패

Settled: 결과 값이 성공 혹은 실패로 반환된 상태

상태 값은 크게 PendingSettled로 나눌 수 있으며,
Settled은 다시 fulfilledRejected로 나누어 진다.

한번 Setteld된 값은 재실행 할 수 없다.

 

생성

Promise는 함수를 인자로 받으며 인자로 들어온 함수는 다시 resolvereject 2개의 함수를 인자로 받게 된다.
resolve 는 비동기 처리 성공 시 호출되며 reject는 비동기 처리 실패시 호출 된다.

코드로 보면 아래와 같은 모습이다.

const promise = new Promise(function(res, rej) {
  setTimeout(function() { // 비동기로 진행되는 코드
    res(111);
  }, 1000);
});

// 화살표 함수로 작성해도 동일하다.
cosnt promise = new Promise((res, rej) => {
  setTimeout(() => {
    res(111);
  }, 1000);
});

 
new Promise로 생성된 인스턴스 객체는 '객체'이기 때문에 위와 같이 변수로 할당하거나 함수의 인자로 사용할 수 있다.

 

사용

인스턴스 호출 시에는 대표적으로 thencatch 메소드를 사용한다.

resolve시 then으로

resolve되는 값은 then 메소드의 인자로 넘어간다.

const promise = new Promise((res, rej) => {
  setTimeout(() => {
    res(111);
  }, 1000);
});

promise
  .then(res => console.log(res));

// 출력값
111

 

reject시 catch로

반대로 reject되는 값은 catch 메소드의 인자로 넘어가서 에러 핸들링을 할 수 있다.

const promise = new Promise((res, rej) => {
  setTimeout(() => {
    rej('error!');
  }, 1000);
});

promise
  .then(res => console.log(res))
  .catch(err => console.error(err));

// catch 메소드에 잡혀서 console.error에서 출력된 값
error!

 
여기서 하나 또 중요한 점은 then 메소드는 다시 Promise를 반환한다는 것이다.

Promise 객체를 반환한다는 것은 then, catch메소드를 사용할 수 있다는 것을 뜻하며, 이를 통해 연속적으로 then메소드를 사용하여 Promise chaining이 가능하다는 것을 의미한다.

 

Promise chaning

Promise chaning
출처 MDN

doSomething() // doSomething의 반환 값은 then 함수의 인자로 전달된다. 
  .then(res => doSomethingElse(result)) // doSomethingElse의 반환 값도 마찬가지
  .then(res => doThirdThing(newResult)) // doThirdThing 여기도 마찬가지
  .then(res => console.log(`Got the final result: ${finalResult}`)) // 위외 동일..
  .catch(err => console.error(err));
// 위의 Promise 객체들 중 하나라도 에러가 발생하면 catch 메소드로 넘겨진다. 

.catch() 이후에도 chaining이 가능

이 부분은 MDN을 보며 알게 되었다.. MDN 짜응..

new Promise((res, rej) => {
    console.log('Initial');

    res(); // resolve된 후 then 실행
})
.then(() => {
    throw new Error('Something failed');
        
    console.log('Do this'); // error 발생으로 실행되지 않는다.
})
.catch(err => {
    console.log(err); // throw new Error의 인자 값이 넘어온자. 
})
.then(() => {
    console.log('Do this, whatever happened before'); // catch 구문 이후 chaining
});


// 출력값
Initial
Something failed // ERROR
Do this, whatever happened before // catch 메소드 이후 then 메소드 실행

 

Promise.all

Promise.all() 메서드는 순회 가능한 객체에 주어진 모든 프로미스가 이행한 후, 혹은 프로미스가 주어지지 않았을 때 이행하는 Promise를 반환합니다. 주어진 프로미스 중 하나가 거부하는 경우, 첫 번째로 거절한 프로미스의 이유를 사용해 자신도 거부합니다.
출처 MDN

Promise.allPromise 인스턴스들이 담긴 배열을 인자로 받아 사용하는데,
배열의 모든 요소가 Promise 인스턴스일 필요는 없다.

const promise1 = Promise.resolve(3);
const promise2 = 42; // 프로미스가 아니어도 괜찮아요~
const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'foo');
});

Promise.all([promise1, promise2, promise3]).then(values => {
  console.log(values); // Array [3, 42, 'foo']
});

// resolve되는 값들을 destructuring 할 수 있다.
Promise.all([promise1, promise2, promise3]).then(([one, two, three]) => {
  console.log(one); // 3
  console.log(two); // 42
  console.log(three); // 'foo'
});

 
Promise 인스턴스 외의 사용 예시로는..

const emptyPromiseAll = Promise.all([]); // 빈 배열을 동기적 실행
const promiseA = Promise.all([1337, "hi"]); // 프로미스가 아닌 값은 무시하지만 비동기적으로 실행됨
const promiseB = Promise.all([1, 2, 3, Promise.resolve(444)]); // 위와 동일

p; // [] 동기적 실행히라 then 메소드 사용하지 않아도 된다. 
p2.then(res => console.log(res)); // [1337, 'hi']
p3.then(res => console.log(res)); // [1, 2, 3, 444]

MDN에 매우 다양한 예시가 있는데 이런 저런 조합으로 동기적 실행과 비동기적 실행의 차이와 비동기 실행시 pending과 settled되는 경우들을 직접 개발자 도구창에 찍어보는 것이 좋다.
아 특히 고급 예제에서 프로미스 만들기 코드와 버튼 누르면 실제 console 찍히는 것도

 

3. async/ await

async function 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의합니다. 비동기 함수는 이벤트 루프를 통해 비동기적으로 작동하는 함수로, 암시적으로 Promise를 사용하여 결과를 반환합니다.
비동기 함수를 사용하는 코드의 구문과 구조는, 표준 동기 함수를 사용하는것과 많이 비슷합니다.
출처 MDN

ES2017에 등장한 async/ await 구문은 Promise를 기반으로 사용되는데 위의 MDN의 설명과 같이 비동기 코드를 좀 더 동기적인 코드처럼 작성할 수 있게끔 한다. 완전 최고

async/ awaitPromise를 대체하는 것이 아니라는 것을 유념해야 한다.
Promise를 사용하지만 then, catch 메소드를 사용하여 컨트롤 하는 것이 아닌 동기적 코드처럼 반환 값을 변수에 할당하여 작성할 수 있게끔 도와주는 문법이다.

await 키워드는 async 함수에서만 사용 가능하며 async 함수가 아닌 곳에서 사용하면 아래와 같이 SyntaxError가 발생된다.

SyntaxError

error handling

try.. catch..구문을 사용할 수 있으며 Promise chaining에서 연결된 then 메소드 중 하나라도 에러가 발생할 시 catch메소드로 잡는 되는 것처럼 비동기 처리 중 에러가 발생했을 때 catch 블락에 잡히게 된다.

function promise() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  try {
      console.log(1);
      const result = await promise(); // Promise가 settled될 때까지 기다린 후 resolve된 값을 할당한다.
      console.log(result);
      console.log(2);
  } catch(err) {
    console.error(err); // error 발생 시 catch 블락에서 잡히도록 handling
  }
}

asyncCall();

// 출력 값
1  // asyncCall 호출
resolved  // resolve 함수 호출
2  // await 후 다음 코드 실행

 
그렇다면 아래의 경우 출력 값은 어떠할까

console.log(0);

function promise() {
  console.log(1);
  
  return new Promise(resolve => {
    setTimeout(() => {
      
      console.log(2);
      resolve('resolved');
    }, 2000);
  });
}

console.log(3);

async function asyncCall() {
  try {
      console.log(4);
    
      const result = await promise(); // Promise가 settled될 때까지 기다린 후 resolve된 값을 할당한다.
    
      console.log(result); 
      console.log(5);
  } catch(err) {
    console.error(err); // error 발생 시 catch 블락에서 잡히도록 handling
  }
}

console.log(6);

asyncCall();

// 출력 값
0
3
6
4  // asyncCall 호출
1  // promise 함수 호출
2  // 2초 후 setTimeout 콜백 함수 호출
resolved // resolve함수 호출
5  // await 후 다음 코드 실행

두 예제에서 눈여겨 보아야 하는 것은 await으로 호출한 함수 출력 값 result를 할당한 후의 console.log들이다.

비동기 상황에서는 어떤 이벤트가 먼저 완료될 지 순서가 불명확한데 async await 사용시 먼저 완료되어야 할 이벤트들의 순서대로, 다시 말해 동기적으로 실행되는 코드처럼 작성할 수 있다!!!!

프로미스로 비동기 코드를 구현하며 작성하다보니 헷갈리는 점들이 있는데
ajax를 사용하여 응답을 받는 경우에 async await의 장점은 정말 십분 발휘된다.
대체 콜백 헬을 어떻게 썼다는거야 ?????? 할 정도로..

MDN async function페이지에서 simple example.. sequentialStart, concurrentStart.. 등을 비교한 예제를 꼼꼼히 읽어보니 도움이 많이 됐다.


비동기 처리를 하는 세 가지 방법을 정리했다.
1. 콜백 함수 사용
2. Promise
3. Promise를 활용한 async/ await

실제 코드를 작성할 때에는 axios나 fetch를 통해 ajax 요청하면 자동으로 Promise를 반환해주기 때문에 Promise 객체를 직접 생성하는 경우보다 Promise로 반환되는 객체들을 aynce await을 사용하여 비동기 처리하는 경우가 대부분이었다. 그래도 어째든 이전에 Promise의 구동 방식을 알고 있어야 사용 할때에도 그 흐름을 알 수 있다.

profile
꿈꿀 수 있는 개발자가 되고 싶습니다

9개의 댓글

comment-user-thumbnail
2020년 2월 6일

좋은 글 감사합니다

1개의 답글
comment-user-thumbnail
2020년 2월 14일

제너레이터도 있었으면 참 좋았을거같네요.
그리고 싱글쓰레드 이야기에서.


따라서 자바스크립트는 즉시 처리하지 못하는 이벤트들을 Web API로 보내 콜스택이 비었을 때에 먼저 처리된 이벤트들을 줄세워 다시 이벤트 큐에 줄을 세워놓게 된다. Event Loop...

Web API로 들어오는 순서는 중요하지 않고, 어떤 이벤트가 먼저 처리되느냐가 중요하다. 실행 순서가 불명확한 비동기!!

이부분이 조금 혼란스러워서 댓글 남기고 사라집니다 ㅠㅠ

콜스택이 하나라서, JS에서는 비동기를 어떻게 해결하였냐면
Web API, Event Loop, Task Queue(callback queue) 이렇게 3가지 요소가 있는데,

대표적인 비동기 실행함수 setTimeout() 을 예로 들면.
Web API 실행 -> 실행이 완료되면 setTimeout() 인자로 함수를 하나 받는데, 이 콜백함수를 Task Queue 에다 넣어줍니다.

그럼 실행은? event loop 라는 애가
1. call stack 비었니?
2. task queue 작업이 있니?
3. 1번 2번 조건이 충족하면, task queue 작업을 call stack 으로 전달해줍니다.

1개의 답글
comment-user-thumbnail
2020년 3월 17일

잘 봤습니다. 감사합니다!

답글 달기
comment-user-thumbnail
2020년 10월 20일

감사합니다. 덕분에 문제가 해결됐습니다.

답글 달기