Asynchronous Programming

WooSeong·2021년 5월 2일
0

학습 노트

목록 보기
17/22

동기 처리와 비동기 처리

동기적 처리는 입력과 출력 사이에 또 다른 일이 발생하지 않는 실행을 의미한다. 이는 하나의 task에서 입력과 처리 출력은 항상 한 묶음으로 여겨진다는 것을 말한다. 은행원이 한명만 근무중인 은행을 생각해 보면 한 고객의 업무를 처리 하기 전까지 다른 고객들은 자기 번호가 되기를 기다릴 수 밖에 없다.

현대의 웹사이트가 동기적으로 구동된다고 생각해보면 아마 아무도 인터넷으로 무언갈 하려고 하지 않을 것이다. 페이지가 로딩되고, 검색창이나 배너등이 하나하나 순서대로 뜨고... 아마 로딩 시간이 분단위로 필요할 것이다.

이와 같이 다양한 기능을 빠르고 효율적으로 구현하기 위해선 동기적 처리에서 벗어나야 한다. 그렇기에 비동기적 처리가 등장하게 된 것이다. 사실 일상 생활에서도 비동기적 처리 상황이 더 흔하다. 스타벅스에 가서 커피를 주문할때 나의 주문을 받은 직원분은 내 커피가 완성되 내가 받을때 까지 다른 주문을 받지 않는가? 만약 그렇다면 오히려 내가 되려 괜찮으시냐며 뒤에 주문이 밀렸다고 말씀드리지 않을까?

비동기적 처리는 한가지 일이 수행되기를 기다리는 시간동안 다른 일을 수행하고 결과만 원하는 순서대로 받을 수 있는 것을 의미한다.

setTimeout

비동기적 처리를 구현하는데 가장 먼저 드는 생각은 바로 setTimeout 일것이다.

window.setTimeout(func(), 1000);//func를 실행하되 1초뒤에 실행 결과를 출력할 것 

하나의 함수(기능)에 대해서 비동기 처리를 할때는 간단하지만 여러개의 함수를 또는 여러 기능을 비동기 처리 해야 한다면? 각 함수의 실행 순서를 기억하고 각각의 타이머 시간을 지정해 줘야 하는등 구현이 매우 어려워 진다.

우리의 목표는 함수의 결과를 기다리지 않고 다른 함수를 실행시키고 여러개의 함수 결과를 의도하는 순서대로 받기만 하면 되는 것이다. 그럼 이번엔 콜백을 사용해 보면 어떨까?

Callback 함수

콜백 함수를 이용하면 작업을 수행할 때 해당 작업이 완료 될때 까지 스택이 해소 되지 않아 다른 작업을 블락하는 상황이 발생하지 않는다. 즉 비동기적인 처리가 가능하게 된다.

문제는 비동기 처리시 처리후 결과를 반환받는 순서를 주의하여야 한다는 점이다. 빨리 처리되는 순서로 결과를 처리하게 된다면 로직이 엉망진창으로 영켜버리고 말것이다.

그리고 콜백 함수를 이용하는 동시에 순서를 주의해서 코딩을 하면 '콜백 지옥'에 빠져 버리게 된다.

step1(function(value1) {
	step2(function(value2) {
		step3(function(value3) {
			step4(function(value4) {
				step5(function(value5) {
					step6(function(value6) {
						//Do something with value4, 5, 6....
						console.log('wake up! you are in callback hell!!')
					})
				}) 
			})
		})
	})
})

코드가 위에서 아래로 흐르는것이 아니라 들여쓰기가 너무 깊어져서 가독성이 심각하게 낮아진다.

콜백 지옥을 해소하기 위한 방법

  • 동기 함수를 사용하는 방법 : 비동기 처리를 위해 콜백 함수를 사용했는데 동기 함수를 사용하는 것은 모순이 발생한다.
  • 콜백 함수를 분리하는 방법 : step 마다 새로운 함수를 선언해서 처리하면 들여쓰기가 깊어지지 않지만, 스텝마다 변수를 선언해 줘야 하며 스코프가 달라지기 때문에 새로운 변수를 선언해 줘야 한다.
  • promise 패턴 사용 : 가장 적절한 방법으로 보인다. 아래서 더 자세히 보자

promise (resolve, reject, then, catch)

콜백 지옥에서 탈출해 콜백을 더 유연하고 가독성이 좋게 사용하기 위해 promise 객체를 사용할 수 있다. promise 객체는 비동기 함수를 받아 처리할 동안 콜 스택을 막지 않으며 비동기 작업이 맞이할 미래의 완료 또는 실패의 값을 나타낸다.

promise 객체의 상태

  • 대기(pending) : 이행하거나 거부되지 않은 초기 상태
  • 이행(fulfilled) : 연산이 성공적으로 완료 됨
  • 거부(rejected) : 연산이 실패함(오류 발생)

.then, .catch

프로미스에 연결한 함수는 그 프로미스의 then 메서드에 의해 대기열에 오른다.

p.then(fulfilledCallback, rejectedCallback);

p.then(function(value) {
	//이행
}, function(reason) {
	//거부
});

p.catch(null, rejectedCallback);
  • then은 2개의 인수를 가지고 있다.
    • fulfilledCallback : promise가 수행될때 호출되는 콜백 함수로 이행 값 하나를 인수로 받는다.
    • rejectedCallback : promise가 거부될때 호출되는 콜백 함수로 거부 이유 값 하나를 인수로 받는다.
  • then은 새로운 promise를 리턴 한다.
    • 새로운 promise를 리턴하기 때문에 then을 체이닝(연결)해서 사용할 수 있다.
  • catch는 오류만 검출하기 위한 메서드이다.
    • 하나의 인수만 가지고 있다. (then과의 차이점을 보이기 위해 null을 넣었다.)
    • then 체이닝에서 한번이라도 거부된다면 다음 then은 실행되지 않고 곧바로 catch메서드로 연결된다.
    • catch 메서드는 실패 이유를 받고 실패 이유를 rejectedCallback에 전달한다.

체이닝(합성)

체이닝을 보기 위해 fetch API를 참고 해보자. fetch API는 프로미스를 리턴한다.

window
    .fetch(serverURI)
    .then(res => res.json())
    .then(data => console.log(data))
    .catch(err => console.log(err));

fetch에 then 메소드가 계속하여 연결되어 있는 모습을 볼 수 있다.

  • fetch가 promise를 리턴한다. 해당 프로미스는 서버에서 GET 요청을 비동기로 처리한다.
  • GET 요청이 성공적으로 처리되어 서버로 부터 http 200 OK 응답을 받았고 payload로 데이터가 들어왔다.
    • 요청이 성공적으로 완료 되었기 때문에 프로미스는 이행(fulfilled) 상태이다.
    • 다음 then은 promise가 수행되었기 때문에 이행 값을 받고 해당 이행 값을 then의 콜백으로 전달한다.
    • 따라서 res(response)는 서버로 부터 받은 데이터를 의미한다.
  • 이행 값을 json()을 통해 본래 형태로 파싱해 주었다.
    • 이와 동시에 then은 새로운 프로미스를 리턴한다. 해당 프로미스의 수행/거부 여부는 res.json()이 수행되었는가의 여부이다.
    • res.json()이 처리되고 이행값을 다음 then으로 넘겨준다.
  • 이전 then에서 리턴된 프로미스의 수행결과 이행 값 (서버 데이터를 파싱한 데이터)이 전달 되었고 전달 받은 이행 값을 console.log에 전달해 주었다.
  • 만일 위의 과정중 거부(오류)가 나올경우 바로 catch로 넘어간다. promise가 거부상태이면 거부 이유를 전달 받아 console.log에 전달한다.
  • catch 역시 프로미스를 리턴하기 때문에 에러 핸들링 이후 새로운 작업을 then 체이닝을 통해 계속 이어 갈 수 있다.

오래된 API에서 promise 사용하기

이상적인 프로그래밍 세계에서는 모든 비동기 함수는 promise을 반환해야 하지만. 불행히도 일부 API는 여전히 success 및 / 또는 failure 콜백을 전달하는 방식일거라 생각합니다.

setTimeout도 promise를 반환하지 않는 오래된 API에 해당된다. 그러나 오류가 발생하는 경우도 확인하기 위해 node.js 의 filesystem 모듈을 예시로 보도록 하자.

API를 promise로 감싸기

const fs = require("fs");

const getDataFromFilePromise = filePath => {
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, 'utf8', (err, data) => {
      if(err) return reject(err);
      return resolve(data);
    })
  })
};

promise를 생성자 호출로 리턴 받는 함수안에 API를 넣어두면 함수를 호출하는 것으로 해당 API를 프로미스 객체 처럼 사용할 수 있다.

  • getDataFromFilePromise 함수는 promise를 리턴 받는다.
  • promise를 생성자 호출로 생성할 경우 두가지 콜백 함수를 갖는다.
    • resolve 콜백 : API가 수행 되었을 경우 실행 된다.
    • reject 콜백 : API가 거부 되었을 경우 실행 된다.
  • getDataFromFilePromise 함수는 filePath를 인수로 받아 내부 API에 전달 한다.
    • fs.readFile은 비동기로 작동하는 비동기 함수이고 promise로 감싸주었으므로 promise를 리턴하는 비동기함수 처럼 사용할 수 있게 된다.

Promise의 다른 메서드

Promise.all(iterablePromises)

여러개의 프로미스의 결과를 한번에 모아 보고 싶다면 .all 메서드를 사용하면 된다.

  • 매개변수 : 순회 가능한 객체(배열, 객체등)
  • 주어진 프로미스중 하나라도 거부하면, 다른 프로미스의 이행 여부와 관계 없이 첫 번째 거부 이유를 사용해 모든 프로미스를 거부한다.
  • 순회 가능한 객체에 프로미스가 아닌 값이 들어있다면 무시하지만, 이행 시 결과 배열에는 포함된다.

Promise.race(iterablePromises)

.all 과 같이 순회 가능한 객체를 매개변수로 받지만 프로미스들을 race(경주) 시켜 가장 빠른 값만 반환하는 메소드이다.

  • 매개변수 : 순회 가능한 객체(배열, 객체등)
  • 주어진 프로미스중 가장 빠른 반환 값 하나를 리턴한다. (이행과 거부의 여부는 상관 없다)

Promise.any(iterablePromises)

.race와 비슷하게 순회 가능한 객체를 매개변수로 받아 가장 빠르게 완료된 값을 반환하지만, .any는 이행된 값만 반환한다.

  • 매개변수 : 순회 가능한 객체(배열, 객체등)
  • 주어진 프로미스중 가장 빠르게 '이행된' 값 하나를 리턴한다.

Promise.finally(finalCallback)

프로미스의 이행/거부 여부와 관계 없이 프로미스가 처리된 후 무조건 매개변수의 콜백을 한 번 실행한다. Promise의 then(), catch() 핸들러의 중복을 피할 수 있게 해주는 유용한 기능

  • 매개변수 : 함수 (콜백 함수)
  • finally는 p.then(finalCallback, finalCallback)을 실행하는 것과 동일하다!
    • 이행, 거부 모두 같은 콜백을 호출한다.
    • then으로 표현할 경우 동일 콜백을 2번 중복해야 한다.
    • finally는 이 중복을 해소해 준다.

async and await

promise를 통해 콜백 지옥에서 빠져 나올 수 있었지만 여전히 동기 함수를 사용하는 것처럼 직관적이지 못하다. 만일 동기 함수의 장점을 살리면서 비동기 함수를 사용할 수 있다면 코드가 직관적이고 잘 읽히면서도 비동기 처리를 보장할 수 있는 멋진 코드를 작성할 수 있을것 같다.

그리고 ECMAscript 2017에서 등장한 것이 'async and await'이다.

async 함수의 가독성을 보기 위해 예를 들어보자.

async function getNewsAndWeatherAsync() {
  let news = await fetch(newsURL).then((new1) => {
    return new1.json();
  })
  let weather = await fetch(weatherURL).then((weather1) => {
    return weather1.json();
  })

  return {news: news.data, weather: weather};
}
  • getNewsAndWeatherAsync 함수는 URL에서 최신 뉴스와 날씨 정보를 받아와 해당 정보를 객체에 담아 리턴하는 함수이다.
  • fetchAPI를 이용하기 때문에 비동기 함수로 작동해야 실행 순서를 보장 받을수 있다.
  • newsURL과 weatherURL은 서로 다른 서버에서 데이터를 가져온다.
  • fetchAPI가 promise를 리턴한다는 것은 앞서 살펴본 예제에서 확인할 수 있다.
  • 다른 속도로 작동하는 두개의 비동기 함수를 마치 동기 함수를 작성하듯이 사용할 수 있다!

async and await 주의점

  • await 키워드는 async 함수에서만 유효하다. async 함수의 스코프 에서만 사용할 수 있으며 외부에서 사용할 경우 SyntaxError가 발생한다.
  • async 함수는 항상 promise를 반환한다. 만약 async 함수의 반환값이 명시적으로 promise가 아니라면 암묵적으로 promise로 감싸여진다.
  • await가 없는 async 함수는 '동기적'으로 실행 된다. await가 있다면 async 함수는 '항상 비동기적'으로 완료 된다.

async and await 에러 핸들링

async 함수는 try...catch... 구문을 통해 에러 핸들링을 한다. MDN 예제를 살펴 보자

async function getProcessedData(url) {
  let v;
  try {
    v = await downloadData(url);
  } catch (e) {
    v = await downloadFallbackData(url);
  } finally {
		v = await downloadEssentialData(url);
	}
  return processDataInWorker(v);
}
  • if문을 쓰는것과 비슷하게 try블록으로 시작한다. try블록에 여러 await 을 넣어 promise.all 과 같이 쓸수 있으며, 여러 await을 각각 에러 핸들링 하기 위해선 다른 try 블록으로 구분해 주면 된다.
  • try블록 내부에서 에러가 발생할 경우(몇개의 await가 있든) catch블록으로 바로 넘어가게 된다.(제어권이 넘어간다.)
  • finally블록은 Promise.finally와 동일하다. 함수의 수행/거부 여부와 관계없이 무조건 실행 된다.
  • catch블록에 조건문을 결합하여 조건적 catch문을 구현할 수 있다.
profile
성장하는 개발자를 꿈꿉니다

0개의 댓글