자바스크립트 비동기 프로그래밍 (callback, promise, async await)

Noma·2021년 9월 9일
0

Hellow World JavaScript, yejinh, 캡틴 판교님의 글을 정리한 것입니다.

동기 vs 비동기

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

동기(Scyncronous)는 요청을 보낸 후 해당 요청의 응답을 받아야 다음 동작을 실행하는 방식을,
비동기(Asyncronous)는 요청을 보낸 후 응답과 관계없이 다음 동작을 실행할 수 있는 방식을 의미한다.

자바스크립트는 Single Thread !

자바스크립트는 싱글 스레드(Single Thread) 언어로, 한 번에 한 가지 일만 할 수 있다.(콜 스텍이 하나뿐임) 따라서 여러가지 이벤트를 처리해야 할 때 동기적으로 처리하게 되면 어떤 이벤트가 오래 걸리는 작업일 경우 그 이벤트가 모두 처리 될 때까지 다른 어떤 업무도 수행하지 못하는 idle 상태가 된다.

따라서 자바스크립트는 즉시 처리하지 못하는 이벤트들을 Web API로 보낸다.(비동기) 해당 작업들이 완료되는 대로 요청시 등록했던 콜백을 큐에 넣어주고, 이후 콜스텍과 큐 사이를 확인하고 있던 이벤트 루프가 콜 스텍이 비었을 때 큐에 있던 작업을 꺼내어 콜 스텍에 넣어 처리해주게 된다.

즉 비동기 방식은 Web API로 어떤 작업을 먼저 보냈는가 보다는, 어떤 이벤트가 먼저 처리되었느냐에 따라 실행 순서(콜 스텍에 넣어지는 순서)가 달라진다.

비동기 처리 사례 1 - AJAX

비동기 처리의 가장 흔한 사례는 제이쿼리의 ajax이다. 제이쿼리로 실제 웹 서비스 개발 시 ajax통신을 빼놓을 수 없다. 보통 화면에 표시할 이미지나 데이터를 서버에서 불러와 표시해야 하는데 이때 ajax 통신으로 해당 데이터를 서버로부터 가져올 수 있기 때문이다.

ajax 코드를 잠깐 보자면, 다음과 같다.

function getData() {
	var tableData;
	$.get('https://domain.com/products/1', function(response) {
		tableData = response;
	});
	return tableData;
}

console.log(getData()); // undefined

여기서 $.get()이 ajax 통신을 하는 부분으로, 지정된 URL에 데이터를 보내달라고 요청을 날리고 있다.

getData()를 호출하면, 서버로부터 받아온 데이터를 tableData에 저장해 콘솔에 찍혀야할 것 같지만 undefined가 출력된다. 그 이유는 $.get()으로 데이터를 요청하고 받아올 때까지 기다려주지 않고 다음 코드인 return tableData;를 실행했기 때문이다.

이처럼 특정 로직의 실행이 끝날 때까지 기다려주지 않고 나머지 코드를 먼저 실행하는 것이 비동기 처리이다.

비동기 처리 사례 2 - setTimeout

또 다른 사례로는 setTimeout()이 있다. 이는 Web API의 한 종류로, 코드를 바로 실행하지 않고 지정한 시간만큼 기다렸다가 로직을 실행한다.

// #1
console.log('Hello');
// #2
setTimeout(function() {
	console.log('Bye');
}, 3000);
// #3
console.log('Hello Again');

비동기 처리에 대한 이해가 없는 상태에서 위 코드를 보면 아마 다음과 같은 결과값이 나올 거라고 생각할 거다.

  • ‘Hello’ 출력
  • 3초 있다가 ‘Bye’ 출력
  • ‘Hello Again’ 출력

그런데 실제 결과 값은 아래와 같이 나온다.

  • ‘Hello’ 출력
  • ‘Hello Again’ 출력
  • 3초 있다가 'Bye' 출력

setTimeout()은 비동기 방식으로 실행되기 때문에 3초를 기다렸다가 다음 코드를 수행하는 것이 아니라 일단 setTimeout()을 실행하고 나서 바로 다음 코드인 console.log('Hello Again');으로 넘어간다.


이처럼 비동기 처리는 순서가 뒤죽박죽인데 원하는 대로, 순차적으로, 실행되도록 하려면 어떻게 해야 할까?

예를 들어, 서버에 사용자 아이디를 요청하는 비동기 처리 후 받아온 아이디를 이용해 프로필 정보를 재요청 해야 하는 상황이라면?

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

비동기 처리 방식의 문제점 해결하기

1. 콜백 함수(Callback function)

먼저 콜백 함수를 이용하면, 앞에서 살펴본 ajax 통신 코드를 개선할 수 있다.

function getData(callbackFunc) {
	$.get('https://domain.com/products/1', function(response) {
		callbackFunc(response); // 서버에서 받은 데이터 response를 callbackFunc() 함수에 넘겨줌
	});
}

getData(function(tableData) {
	console.log(tableData); // $.get()의 response 값이 tableData에 전달됨
});

함수의 반환 값을 받아서 다음 비동기 처리를 해야하는 경우에 위와 같이 다음 함수를 콜백으로 전달하면, 특정 로직이 끝났을 때 원하는 동작을 실행시킬 수 있다.

콜백 지옥(Callback hell)

$.get('url', function(response) {
	parseValue(response, function(id) {
		auth(id, function(result) {
			display(result, function(text) {
				console.log(text);
			});
		});
	});
});

웹 서비스를 개발하다 보면 서버에서 데이터를 받아와 화면에 표시하기까지 인코딩, 사용자 인증 등을 처리해야 하는 경우가 있다. 만약 이 모든 과정을 비동기로 처리해야 한다고 하면 위와 같이 콜백 안에 콜백을 계속 무는 형식으로 코딩을 하게 된다.

따라서 콜백만으로는 복잡한 비동기 데이터 흐름을 표현하기가 어려워 많은 개발자들이 힘들어했고, 결국 콜백 지옥(callback hell)이라 불릴 정도로 치명적인 단점들을 가지고 있다.

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

  2. 에러처리를 한다면 모든 콜백에서 각각 에러 핸들링을 해줘야 한다.
    콜백의 깊이만큼이나 에러 처리가 복잡해진다.

  3. 콜백이 우연히 두 번 호출되거나, 아예 호출되지 않는 경우를 방지하는 안정장치도 없다.
    자바스크립트는 콜백이 정확히 한 번 호출될 것을 보장하지 않는다.

해결할 수 없는 문제들은 아니지만 비동기적 코드가 늘어날수록 이를 관리하기는 매우 어려워진다.

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

2. Promise 객체

Promise는 '언젠가 끝나는 작업'의 결과값을 담는 통과 같은 객체이다. Promise 객체가 만들어지는 시점에는 그 통 안에 무엇이 들어갈지 모를 수도 있다. 대신 then 메소드를 통해 콜백을 등록해서, 작업이 끝났을 때 결과값을 가지고 추가 작업을 할 수 있다.

Promise 객체는 총 4개의 상태값을 가진다.

  • pending : 아직 결과 값이 반환되지 않은 진행 중 혹은 초기 상태
  • settled: 결과 값이 성공 혹은 실패로 반환된 상태
    • fullfilled: 성공
    • rejected: 실패

(한번 settled된 값은 재실행 할 수 없다.)

생성하기

Promise 객체를 생성하는 가장 쉬운 방법은 Promise.resolve 정적 메소드를 사용하는 것이다.

conost promise=Promise.resolve(1);

위 코드는 1이라는 결과값을 갖는 Promise 객체를 생성했다. 하지만 이 코드는 비동기 작업을 하고 있진 않다.

비동기 작업을 하는 Promise 객체는 Promise 생성자를 통해 만들 수 있다.

const promise = new Promise(function(resolve, reject) {
  setTimeout(()=>{ 
    console.log('2초가 지났습니다.');
    resolve('hello');
  }, 2000);
});

Promise 생성자는 콜백을 인자로 받는다. 이 콜백의 첫 번째 인수로 resolve 함수가 들어오는데, 콜백 안에서 resolve를 호출하면 resolve에 인수로 준 값이 곧 Promise 객체의 궁극적인 결과값이 된다.

두 번째 인수로 들어오는 reject 함수는 비동기 작업에서 에러가 발생했을 때 호출하는 함수이다.

위 예제에서는 setTimeout을 이용해 2초가 지난 뒤에 콜백이 실행되도록 했다. 즉, promise 변수에 저장된 Promise 객체는 2초 동안은 결과 값이 없는 상태이다가(pending), 2초가 지나면 resolve 함수가 호출되어 promise 객체는 결과값을 갖는 객체가 된다(fullfilled).

사용하기

Promise 객체의 결과값 혹은 에러를 사용해 추가 작업을 하려면 then과 catch 메소드에 콜백을 등록하여 사용할 수 있다.

resolve시 then으로

resolve 되는 값은 then 메소드의 인자(여기선 res)로 넘어간다.

const promise=new Promise((resolve,reject)=>{
	setTimeout(()=>{
		resolve('2');    
    },2000);
});
promise
  .then(res=> console.log(`${res}초가 지났습니다.`));

// 출력값
2초가 지났습니다.

reject시 catch로

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

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

promise
  .then(res => console.log(res)) //.then(console.log)와 같음
  .catch(err => console.error(err));

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

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

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

Promise Chaning

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이 가능하다.

여기까지 봤을 때 Promise도 똑같이 콜백을 등록하는데 뭐가 더 좋다는 건지 모르겠다고 생각할 수도 있다. Promise의 진가는 복잡한 비동기 데이터 흐름을 다룰 때 발휘된다. 아래 같은 특징을 활용하면, 콜백만 사용했을 때보다 코드를 훨씬 깔끔하게 작성할 수 있다.

  • then 메소드는 Promise 객체를 반환하므로, 콜백을 중첩하지 않고도 비동기 작업을 연이어 할 수 있다.
  • 비동기 작업이라는 동작 자체를 값으로 다룰 수 있게 된다. 즉, 이제까지 값을 다루면서 해왔던 모든 작업을 Promise 객체에 대해서도 할 수 있다.

➕ Promise.all

Promise.all 정적 메소드는 '인수로 들어온 iterable한 객체에 들어 있는 모든 Promise가 이행되었을 때' 그 자신도 완료되는 새 Promise 객체를 반환한다.

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

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

여기에서 다루지 않은 Promise의 기능은 몇 가지 더 있다. Promise.race에 대한 것은 MDN을 보고 어떤 차이가 있는지 살펴보자.

Promise를 사용하는 비동기 프로그래밍 방식은 이전과 비교하면 여러 가지 장점을 갖지만, 여전히 콜백을 사용한다는 점 때문에 '불편하다', '가독성이 좋지 않다'는 비판을 받아왔다.

ES2017에 등장한 비동기 함수를 사용하면, 동기식 코드와 거의 같은 구조를 갖는 비동기식 코드를 짤 수 있다.

3. 비동기 함수(async function) - async/await

비동기 함수는 Promise를 대체하는 것이 아니라, Promise를 사용하지만 then, catch 메소드를 사용하여 컨트롤 하는 것이 아닌 동기적 코드처럼 반환값을 변수에 할당하여 작성할 수 있게끔 도와주는 문법이다. (syntactic sugar)

함수 앞에 async 키워드를 붙이면, 이 함수는 비동기 함수가 된다.

// 비동기 함수
async function func1(){
	//...
}

// 비동기 화살표 함수
const func2 = async ()=>{
	//...
}

// 비동기 메소드
class Myclass{
	async myMethod(){
    	//...
    }
}

비동기 함수는 항상 Promise 객체를 반환한다는 특징이 있다. 이 Promise의 결과값은 비동기 함수 내에서 무엇을 반환하느냐에 따라 결정되며, then 메소드와 똑같은 방식으로 동작한다.

async function func1(){
	return 1;
}
async function func2(){
	return Promise.resolve(2);
}
func1().then(console.log); // 1
func2().then(console.log); // 2

또 하나 중요한 특징은 비동기 함수 내에서 await 키워드를 쓸 수 있다는 것이다. await은 Promise의 then 메소드와 유사한 기능을 하는데, await 키워드 뒤에 오는 Promise가 결과값을 가질 때까지 함수의 실행을 중단시킨다.

여기서의 '중단'은 비동기식이며, 브라우저는 Promise가 완료될 때까지 다른 작업을 처리할 수 있다.

await은 연산자이기도 하며, await 연산의 결과값은 뒤에 오는 Promise 객체의 결과값이 된다.

// Promise 객체를 반환하는 함수.
function delay(ms) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`${ms} 밀리초가 지났습니다.`);
      resolve()
    }, ms);
  });
}

async function main() {
  await delay(1000);
  await delay(2000);
  const result = await Promise.resolve('끝');
  console.log(result);
}

main();

비동기 함수의 가장 큰 장점은 동기식 코드를 짜듯이 비동기식 코드를 짤 수 있다는 것이다.

then 메소드를 사용하던 때와 달리, 비동기 작업을 위해 콜백을 사용하지 않아도 된다. 또한 await 키워드는 for, if와 같은 제어 구문 안에서도 쓰일 수 있기 때문에, then 메소드를 사용할 때보다 복잡한 비동기 데이터 흐름을 아주 쉽게 표현할 수 있다.

❗ await 키워드는 async 함수에서만 사용이 가능하며, 이 외에 공간에서 사용시 SyntaxError가 발생한다.

Error handling

try catch 구문을 사용하면 비동기 처리 중 발생한 에러가 catch block에 잡히게 된다.

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 
resolved 
2 

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

profile
Frontend Web/App Engineer

0개의 댓글