자바스크립트의 동기, 비동기

CDD·2023년 3월 28일
0

web-develop

목록 보기
7/11
post-thumbnail

Node.JS Feature

JavaScript를 공부한 지 어느덧 4주 정도가 지났는데, 가장 핵심적인 부분인 동기 처리와 비동기 처리에 관해 정리를 할 필요가 있어 글로써 적게 되었다. 자바스크립트 개발은 주로 Node.js로 이루어지기 때문에 이에 대한 특징을 잘 알아야 한다. 싱글스레드, 비동기, 이벤트 기반 같은 특징이 있는데, 이는 다 연결된 개념으로 이어지기 때문에 이들을 중심으로 설명해보려고 한다.

싱글스레드 방식

스레드는 명령을 실행하는 단위를 의미한다. 흔히 일을 하는 사람이라고도 표현할 수 있는데, 한 개의 스레드는 한 번에 한가지 동작 밖에 수행을 하지 못한다. 장점이라면 프로세스 단위에서 DeadLock에 대한 걱정을 하지 않아도 되고, 경쟁 상태도 신경 쓸 필요가 없지만 작업 처리 효율이 떨어진다는 단점이 있긴 한다. 대규모 프로젝트 같은 경우 백엔드를 spring 쪽으로 선호하는 이유도 자바는 멀티스레드 방식이기 때문이다. 이를 위해 Node.js에서는 비동기 동작으로 스레드 기반의 작업을 최소화한다.

동기 방식

비동기와 대비되는 동기적(Synchronous) 방식부터 설명하자면 기본적으로 우리가 알고 있는 코드의 흐름이라고 보면 된다. 한 줄의 코드가 실행된 후에 그 다음 작업이 수행되는 절차적인 방식이다. 흐름에 있어서 헷갈릴 걱정이 없긴 하지만 가끔 앞선 작업이 오래 걸릴 경우 다음 작업이 그만큼 늦춰지게 되어 효율성을 잃게 되는 경우가 있다. 간단한 예를 들어보자.

for (let i = 0; i < 1000; i++) console.log("First");
for (let i = 0; i < 1000; i++) console.log("Second");
for (let i = 0; i < 1000; i++) console.log("Third");

블로킹

동기적 방식으로 실행된다고 하면 First가 1000번 루프를 돌 때까지 Second는 기다려야 하고, Third는 최종적으로 2000번의 루프를 기다려야 한다. 그 다음에 또 작업이 존재한다고 하면 골치 아파질 것이다. 블로킹은 하나의 작업을 마치고 나서 다음 작업을 시작하는 순차적인 실행 방식이다. 자바스크립트에서는 이를 위해 await과 같은 방법을 사용한다.

비동기 방식

반면 비동기 방식은 특정 코드를 큐에 넣어놓고, 이후의 코드부터 실행하는 방식으로 진행된다. 만약 이전의 코드에서 도출된 결과값이 필요 없다면 이를 활용하여 훨씬 효율적인 코드를 구성할 수 있다.

function wait() {
  setTimeout(() => {
    console.log("비동기");
  }, 3000);
}

function Hello() {
  console.log("Hello");
}

wait();
Hello();

/* Console Output
Hello
비동기 */

가장 대표적인 쓰임이 setTimeout() 함수이다. 두 번째 인자에 있는 ms만큼 기다렸다가 첫 번째 인자에 있는 함수를 실행한다. 동기적인 방식으로 진행된다면 wait() 함수가 실행되고 5초 후에 Hello가 출력되어야 하는데, setTimeout()은 비동기적인 방식으로 진행되기 때문에 Hello()부터 실행이 된다.

논블로킹

논블로킹은 하나의 작업을 실행시키고, 그 작업이 마치지 않아도 다음 작업을 실행하는 방식이다. 작업을 실행시켜 놓기만 하는 느낌인데, 이렇게 구현하면 다음 작업의 지연을 걱정할 필요가 없다. 보통 결과값을 바로 리턴 받지 못하고, 콜백이나 그 외 다른 방식으로 받는 경우가 많다. 기술적으로는 사실 싱글스레드 만으로 불가능한데, 이는 나중에 설명하는 것으로 미뤄두겠다.

setTimeout은 그렇다 치고, 그냥 일반적인 코드를 비동기 방식으로 변환할수는 없을까? 이에 대한 방법은 크게 세 가지 방법을 이용할 수 있다. 바로 callback, promise, async/await이다.

Callback

콜백함수라는 말이 처음에는 잘 와닿지가 않았다. 인터넷에 예시를 찾아봐도 이해하기 힘든 말들로 가득하고 말이다. 그래서 알기 쉽게 설명하자면, 콜백함수는 특정 함수가 존재할 때 인자로 넘기는 함수의 형태로 존재한다, 외형적으로 봤을 때는 그렇다.

function someFunction(callback) {
  console.log("not callback");
  callback();
}

someFunction(() => {
  console.log("callback");
});

이와 같은 방법을 응용하면 someFunction에서 어떠한 데이터를 로드한다고 쳤을 때, 그 결과를 처리하기 위해 callback 함수를 비동기적으로 사용할 수 있다. 하지만 callback 함수를 무분별하게 사용한다면 콜백 지옥에 빠져버리게 될수도 있다. 성능을 떠나서 코드의 가독성과 유지보수 모두 헤치는 요인이 될 수 있기에 새로운 Promise라는 기술이 도입되었다.

Promise

Promise는 비동기 작업을 표현하는 자바스크립트의 객체인데, 비동기 작업의 진행(then, catch ..), 성공(resolve), 실패(reject) 상태를 표현할 수 있다. 구현 방법은 객체이기 때문에 생성자가 존재하는데, 인자로 resolvereject를 받는다. 각각은 성공과 실패를 의미하고, 보통 조건문을 통해 resolve를 실행할지, reject를 실행할지를 결정한다. 예시 코드로 보는게 이해가 쉬울 것이다.

let promise = new Promise((resolve, reject) => {
  if (value > 30) {
    return reject("실패");
  }
  resolve(value);
});

이를 직접 사용하는 방식은 다음과 같다.

promise
  .then((data) => {
    console.log(data);
  })
  .then((data) => {
    console.log(data++);
  })
  .catch((e) => {
    console.log(e);
  })
  .finally(() => {
    console.log("종료");
  });

then을 계속해서 메서드 체이닝 시켜주는 방식으로 비동기 동작을 실행시킬 수 있다. 앞의 then이 끝나야 뒤의 then이 실행되고, 작업 실패 시 reject로 넘어가 이를 catch로 사용한다. resolve - then, reject - catch로 이어져있다고 생각하면 된다. 마지막으로 finally는 말 그대로 최종적인 의미를 담고 있는데, 위의 실행 결과가 어떻든 최종적으로 수행할 작업을 의미한다. 진행이 then, catch 둘 중 어느것이 되었든 간에 종료를 출력하는 코드는 실행된다는 말이다.

Promise.all([promise1, promise2, promise3])
  .then((data) => {
    console.log("All clear: ", data);
  })
  .catch((err) => {
    console.log("Something Failed: ", err);
  });

다음과 같이 Promise 배열을 받아 모두 성공시에만 then을 실행시키는 방식으로도 구현할 수 있는데, 여기서 사용되는 것이 Promise.all이다. 하나라도 실패할 경우 에러를 출력하기 위해서, 무조건 모든 과정이 성공적으로 실행시켜야 하는 경우에 사용한다. 사실 오래된 코드의 콜백함수를 Promise로 변환하는 과정을 현업에서 많이 거친다고 한다. 하지만 이것보다 더 쉽게 사용할 수 있는 방법인 async/await 함수가 있다.

async/await

Promise를 활용한 방법인데, await 키워드를 이용한다. 단순히 함수 앞에 async를 써주는 것 만으로도 사용할 수 있고, async로 선언된 함수는 반드시 Promise를 리턴하여야 한다.

async function asyncFunc() {
  let data = await fetchData();
  let user = await fetchUser(data);
  return user;
}

fetchData = () => {
  return new Promise((res, rej) => {
    if (fetch('http://sample ...')) res();
    else rej();
  });
};

function fetchUser ...

대충 이런식으로 작성해봤는데, 가장 주목해야 할 것은 맨 처음의 async function이다. user에서 data를 쓰기 때문에 data의 작업이 다 끝날때까지 기다려야 한다. Promise 같은 경우 then을 사용했지만 async function에서는 단순히 앞에 await 키워드를 붙여주는 것만으로도 끝난다. 가독성의 끝판왕이라고 볼 수 있고, 코드를 작성하기도 편하기 때문에 유지보수 측면에서 정말 좋다. 에러가 발생하는 경우, async function에서는 try, catch를 사용하면 된다.

async function asyncFunc() {
  try {
    let data = await fetchData();
    return fetchAnotherData(data);
  } catch (err) {
    console.log(err);
  }
}

Event Loop

아까 싱글스레드의 비동기에 대해 간략하게 설명하고 넘어갔는데, 좀 더 자세하게 설명하기 위해서는 Event Loop 개념이 들어간다. 이전 코드의 마무리를 짓지 않고 다음 코드에 들어갔을 때, 그 코드는 어떻게 될까? 멀티 스레드면 관리 못하는거 아냐? 와 같은 질문의 대답을 해줄 수 있는 개념이다.

Task Queue, Microtask Queue, Call Stack

위의 사이트에 들어가서 직접 코드를 실행시켜보면 어떠한 방식으로 진행되는지 알 수 있다. 그래도 좀 더 자세히 설명하기 위해 위의 사이트 내 존재하는 소스코드를 가져와봤다.

function logA() {
  console.log("A");
}
function logB() {
  console.log("B");
}
function logC() {
  console.log("C");
}
function logD() {
  console.log("D");
}

// 실행
logA();
setTimeout(logB, 0);
Promise.resolve().then(logC);
logD();

A -> B -> C -> D 순서대로 적혀있는 만큼 순서대로 출력하면 좋겠지만, 비동기적인 방식 두 가지가 섞여 있기 때문에 예상과는 다르게 진행된다. 우선 맨 처음으로 logA 함수가 Call Stack에 올라갈 것이다, 그리고 A라는 값이 출력된다. 그 다음에는 setTimeout에 있는 logB가 실행될 차례인데, 지연시간이 0초임에도 불구하고 logB의 실행은 Task Queue로 가게 된다. 다음으로 Promise 내부에 있는 logC 함수가 실행되게 되는데 Promise 같은 경우 Microtask Queue로 간다. 이 두 가지 큐에 있는 작업들은 Call Stack이 모두 비워진 후에야 비로소 실행되게 된다. 아직 Call Stack에 들어갈 logD가 남았으므로 D가 출력되고 나면 Microtask Queue부터 순차적으로 Call Stack에 쌓이기 시작하고, 실행된다. 그래서 A -> D -> C -> B 순으로 실행된다.

Task Queue는 대기 중인 작업들의 처리를 담당하는 큐이다. setInterval, setTimeout과 같은 비동기 함수나 콜백함수에서 사용하는 큐이기도 하다. Microtask QueueTask Queue보다 우선순위가 높은 작업들의 처리를 담당하는데 Promise와 같은 비동기 함수들이 이 큐에 들어가게 된다. 일반적으로 Task Queue = Message Queue라고 부르기도 하고, Microtask Queue = Job Queue라고 부르기도 한다.

0개의 댓글