[JavaScript] 비동기(Asynchronous)와 관련된 개념들 정리

유진·2021년 2월 18일
0

1. 동기 (Synchronous)

기본적으로 동기적 코드는 이전 작업이 완료되어야 다음 작업을 시작할 수 있다. 다음의 예제를 보자.

function expensiveOperation() {
  /* for문을 천만번 돌림 */
  let myDate;
  for (let i = 0; i < 10000000; i++) {
    let date = new Date();
    myDate = date;
  }

  console.log(myDate);

	/* 새로운 요소 p 추가 */
  let p = document.createElement('p');
  p.textContent('new paragraph');
  body.appendChild(p);
}

for문이 천만번 돌때까지 우리는 새로운 요소 p를 추가할 수 없다.

동기적 코드는 순차적이다. 그리고 순차적으로 수행되는 코드는 사람에게 직관적이다. 우리가 코드를 작성할 때는 (당연히) 첫번째 줄을 끝내면 두번째 줄로 넘어가 명령을 수행할 것이라고 예상하기 때문이다.

그러나 동기적 코드는 사람에게 직관적일지 몰라도, 효율성 면에서는 꽝이다.

다음의 수도 코드는 서버에서 데이터를 받아온다.

function getData() {
	console.log('hi');
	fetch('https://blah');  // fetch API가 아닌 가상의 함수라고 가정하자!
	console.log('bye');
	// (다양한 연산들...)
}

function doManyThings() {
	getData();
	func1();
	func2();
	// (다양한 연산들..)
}

doManyThings();

해당 코드가 동기적으로 작동한다고 하자. doManyThings 는 매우 많은 작업을 수행한다. 제일 먼저 getData() 함수를 호출한다. getData() 안에서는 URL을 통해 외부에서 데이터를 가져온다. 그런데, fetch() 가 얼마나 걸릴지는 아무도 모른다. 가져오는 데이터가 작다면 금방 가져오겠지만, 데이터가 크다면, 또는 서버 위치가 물리적으로 멀다면 오래 걸릴 수도 있다.

결국, getData() 함수와 doManyThings() 함수는 얼마나 걸릴지도 모르는 fetch()를 위해 모든 작업을 멈추고 fetch()가 서버에서 데이터를 가져오는 동안 기다려야 한다. 우리는 이것을 블로킹(blocking)이라고 한다.

❓ fetch()를 하는 동안 다른 일을 할 수는 없나요?

  • 적어도 동기적 코드에서는 불가능하다. 자바스크립트는 싱글 스레드이기 때문에 한번에 한가지 작업만 할 수 있다.

아무튼, 인터넷이 점점 발달하고, 웹에서 해야 할일은 점점 많아진다. 특히 웹의 핵심은 네트워크를 통한 서버와 클라이언트 간의 통신이다. 오늘날의 웹에서는 서버와 클라이언트 간의 통신이 매우매우 빈번하게 일어나고 있다. (단지 우리 눈에 보이지 않을 뿐)

흠....... 가령 네이버를 동작시키는 자바스크립트 코드가 동기적으로 작동된다고 생각해보자. 우리가 브라우저에 www.naver.com을 입력해서 접속하면 네이버는 서버에서 네이버 사이트 정보를 브라우저에게 보낸다. 그러면 브라우저는 네이버에서 보낸 데이터들을 차례대로 fetch 한다. 먼저 네이버 로고를 fetch하고, 검색창을 fetch하고, 그다음엔 로그인 버튼을 fetch하고... 그렇게 순차적으로 네이버 페이지 전체를 fetch한다.

이 수많은 요소들을 순서대로 하나씩 가져온다면 아무리 인터넷이 빨라도 오래 걸린다. 그리고 자바스크립트는 한번에 한가지 작업만 할 수 있기 때문에, fetch()를 하는 동안에는 브라우저는 블로킹되어 있으며, 비로소 fetch()가 끝나고 나서야 다른 무언가를 할 수 있다.

만약 이렇게 오랜 시간 기다린 후에 무언갈 검색할 수 있다면, 네이버는 망하고 말것이다. (메인 페이지 로딩하는데 몇초씩 걸린다면 누가 네이버 쓰겠는가)

그래서 자바스크립트를 만드는 사람들은 어떤 작업이 완료되지 않아도 다음 작업을 수행하는 방식을 고안해냈다. 이것이 바로 비동기이다.

2. 비동기(Asynchronous)

비동기는 이전 작업의 완료 여부와 관계 없이 다음 작업을 실행한다. 만약 fetch() 등 외부에서 데이터를 가져와야 할 때, fetch()가 데이터를 가져올때까지 기다리는 것이 아니라, fetch()가 데이터를 가져오는 동안 자바스크립트는 다음 작업을 수행하는 것이다.

자바스크립트는 싱글 스레드인데 어떻게 이게 가능할까? 왜냐면 자바스크립트가 직접 처리하는 것이 아니라, 자바스크립트를 구동하는 런타임에서 이를 담당하기 때문이다. 브라우저(또는 node.js)에서 자바스크립트 코드를 실행한다는 것은, 브라우저에서 제공하는 자바스크립트 엔진으로 코드를 실행한다는 것이다. 엔진은 자바스크립트 코드를 순차적으로 실행하다가 비동기 작업을 만나면 Web API에게 작업을 넘겨준다. Web API는 위임받은 해당 작업들을 처리하고, 해당 작업이 완료되면 그 결과물을 자바스크립트에 돌려준다. Web API가 비동기 작업을 수행하는 동안에는 자바스크립트는 다음 작업으로 넘어가 멈추지 않고 코드를 실행할 수 있다. 이렇게 브라우저의 런타임 환경에서 비동기 작업을 별도로 처리하는 것을 논블로킹(Non-blocking)이라고 한다.

더 자세한 내용은 Javascript 동작원리 (Single thread, Event loop, Asynchronous) 참고

동기는 요청을 보낸 후 해당 요청에 대한 응답을 받아야 다음 작업을 실행한다.
비동기는 요청을 보낸 후 응답 여부와 관계 없이 다음 작업을 실행한다.

그렇다면, 자바스크립트에서는 비동기 작업을 어떻게 구현할 수 있을까? 자바스크립트에는 두가지 비동기 스타일이 있다.

  • callback
  • promise-style

하나씩 살펴보자.

3. 비동기 콜백(Async callback)

콜백은 자바스크립트에서 가장 오래된 비동기 메커니즘으로, 백그라운드 작업이 '완료되면' 호출되는 함수이다.

setTimeout 도 콜백을 사용한다. setTimeout은 두번째 인자로 주어진 n 밀리세컨드만큼 기다린 후 첫번째 인자로 주어진 함수를 실행한다. 이 때 주어진 함수가 바로 콜백함수이다. n 밀리세컨드를 기다리는 작업이 '완료되면' 첫번째 인자로 주어진 함수가 호출된다.

const sayBye = () => {
	console.log('bye');
}
console.log('hi');
setTimeout(sayBye, 10000); // 10초간 기다린 후 sayBye 호출
console.log('bye again');

결과는 다음과 같다.

> hi
> bye again
> bye

여기서 중요한 것은 setTimeout이 비동기 작업이라는 것이다. 자바스크립트는 비동기 작업인 setTimeout을 만나자 setTimeout 작업을 Web API에 넘기고 본인은 다음 작업으로 넘어가 console.log('bye again')을 수행하였다. 동시에 Web API는 setTimeout을 수행을 완료한 후 태스크 큐에 콜백함수를 넘기고, 태스크 큐에서는 콜스택이 비어있음을 확인하고 콜백함수를 콜스택으로 올린 것이다.

결론적으로 setTimeout은 다른 작업들처럼 바로 호출된게 아니라, Web API로 갔다가 콜스택에 쌓인 것이기 때문에 설사 시간이 0초로 설정 되어 있더라도 다른 작업들보다 늦게 콜스택에 쌓인다.

콜백의 약점 - 콜백 지옥(Callback Hell)

콜백에는 약점이 있다. 바로 가독성이 떨어진다는 것이다. 콜백 함수 내에서 또 콜백 함수를 사용할 수도 있는데, 이렇게 콜백함수가 꼬리에 꼬리를 물면 대강 다음과 같이 보인다.

(이미지 출처 - Avoiding Callback Hell)

콜백 지옥은 유지보수가 어렵게 만든다. 콜백함수에서 에러에 대한 예외처리를 만들기 위해서는 각 콜백마다 에러 처리 함수를 추가해주어야 한다. 가뜩이나 가독성도 떨어지는데 에러 처리도 따로따로 해줘야 한다.

콜백만으로 비동기적 코드를 짜기에는 관리가 어렵기 때문에 자바스크립트를 만드는 똑똑이 개발자들은 새로운 방식을 고안했다. 그것이 프로미스(promise)이다.

4. 프로미스(Promise)

프로미스는 콜백을 보완하는 새로운 방식으로, 콜백 자체를 대체하는 것이 아니라 콜백을 사용해 비동기 코드를 관리하기 쉽게 만들어준다.

프로미스 기반 비동기 함수를 호출하면, 해당 비동기 함수는 프로미스 객체를 반환한다. 프로미스 객체는 약속을 하는 객체로, 자바스크립트에게 "나 이거 아직 다 못끝냈는데, 무언갈 반환하긴 할거야. 그러니까 기다리지 말고 다른 작업 하고 있으면 내가 나중에 결과 반환해줄게."라고 말하는 것과 같다.

프로미스 객체는 총 4가지 상태를 가지고 있다.

  • pending: 비동기 작업이 끝나지 않은 상태. 아직 작업의 성공/실패 여부를 모른다.
  • resolved: 비동기 작업이 끝난 상태. fulfilled와 rejected로 나뉜다.
    • fulfilled: 비동기 작업이 성공적으로 완료된 상태로, 비동기 작업의 결과물이 객체에 들어있다.
    • rejected: 비동기 작업이 실패한 상태로, 비동기 작업이 실패한 원인을 담은 에러메시지가 들어있다.

이 상태는 한번 결정되면 바뀌는 일이 없다. (함수가 이미 리턴을 했는데 갑자기 리턴한 값을 바꾸는게 불가능한 것처럼..)

프로미스는 객체이기 때문에 비동기 작업을 진행한 함수가 아닌 다른 함수에서 프로미스 객체를 사용할 수 있다. 이 점에서 프로미스는 콜백에 비해 자유롭다.

프로미스 만드는 방법

프로미스 인스턴스를 만들어, resolvereject 콜백이 있는 콜백함수를 인자로 넣어주면 된다.

let flag = true;
const promise1 = new Promise((resolve, reject) => {
  if (flag===true) {
    setTimeout(() => {
      flag = false;
      resolve('foo');
    }, 300);
  } else {
    reject('flag is false');
  }
});

5. 프로미스 사용방법 1 - .then() 핸들러

리턴된 프로미스 객체는 then 핸들러를 이용해 쉽게 관리할 수 있다.

  • then() 블럭: 이전 작업이 성공적으로 끝났을 때(=프로미스 객체가 fulfilled 상태가 될 때) 실행할 콜백 함수를 인자로 받는다. 이 콜백 함수는 인자로 이전 작업의 성공 결과를 전달받는다. 또한, then() 블럭은 프로미스를 반환하기 때문에 then() 블럭을 여러개 사용해 연쇄적인 비동기 작업을 수행할 수도 있다.
  • catch() 블럭: 여러개의 then() 블럭 중 하나라도 실패하면(=프로미스 객체가 rejected 상태가 되면) 동작한다. then() 구문의 맨 뒤에 붙인다. 또한, try...catch 구문과 비슷하게 동작한다. (그러나 try...catch는 프로미스와 함께 동작할 수 없다)
  • finally() 블럭: 프로미스의 결과가 fulfilled인지 rejected인지 관계없이 프로미스가 완료된 후 최종적으로 코드를 실행하고 싶을 때 사용한다.

예제는 다음과 같다.

console.log('Starting');
let image;

fetch('coffee.jpg')
  .then((response) => {
    console.log('It worked :)');
    return response.blob();
  })
  .then((myBlob) => {
    let objectURL = URL.createObjectURL(myBlob);
    image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  })
  .catch((error) => {
    console.log(
      'There has been a problem with your fetch operation: ' + error.message,
    );
  })
	.finally(() => {
		runFinalCode();
	});

console.log('All done!');

6. 프로미스 사용방법 2 - async/await 키워드

1) async 키워드

함수 선언부에 async 를 붙여주면 함수를 비동기 함수로 만들며, 프로미스를 반환한다. async 키워드는 비동기 함수 내에서 await 키워드가 비동기 코드를 호출할 수 있게 해준다.

예제를 보자. 다음의 함수는 'hello'라는 문자열이 아니라 프로미스를 반환한다.

async function hello() { return "Hello" };
hello();
// expected result
// Promise {...}

다음처럼 작성할 수도 있다.

let hello = async function() { return "Hello" };
// 또는
let hello = async () => { return "Hello" };

async 키워드로 만들어진 비동기 함수의 결과값을 사용하기 위해서는 .then() 블럭을 사용해야 한다.

hello().then((val) => console.log(val));

2) await 키워드

비동기 함수를 await 키워드만 사용하면, 해당 비동기 함수가 resolved 상태를 리턴할 때까지 잠시 중단했다가, 결과값을 리턴하면 계속 코드를 실행한다. 다시 말해, await 키워드는 자바스크립트 런타임으로 하여금 await 키워드를 사용하는 비동기 함수 호출이 결과를 반환할 때까지 해당 라인의 코드 실행을 멈춘다. 그러나 다른 동기적 코드는 실행되도록 한다. 비동기 함수가 결과값을 반환하면 코드는 계속 이어져서 실행된다.

await 키워드는 async function 안에서만 쓸 수 있다! 비동기 코드를 실행할 블럭을 정의하려면 비동기 함수를 생성해야 하기 때문이다.

await 키워드는 비동기 코드를 실행한다는 점에서 .then() 블럭과 매우 유사하며, 실제로 서로를 대체할 수 있다.

다음의 두 코드는 같은 동작을 하는 코드이다.

fetch('coffee.jpg')
.then(response => response.blob())
.then(myBlob => {
  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
})
.catch(e => {
  console.log('problem with your fetch operation: ' + e.message);
});
async function myFetch() {
  let response = await fetch('coffee.jpg');
  let myBlob = await response.blob();

  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
}

myFetch()
.catch(e => {
  console.log('problem with your fetch operation: ' + e.message);
});

3) 에러 처리

async/await에서 에러를 처리하는 방법은 크게 두가지가 있다.

  • try~catch 구문
  • .catch() 블럭

try~catch 구문은 동기식 코드에서 쓰지만, async/await 에서도 쓸 수 있다.

바로 앞에서 살펴본 .catch()를 이용한 에러처리를 try~catch 구문으로 바꿔보자.

async function myFetch() {
  try {
    let response = await fetch('coffee.jpg');
    let myBlob = await response.blob();

    let objectURL = URL.createObjectURL(myBlob);
    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  } catch(e) {
    console.log(e);
  }
}

myFetch();

흠... catch() 블럭을 다시 사용해 코드를 리팩토링 해보자.

async function myFetch() {
  let response = await fetch('coffee.jpg');
  return await response.blob();
}

myFetch().then((blob) => {
  let objectURL = URL.createObjectURL(blob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
})
.catch((e) =>
  console.log(e)
);

결론적으로 try~catch 구문과 .catch() 블럭을 둘다 쓸 수 있으니 취향에 따라 맞는걸 고르면 된다.

7. Promise.all()

만약 여러개의 프로미스를 받고, 모든 프로미스가 fulfilled 일 때 코드를 실행하고 싶은 경우, Promise.all() 메서드를 사용할 수 있다.

Promise.all()은 배열을 매개변수로 받아, 배열의 모든 프로미스가 fulfilled 상태일 때만 새로운 fulfilled 상태의 프로미스 객체를 반환한다. 만약 배열 요소 중 하나라도 rejected 상태인 프로미스가 있으면 rejected 상태의 프로미스를 반환한다.

let a = fetch(url1);
let b = fetch(url2);
let c = fetch(url3);

Promise.all([a, b, c]).then(values => {
  ...
});

참고문헌

profile
제가 또 기가막힌 한 줌의 트러플 소금 같은 존재그등요

0개의 댓글