JS ) 자바스크립트의 비동기처리

devHyuck·2023년 11월 17일

Java Script

목록 보기
4/4
post-thumbnail

동기와 비동기

자바스크립트는 싱글 스레드 방식의 언어입니다.
여러개의 함수를 동시에 처리할 수 없고 하나의 함수가 완료되어야 다음 함수를 실행합니다.
일반적으로 코드들이 위에서 아래로 차례로 동작하는 방식을 동기(Synchronous)라고 합니다.

console.log(1);
console.log(2);
console.log(3);

// 위에서 한 줄씩 실행된다.

/*
	1
	2
	3
*/

그런데 만약 자바스크립트에서 바로 실행이 완료되지 않는 함수가 실행될 경우 이렇게 순차적으로 실행하게 되면 그 함수의 실행이 끝날때까지 브라우저는 멈추게 됩니다.

이것은 분명 웹 페이지의 성능과 사용자 경험에 안 좋은 영향을 주겠죠.

그래서 자바스크립트에서는 여러 함수들을 동시에 처리하기 위해 비동기(Asynchronous)식으로 처리되는 함수들이 있습니다.

console.log(1)
setTimeout(() => {
	console.log(2)
}, 1000);
console.log(3)

/*
	1
	3
	(1초 대기)
	2
*/

분명 1을 출력하고 2를 출력해야하는데 setTimeOut 함수를 만나서 1초를 기다려야 하는 상황입니다.
그런데 자바스크립트는 2를 건너뛰고 3부터 출력한 다음 1초뒤에 2가 출력됩니다.

이렇게 비동기식으로 처리되는 함수는 setTimeOut, addEventListener 등등이 있습니다.
모두 실행이 완료되기까지 시간이 걸리는 함수들이죠.

그런데 분명 자바스크립트는 싱글 스레드 방식의 언어라고 했는데 어떻게 병렬적으로 함수를 처리하는지 의문입니다.
그건 브라우저에서 자바스크립트의 동작원리를 보면 이해가 됩니다.

간단히 설명하자면 브라우저가 자바스크립트의 코드들을 읽을때 실행시킬 함수를 모두 콜 스택(Call Stack)에 쌓아놓습니다.
여기는 함수들이 순차적으로 실행되는 메인 무대입니다.

그리고 콜 스택에 있는 함수들 중에 비동기적으로 처리되어야 하는 함수를 만나면 바로 WebAPI로 보내버립니다.
WebAPI는 함수를 병렬적으로 처리할 수 있는 멀티 스레드 방식이기에 여러개의 함수를 처리할 수 있습니다.

메인 무대인 콜 스택에서 순차적으로 함수들이 실행되는 동안 WebAPI에선 비동기적으로 실행되어야 할 함수들을 병렬로 처리를 하고 태스크 큐(Task Queue)라는 대기실로 보냅니다.

그리고 메인 무대가 비워졌을때 대기실에 있던 함수들을 이벤트 루프(Event Loop)라는 관리자가 메인 무대로 올려보내죠.

그래서 웹 브라우저에서 사용자는 이벤트리스너가 동작하기 전에도 모든 웹 페이지의 기능들을 사용할 수 있습니다.

하지만 이런 비동기적 특성은 사용자 경험과 성능향상에는 좋지만 문제가 있습니다.
실행되는 함수의 완료여부는 상관하지 않고 콜 스택은 다음 함수를 계속 처리합니다.
만약 실행이 완료되지 않은 함수의 결과가 지금 실행되고 있는 함수의 작업에 필요하다면 오류가 발생합니다.

이렇듯 비동기적으로 처리되는 함수들이 어떤 상황에서는 순차적으로 실행되어야 할 때가 있습니다.
그럼 자바스크립트에서 비동기적 함수를 알맞게 처리하는 방법에 대해서 알아봅시다.

Callback function

함수의 매개변수에 함수 자체를 넘겨서 함수내에서 매개변수 함수를 실행하는 기법인데, 그냥 콜백함수는 함수안에 들어가는 함수를 지칭합니다.

좀 더 쉽게 예를 들자면 순차적으로 실행하고 싶은 함수가 두개 있다고 칩시다.

function foo1(){
	console.log(1)
}

function foo2(){
	console.log(2)
}

foo1()
foo2()

이렇게 작성하면 순차적으로 1과 2가 출력됩니다.
하지만 비동기적 함수가 있다면 자바스크립트에서는 순차적으로 실행되는 것을 보장하지 않습니다.

이때 콜백함수를 이용하여 실행 순서를 간접적으로 끼워 맞출 수 있습니다.

function foo1(cb){
	setTimeout(()=>{
		console.log(1);
		cb();
	}, 1000)
}

function foo2(){
	console.log(2);
}

foo1(foo2)

이런식으로 함수를 디자인해서 억지스럽지만...😅 1초뒤에 첫번째 함수를 실행하고 두번째 함수를 순차적으로 실행할 수 있습니다.

우리가 주로 사용하는 메소드인 addEventListener, setTimeout, forEach등에도 콜백함수가 사용됩니다.

array.forEach(()=>{} //콜백함수);
element.addEventListener('event', ()=>{} // 콜백함수)
// 모두 함수의 파라미터로 사용되는 함수이다.
// 어떤 작업을 하고 -> 그 작업이후에 실행할 작업

그런데 콜백함수에도 단점이 있는데, 여러개의 함수를 순차적으로 사용하게 된다면 코드가 점점 옆으로 길어져서 코드를 한 눈에 파악하기 힘든 콜백지옥(callback hell)에 빠질수도 있습니다.

foo1(function(){
	foo2(function(){
		foo3(function(){
			foo4(function(){
				~~~~~~~~~~~
			})
		})
	})
}

그러므로 콜백함수는 가독성이 용이한 수준안에서 적절히 사용하여야 합니다.

이런게 보기 싫다면 ES6에서 도입된 Promise 객체가 있습니다.

Promise

프로미스(Promise)는 자바스크립트에서 비동기적인 코드를 효율적으로 다룰수있게 도와줍니다.
주로 서버에서 데이터를 가져오거나 파일을 읽는 등 시간이 걸리는 작업을 수행할 때 사용됩니다.

프로미스는 생성자 함수를 사용해 생성할 수 있습니다.

const 프로미스 = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  if (성공조건) {
    resolve(결과 값);
  } else {
    reject(오류 정보);
  }
});

프로미스는 이름 그대로 작업의 결과 반환을 약속(Promise)하는 객체입니다.
프로미스 생성자 함수에는 두개의 매개변수를 가진 콜백함수가 필요합니다.
콜백함수의 첫번째 인수는 작업의 성공(resolve)을 알려주는 객체이고, 두번째 인수는 작업의 실패(reject)를 알려주는 객체입니다.

프로미스는 총 3가지의 상태를 가집니다.

Pending (대기): 작업이 진행 중인 상태.
Fulfilled (이행): 작업이 성공적으로 완료되어 Promise가 결과 값을 반환한 상태.
Rejected (거부): 작업이 실패하거나 오류가 발생한 상태.

프로미스 객체가 반환하는 상태에 따라 then, catch, finally로 구분하여 연속적인 작업을 정의할 수 있습니다.

프로미스.then(() => {
	// 프로미스가 성공일 경우 실행할 코드
  	// resolve() 의 파라미터가 전달됨
}).catch(() => {
	// 프로미스가 실패일 경우 실행할 코드
  	// reject() 의 파라미터가 전달됨
}).finally(()=>{
	// 성공하든 실패하든 실행되는 코드
})

프로미스 객체 내부에서 작업이 성공하여 resolve()를 호출하게 되면 바로 then()으로 이어져 메소드의 콜백함수에서 추가적인 작업을 이어나갑니다.
여기서 resolve()의 파라미터는 then()의 콜백함수에 이어받아져서 값을 처리할 수 있습니다.

마찬가지로 작업이 실패하여 프로미스 객체가 reject()를 호출하게 되면 catch()에서 추가작업을 처리할 수 있고 reject()의 파라미터도 catch()의 콜백함수안에서 처리가 가능합니다.

이렇게 프로미스를 사용하면 비동기 코드를 더 구조적으로 사용할 수 있고 콜백지옥을 피하면서 코드를 유지보수하기 쉽게 만들 수 있습니다.

또 프로미스에서 제공되는 메소드들로 여러가지 기법들을 사용할 수 있는데 몇가지만 소개해드리겠습니다.

프로미스 체이닝

프로미스
  .then((result) => {
    // 첫 번째 비동기 작업 결과
    return 추가 작업();
  })
  .then((result2) => {
    // 두 번째 비동기 작업 결과
  })
  .catch((error) => {
    // 어느 시점에서든 거부된 경우
  });

프로미스 체이닝으로 연속된 비동기 작업을 처리할 수 있습니다.
then() 메소드에서 값을 리턴하면 그 반환값은 자동으로 프로미스 객체로 감싸져 반환되기 때문에 연속적으로 then() 메소드를 사용할 수 있습니다.

promise.all

const promises = [promise1, promise2, promise3];

Promise.all(promises)
  .then((results) => {
    console.log(results); // 모든 Promise의 결과 배열
  })
  .catch((error) => {
    console.error(error); // 하나 이상의 Promise가 거부된 경우
  });

여러개의 프로미스를 병렬로 처리하고 모든 프로미스가 완료되었을때 실행할 코드를 작성할 때 사용합니다.

promise.race

const promises = [promise1, promise2, promise3];

Promise.race(promises)
  .then((firstResult) => {
    console.log(firstResult); // 가장 먼저 이행된 Promise의 결과
  })
  .catch((firstError) => {
    console.error(firstError); // 가장 먼저 거부된 Promise의 오류
  });

여러개의 프로미스중 가장 먼저 이행되거나 거부된 프로미스를 반환합니다.

이것 말고도 활용가능한 다른 메소드들이 많지만 모두 적기에는 글이 길어져서 이 정도만 소개하겠습니다.
그리고 콜백지옥을 피하기 위한 문법이지만 지나치게 then() 메소드가 체이닝되면 코드가 장황해지고 가독성이 떨어지는 프로미스 지옥도 발생하니 주의하여 사용해야 합니다.

그래서 이것 또한 극복하기 위해 나온 문법이 있습니다.

async/await

async/await 키워드는 ES8에서 도입된 비동기 처리를 위한 문법입니다.

async function getData() {
  const response = await fetch('https://data.com/data.json'); // json 요청
  const data = await response.json(); // json 데이터를 파싱
  return data;
}

async function main() {
	const data = await getData(); // getData 함수가 처리될때까지 기다림
	console.log(data);
}

main();

함수앞에 async 키워드를 붙히면 함수자체가 프로미스가 됩니다.
프로미스를 기반으로 해서 thencatch 메소드를 사용 할 수 있지만 await 키워드를 사용하여 비동기 작업을 마치 동기 작업처럼 작성할 수 있어서 코드가 간결해지고 가독성이 좋아집니다.
await 키워드는 이 작업이 끝날때까지 기다린다 라는 의미입니다.

조건문을 사용해서 강제로 실패를 리턴할 수 있지만 기본적으로는 성공값(resolve )만 반환해서 작업이 실패할 경우 코드가 멈추게 됩니다.
그래서 이것을 방지하기 위한 방법이 try/catch 문법입니다.

async function getData() {
  const response = await fetch('https://data.com/data.json');
  const data = await response.json();
  return data;
}

async function main() {
  try {
    const data = await getData();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

main(); 

이 문법은 쉽게 말하면 try 해보고 안되면 catch 한다 정도로 생각하시면 됩니다.

마무리

async/await가 최신문법이라고 해서 무조건 정답은 아닙니다.
비동기 작업을 처리함에 있어서 콜백함수나 프로미스 문법보다 깔끔해보이지만 어디에 사용하느냐에 따라 복잡할 수도, 불편할 수도 있습니다.
그래서 용도에 따라 여러가지 방법을 적절히 맞춰가며 사용하는 것이 좋아보입니다.

혼자 공부하며 제 나름의 비유와 해석으로 작성된 포스팅이니 오류가 있을수도 있습니다.
언제나 오류를 지적하는 댓글은 환영입니다. 🙂

profile
프론트엔드 개발자

0개의 댓글