JavaScript에서 비동기 처리?

김상근·2023년 12월 15일
0

자바스크립트에서 동기와 비동기

📖 Introduction

개발을 하다보면 비동기를 처리하는 과정에서 수많은 에러가 발생했었고 내가 원하는 방향대로 흘러가지 않는 경우가 많았다. 이러한 에러를 해결하기 위해서는 자바스크립트의 비동기 처리 과정에 대한 정확한 이해가 필요하다고 생각했다.
그래서 이번 기회에 자바스크립트의 비동기 처리에 대한 개념을 확실하게 정리하기위해 내용을 정리해보려고 한다.

동기

자바스크립트는 싱글 스레드 언이이기 때문에 한번에 하나의 작업만 수행 가능하다. 즉, 이전 작업이 모두 완료되어야 다음 작업을 수행할 수 있다. 우리가 일반적으로 작성하는 코드는 위에서 아래로 순서대로 동작한다. 이러한 방식을 동기(Synchronous)라고 부른다.

이러한 동기 방식은 간단하고 직관적이지만, 작업이 오래 걸리거나 응답이 늦어지는 경우에는 전체적인 성능과 사용자 경험에 영향을 줄 수 있다. 예를 들어 서버에 데이터를 요청하고 응답을 받아야 하는 작업이 있다면, 응답이 올 때까지 다른 작업을 하지 못하고 대기해야 한다. 이렇게 되면 프로그램의 흐름이 멈추거나 지연되게 된다.

비동기

자바스크립트에서는 이러한 문제를 해결하기 위해 비동기(Asynchronous)라는 개념을 도입하여, 특정 작업의 완료를 기다리지 않고 다른 작업을 동시에 수행할 수 있도록 하였다. 비동기는 메인 스레드가 작업을 다른 곳에 인가하여 처리되게 하고, 그 작업이 완료되면 콜백 함수를 받아 실행하는 방식으로, 쉽게 말해 작업을 백그라운드에 요청하여 처리되게 하여 멀티로 작업을 동시에 처리하는 것으로 보면 된다.

서버에 데이터를 요청하고 응답을 받아야 하는 작업이 있다면, 응답이 오는 것과 상관없이 다른 작업을 계속 이어나가 병렬로 작업을 동시 처리가 가능해져 프로그램의 흐름이 멈추거나 지연되지 않게 된다. 따라서 Task들이 병렬적으로 동시에 처리되게 되고 총 코드 실행 시간은 획기적으로 줄어들게 된다.

자바스크립트의 비동기 특징

비동기 처리의 유용성

예를 들어 대규모 웹 어플리케이션에서 데이터 베이스 쿼리를 수행하는 작업이 있다고 가정하면, 이 작업을 동기적으로 수행했을 경우에는 데이터베이스에서 응답이 오기전 까지는 아무 작업도 할 수 없다. 이 경우 대규모 트래픽이 발생할 경우 웹 애플리케이션의 성능이 저하될 수 있다.

하지만 비동기 방식으로 데이터베이스 쿼리를 수행하면, 데이터베이스에서 응답이 오기 전에도 다른 작업을 수행할 수 있다. 이렇게 비동기 방식을 사용하면, 대규모 트래픽에서도 안정적으로 동작할 수 있는 웹 애플리케이션을 만들 수 있다.

대표적인 방법으로는 웹에서 비동기를 처리할 수 있는 Ajax 기술이 있다. 다른 서버에게 데이터를 요청할때 XMLHttpRequest 객체나 혹은 fetch 메서드로 요청을하게 하는데, 서버로부터 응답을 기다리는 동안에도 사용자와의 인터랙션을 유지할 수 있으므로 사용자 경험을 향상시킬 수 있게 된다.

// fetch 함수에 URL 전달
fetch("https://jsonplaceholder.typicode.com/todos/1")
  .then(function(response) {
    return response.json(); // 응답을 JSON 형식으로 변환
  })
  .then(function(data) {
    console.log(data); // JSON 데이터를 출력
  })
  .catch(function(error) {
    console.error(error); // 에러를 출력
  });

비동기의 병렬 처리 원리

아래 그림은 비동기 함수의 콜백 함수가 이벤트 루프에 의해서 Callback Queue에 담기고 다시 싱글 스레드인 Call Stack에 담겨서 콜백 함수가 실행되는 동작 원리를 보여준다.

그렇다면 싱글 스레드 언어인 자바스크립트는 어떻게 비동기 처리를 하는 것일까?
그 이유는 자바스크립트의 Call Stack은 싱글 스레드로 동작하지만, 서버에 리소스를 요청하거나 파일 입출력 혹은 타이머 대기 작업을 실행하는 Web APIs들은 멀티 스레드로 동작하기 때문에 동시 작업 처리가 가능하기 때문이다.

Web APIs는 타이머, 네트워크 요청, 파일 입출력, 이벤트 처리 등 브라우저에서 제공하는 다양한 API를 포괄하는 API의 총칭이다. 브라우저마다 다르겠지만, 크롬 브라우저 일 경우 Web API는 멀티 스레드로 구현되어 있다.

즉, 브라우저라는 소프트웨어가 멀티 스레드 이기 때문에 메인 자바스크립트 스레드를 차단하지 않고 다른 스레드를 사용하여 Web API의 작업을 처리하여 동시 처리가 가능하다.

만약 아래와 같이 3초를 대기하는 setTimeout 비동기 함수와 그외 작업(Task)들이 있다고 한다면, 이 setTimeout 코드가 Web APIs 들 중 타이머 처리를 담당하는 Timer API에 넘어가서 3000ms 밀리초를 병렬로 처리되면서, 동시에 메인 콜 스택의 Task1, Task2 ... 를 처리하는 것이다.

setTimeout(() => {
	console.log('5초 대기 완료')
}, 3000);

Task1();
Task2();
Task3();

하지만 자바스크립트의 비동기는 완벽한 멀티 스레딩이 아니다.

왜 완벽한 멀티 스레딩이 아닌가?

setTimeout을 이용해 비동기의 멀티 작업 처리를 설명했지만, 사실 자바스크립트의 비동기는 완벽한 멀티 스레딩이 아니다. 왜냐하면 타이머 3000ms 만 병렬적으로 처리되고, 그 안의 콜백 함수 실행 코드는 추후에 이벤트 루프에 인해 Call Stack에 들어가 싱글 스레드로 처리되기 때문이다.

setTimeout 뿐만 아니라 fetch 비동기 함수도 마찬가지이다. 서버에 요청해서 리소스를 다운로드 하는 것은 멀티 스레드로 병렬적으로 처리되지만, 요청이 완료되고 나서의 후처리 then 핸들러의 콜백 함수는 콜 스택에 별도로 처리된다.

그렇다면 자바스크립트는 왜 완벽한 멀티 스레디을 지원하지 않는걸까?

그 이유는 멀티 스레드 프로그래밍은 병렬 처리를 통해 얻는 장점도 많지만 항상 동시성 문제가 따라와 synchronized 처리가 수반된다. 만약 synchronized 처리를 잘못하면 오히려 성능 감소가 일어나기 때문에 고난이도의 지식과 실력을 요구한다. 따라서 자바스크립트는 이러한 동시성 문제에 대해서 심플하게 처리하기 위해 비동기 콜백 함수 방식을 채택하였다. 이밖에 자바스크립트 언어를 설계할 과거 당시에는 멀티 프로세서 컴퓨터는 보편적이지 않았을 뿐더러 자바스크립트가 처리할 코드 양도 적었기 때문이라는 이유도 있다.

자바스크립트에서의 멀티 스레딩

그렇다면 자바스크립트는 완벽한 멀티 스레딩을 구현할 수 없는 것일까?
그래서 나온 것이 웹 워커(web worker)이다. 웹 워커를 이용하면 자바스크립트에서도 멀티 스레드 프로그래밍을 할 수 있다.

// 웹 워커 스크립트 파일(worker.js)
self.addEventListener('message', function(e) {
  // 메인 스크립트로부터 메시지를 받으면 실행할 함수
  var result = "Hello " + e.data;
  self.postMessage(result); // 결과를 메인 스크립트로 전송
}, false);
// 메인 스크립트에서 웹 워커 사용 예시
var worker = new Worker('./worker.js'); // 웹 워커 객체 생성

worker.addEventListener('message', function(e) {
  // 웹 워커로부터 메시지를 받으면 실행할 함수
  console.log(e.data); // 결과 출력
}, false);

worker.postMessage('World'); // 웹 워커에게 메시지 전송

비동기 처리의 문제점

다만 성능 향상을 위해 비동기 처리를 이용할때 주의해야 할 점이 있다. Asynchronous는 요청한 작업의 완료 여부를 기다리지 않고 자신의 그다음 작업을 계속 수행해 나간다고 했다. 그런데 만일 그다음 실행할 작업이 이전에 요청한 작업의 결과가 반드시 필요할 경우 문제가 생긴다.

예를 들어 다음 코드는 서버의 데이터베이스를 조회하여 데이터를 가져오는 로직을 간단하게 표현한 예제이다. getDB() 함수를 통해 데이터베이스를 조회하는데, 이때 조회 시간이 3초 걸린다고 가정하자. 그리고 DB로부터 응답을 받게 되면 data 변수에 저장하고 값을 두배 곱셈 연산 후 출력하려고 한다.

function getDB() {
    let data;
    // 데이터베이스에서 값을 가져오는 3초 걸린다고 가정 (비동기 처리)
    setTimeout(() => {
        data = 100;
    }, 3000);

    return data;
}

function main() {
    let value = getDB();
    value *= 2;
    console.log('value의 값 : ', value);
}
main(); // 메인 스레드 실행
// value의 값 : NaN

하지만 결과를 확인해 보니 data 변수에 NaN이라는 이상한 값이 들어가 있다. 왜 이런 결과가 나왔을까? 그 이유는 비동기 함수인 setTimeout 함수가 3초 동안 대기하는 동안 완료될 때까지 기다리지 않고 다음 코드인 console.log(data)를 실행하 였기 때문이다. 이때 data 변수에는 아직 데이터가 저장되지 않았으므로 여기에 연산을 하니 이상한 값이 출력되는 것이다.
그렇다면 위와 같이 작업의 순서를 맞추는 것이 필수인 경우 어쩔수 없이 비동기를 포기하고 동기로 처리해야 될까?

이를 해결하는 몇가지 방법이 있다. 가장 대표적인 것이 콜백 함수이다.

비동기를 올바르게 처리하기 위한 기법

콜백함수

흔히 비동기를 다룰때 자주 엮여 등장하는 개념이 콜백(callback) 함수이다. 콜백 함수는 자바스크립트의 일급 객체 특성을 이용해 함수의 매개변수에 함수 자체를 넘겨, 함수 내에서 매개변수 함수를 실행하는 기법이다.
비동기 방식은 요청과 응답의 순서를 보장하지 않는다. 따라서 응답의 처리 결과에 의존하는 경우에는 콜백 함수를 이용하여 작업 순서를 간접적으로 끼워 맞출 수 있다. 콜백 함수 방식으로 위의 문제 코드를 수정하면 다음과 같다.

function getDB(callback) {
    // 데이터베이스로부터 3초 후에 데이터 값을 받아온 후, 콜백 함수 호출
    setTimeout(() => {
        const value = 100;
        callback(value);
    }, 3000);
}

function main() {
    // 호출할 작업에 콜백 함수를 넘긴다
    getDB(function(value) {
        let data = value * 2;
        console.log('data의 값 : ', data);
    });
}
main();
// data의 값 : 200

위 코드는 콜백 함수 내에서 data 변수의 값을 받아 출력하므로, 비동기 작업이 완료된 후에 출력된다. 즉, 콜백 함수는 비동기 함수에서 작업 결과를 전달받아 처리하는데 사용되어 작업 순서를 맞출수 있게 되는 것이다. 따라서 비동기 함수와 콜백 함수는 서로 밀접한 관계를 가지고 있다.

다만 너무 복잡하게 얽힌 비동기 처리 때문에 콜백 함수 방식은 코드 복잡도를 증가시켜, 개발자가 어플리케이션의 흐름을 읽기 어려워지는 등의 문제가 있을 수 있어 잘못하면 콜백 지옥(callback hell)에 빠질수 있다는 단점이 있다.

프로미스 객체

콜백 함수는 엄연히 말하자면 비동기를 순차적으로 처리하기 위한 일종의 '편법' 같은 것이지 정식으로 지원하는 비동기 전용 함수가 아니다. 따라서 자바스크립트의 Promise 객체는 이러한 한계점을 극복하기 위해 비동기 처리를 위한 전용 객체로서 탄생하였다. Promise는 비동기 작업의 성공 또는 실패와 그 결과값을 나타내는 객체이다. 그래서 Promise를 사용하면 비동기 작업을 쉽고 깔끔하게 연결할 수 있게 된다.

function getDB() {
  return new Promise((resolve) => {
    setTimeout(() => {
      const value = 100;
      resolve(value);
    }, 3000);
  });
}

function main() {
  getDB()
    .then((value) => {
      let data = value * 2;
      console.log('data의 값 : ', data);
    })
    .catch((error) => {
      console.error(error);
    });
}

main();
// data의 값 : 200

async / await

하지만 프로미스도 완벽한 해결책은 아니다. 왜냐하면 Callback Hell이 있듯이 지나친 then 핸들러 함수의 남용으로 인한 Promise Hell이 존재하기 때문이다. 즉, 프로미스가 여러 개 연결되면 코드가 길어지고 복잡해질 수 있다는 것이다. 그래서 자바스크립트에는 async/await라는 문법이 또한 추가되었다. async/await는 프로미스를 기반으로 하지만, 마치 동기 코드처럼 작성할 수 있게 해준다. 비동기 작업에 대한 가독성을 향상시켜주기 때문에 비동기 작업을 처리할 일이 있다면 대게 async/await 방식을 쓰는 것을 추천한다.

function getDB() {
    return new Promise((resolve, reject) => {
        // 데이터베이스에서 값을 가져오는 3초 걸린다고 가정 (비동기 처리)
        setTimeout(() => {
            const value = 100;
            resolve(value); // Promise 객체 반환
        }, 3000);
    });
}

async function main() {
    let data = await getDB(); // await 키워드로 Promise가 완료될 때까지 기다린다
    data *= 2;
    console.log('data의 값 : ', data);
}
main(); // 메인 스레드 실행

무조건 async / await이 답인가?

이렇게 보면 async/await이 비동기를 처리함에 있어 callback이나 Promise 방식보다 훨씬 좋아 보이지만, 사용 방법에 따라서는 코드가 복잡해질 수도 있다. 따라서 이 비동기 처리에 대한 3가지 방식은 용도에 맞춰서 적절히 사용해야 한다. 
왜냐하면 callback 방식은 별 다른 키워드 없이도 정말 단순하게 구현할 수 있는 문법이기 때문에, 콜백 지옥을 맞이할 정도의 복잡한 상황이 아닐 때면 오히려 사용하면 가독성이 좋다. 대표적인 예로 Node.js의 Express 프레임워크는 서버 라우팅을 콜백 함수로 처리하는 방식을 제공한다.

const express = require("express"); // Express 모듈 불러오기
const app = express(); // Express 앱 객체 생성

// /home url 경로에 GET 요청이 들어오면 이에 대한 라우팅 정의
app.get("/home", function (req, res) {
  // 응답 보내기
  res.send("Hello, Express!");
});

따라서, 콜백 함수는 복잡하기 않고 비교적 심플한 비동기 작업을 처리해야 할 때 사용하면 오히려 프로미스 방식보다 더 좋을 수 있다. 반면에 비교적 복잡한 비동기 작업을 처리할 때는 Promise 객체를 사용하면 코드를 보다 간결하게 작성할 수 있다.

상황에 맞게 잘 선택해서 사용할 수 있도록 연습하자!

참고 래퍼런스

출처: https://inpa.tistory.com/entry/🌐-js-async [Inpa Dev 👨‍💻:티스토리]

profile
성장해 나가는 프론트엔드 개발자가 되기 위해 노력하는 중입니다. 적극적인 피드백을 환영합니다.

0개의 댓글