2025-09-19 00:40

  • 비동기 프로그래밍은 여러 작업을 동시에 처리하여 프로그램의 효율성과 사용자 경험을 극대화하는 핵심 기술이다.

  • 동기 방식의 ‘작업이 끝날 때까지 대기’하는 문제점을 해결하기 위해 콜백, 프로미스, async/await 같은 기법이 등장했다.

  • 이벤트 루프는 자바스크립트 같은 싱글 스레드 환경에서 비동기 작업을 가능하게 하는 핵심 원리이다.

비동기 프로그래밍 A to Z 완벽 가이드

오늘날 우리가 사용하는 대부분의 애플리케이션은 네트워크 통신, 파일 입출력, 데이터베이스 조회 등 시간이 걸리는 작업을 수행한다. 만약 이런 작업을 처리하는 동안 애플리케이션 전체가 멈춘다면 어떨까? 사용자는 버튼을 눌러도 반응 없는 화면을 보며 답답함을 느낄 것이다. ‘비동기(Asynchronous) 프로그래밍’은 바로 이 문제를 해결하기 위해 탄생한 현대 개발의 필수 패러다임이다.

이 핸드북에서는 비동기 프로그래밍이 왜 필요한지, 어떤 구조로 동작하는지, 그리고 어떻게 활용하는지 A부터 Z까지 심도 있게 탐구한다.

1. 비동기 프로그래밍, 왜 만들어졌나?

비동기 개념을 이해하려면 먼저 ‘동기(Synchronous)’ 방식의 한계를 알아야 한다.

동기 방식: 한 번에 하나씩, 순서대로

전통적인 프로그래밍 방식인 동기는 코드가 작성된 순서대로 작업을 하나씩 처리한다. 첫 번째 작업이 끝나야만 두 번째 작업이 시작될 수 있다. 이는 마치 은행 창구에서 내 앞사람의 업무가 완전히 끝나야 내 차례가 오는 것과 같다.

이 방식은 논리적으로 명확하고 예측이 쉽다는 장점이 있다. 하지만 결정적인 단점이 존재한다. 바로 **‘블로킹(Blocking)‘**이다.

쉽게 이해하기: 카페 직원의 비유

  • 동기적인 직원: 손님 한 명의 주문을 받고, 커피를 만들고, 제공하는 모든 과정이 끝날 때까지 다음 손님 주문을 받지 않는다. 만약 커피 내리는 데 5분이 걸린다면, 뒤에 줄 선 손님들은 하염없이 5분을 기다려야 한다. 이 5분 동안 직원은 다른 아무 일도 하지 못한다. 이것이 바로 ‘블로킹’이다.

  • 비동기적인 직원: 손님 주문을 먼저 받는다(시간이 거의 안 걸리는 작업). 그리고 커피 머신에게 커피를 내리라고 시켜놓고(시간이 오래 걸리는 작업), 커피가 완성되기를 기다리는 동안 다음 손님 주문을 받는다. 커피가 다 되면 진동벨을 울려 손님에게 알려준다. 이렇게 하면 여러 손님의 주문을 효율적으로 동시에 처리할 수 있다.

동기 방식의 블로킹은 특히 I/O(Input/Output) 작업, 즉 네트워크 요청이나 파일 읽기/쓰기처럼 CPU가 아닌 외부 장치와의 통신에서 심각한 성능 저하를 유발한다. CPU는 매우 빠르지만, 네트워크 응답이나 하드 디스크의 데이터 읽기는 상대적으로 매우 느리기 때문이다. 이 느린 작업을 기다리느라 빠른 CPU가 아무 일도 못 하고 멈춰있는 것은 엄청난 자원 낭비다.

비동기 프로그래밍은 이러한 블로킹을 없애고, 기다리는 시간 동안 다른 유용한 작업을 처리하여 시스템의 전반적인 효율성과 응답성을 높이기 위해 만들어졌다.

2. 비동기 프로그래밍의 구조와 핵심 요소

비동기 프로그래밍은 ‘작업을 요청하고, 끝날 때까지 기다리지 않고, 나중에 완료되면 결과를 처리’하는 방식으로 동작한다. 이를 구현하기 위해 여러 가지 모델과 문법이 발전해 왔다.

동기와 비동기 비교

구분동기(Synchronous)비동기(Asynchronous)
작업 순서정해진 순서에 따라 순차적으로 실행순서에 상관없이 동시에 여러 작업 실행
제어권함수 호출 시, 해당 함수의 실행이 끝나야 제어권 반환함수 호출 시, 즉시 제어권 반환하고 백그라운드에서 작업
결과 처리함수의 반환값(return)으로 결과를 바로 받음콜백 함수나 프로미스 등을 통해 나중에 결과를 받음
장점코드 흐름이 직관적이고 이해하기 쉬움시스템 효율성 및 응답성 극대화, 향상된 사용자 경험
단점블로킹으로 인한 성능 저하 발생 가능코드 흐름이 복잡하고 디버깅이 어려울 수 있음

비동기 구현의 3단계 발전사

자바스크립트를 기준으로 비동기 프로그래밍은 다음과 같은 형태로 발전해 왔다.

1) 콜백 함수 (Callback Function)

가장 전통적인 비동기 처리 방식. 특정 작업이 완료되었을 때 호출될 함수(콜백)를 다른 함수의 인자로 넘겨주는 형태이다.

비유: 도미노 첫 번째 도미노(함수)가 넘어지면(작업 완료), 그 끝에서 두 번째 도미노(콜백 함수)를 넘어뜨리는 것과 같다.

function taskA(callback) {
  console.log('A 작업 시작');
  setTimeout(() => { // 2초 걸리는 비동기 작업 시뮬레이션
    console.log('A 작업 완료');
    callback('A의 결과물');
  }, 2000);
}

function taskB(resultFromA, callback) {
  console.log(`B 작업 시작 (A로부터 받은 결과: ${resultFromA})`);
  setTimeout(() => {
    console.log('B 작업 완료');
    callback('B의 결과물');
  }, 1000);
}

// 작업을 순차적으로 실행
taskA((resultA) => {
  taskB(resultA, (resultB) => {
    console.log(`모든 작업 완료: ${resultB}`);
  });
});

문제점: 콜백 지옥 (Callback Hell)

비동기 작업을 순차적으로 여러 개 처리해야 할 경우, 콜백 함수 안에 또 다른 콜백 함수가 중첩되면서 코드가 깊어지고 가독성이 급격히 떨어지는 ‘콜백 지옥’ 현상이 발생한다. 에러 처리도 각 콜백마다 별도로 해줘야 해서 매우 번거롭다.

2) 프로미스 (Promise)

콜백 지옥의 문제를 해결하기 위해 등장한 객체. 비동기 작업의 ‘미래’ 상태(성공 또는 실패)와 그 결과값을 나타낸다.

비유: 자판기

  1. 자판기에 돈을 넣고 버튼을 누르면(비동기 작업 요청), 바로 음료수가 나오는 대신 ‘곧 나올 것’이라는 약속(Promise 객체)을 받는다.

  2. 음료수가 성공적으로 나오면(성공, fulfilled), 음료수를 받는다.

  3. 음료수가 품절이거나 기계가 고장 났다면(실패, rejected), 돈을 돌려받는다.

프로미스는 세 가지 상태를 가진다.

  • 대기 (Pending): 비동기 처리가 아직 완료되지 않은 초기 상태

  • 이행 (Fulfilled): 비동기 처리가 성공적으로 완료된 상태

  • 거부 (Rejected): 비동기 처리가 실패한 상태

function taskA() {
  return new Promise((resolve, reject) => {
    console.log('A 작업 시작');
    setTimeout(() => {
      console.log('A 작업 완료');
      resolve('A의 결과물'); // 성공 시 resolve 호출
      // reject(new Error('A 작업 실패!')); // 실패 시 reject 호출
    }, 2000);
  });
}

function taskB(resultFromA) {
  return new Promise((resolve, reject) => {
    console.log(`B 작업 시작 (A로부터 받은 결과: ${resultFromA})`);
    setTimeout(() => {
      console.log('B 작업 완료');
      resolve('B의 결과물');
    }, 1000);
  });
}

taskA()
  .then(resultA => { // taskA가 성공하면 실행
    return taskB(resultA);
  })
  .then(resultB => { // taskB가 성공하면 실행
    console.log(`모든 작업 완료: ${resultB}`);
  })
  .catch(error => { // 어느 단계에서든 실패하면 실행
    console.error(`에러 발생: ${error}`);
  });

프로미스는 .then() 체이닝을 통해 콜백 지옥처럼 코드가 깊어지는 대신 평평하게 만들어 가독성을 높였다. 또한 .catch()를 통해 에러를 한 곳에서 통합적으로 관리할 수 있게 되었다.

3) Async / Await

ES2017에서 도입된 최신 비동기 처리 문법. 프로미스를 기반으로 하지만, 마치 동기 코드처럼 보이게 만들어 비동기 코드의 가독성을 혁신적으로 개선했다.

비유: 요리 레시피

  1. 오븐을 180도로 예열한다. (await)

  2. 반죽을 만든다.

  3. 반죽을 오븐에 넣고 30분간 굽는다. (await)

  4. 구워진 빵을 꺼낸다.

레시피의 각 단계는 이전 단계가 완료되어야 진행할 수 있다. await는 마치 레시피의 한 단계가 끝날 때까지 기다리는 것과 같이, 프로미스가 완료될 때까지 함수의 실행을 일시 중지하고 결과를 기다린다.

  • async: 함수 앞에 붙이며, 이 함수가 비동기 함수임을 명시하고 항상 프로미스를 반환하게 만든다.

  • await: async 함수 안에서만 사용할 수 있으며, 프로미스 오른쪽에 위치한다. 해당 프로미스가 완료될 때까지 기다렸다가 결과값을 반환한다.

// 위의 Promise 예제를 async/await로 변환
async function runTasks() {
  try {
    console.log('작업 시작');
    const resultA = await taskA(); // taskA가 끝날 때까지 기다림
    const resultB = await taskB(resultA); // taskB가 끝날 때까지 기다림
    console.log(`모든 작업 완료: ${resultB}`);
  } catch (error) {
    console.error(`에러 발생: ${error}`);
  }
}

runTasks();

async/await 문법은 동기 코드와 거의 흡사한 구조를 가지므로 비동기 로직을 훨씬 쉽게 작성하고 이해할 수 있다. 에러 처리도 동기 코드에서 사용하는 try...catch 구문을 그대로 사용할 수 있어 매우 편리하다.

3. 심화 내용: 비동기 동작의 비밀, 이벤트 루프

자바스크립트는 싱글 스레드(Single Thread) 기반 언어다. 즉, 한 번에 하나의 작업만 처리할 수 있다. 그런데 어떻게 비동기 작업이 동시에 처리되는 것처럼 보일까? 그 비밀은 바로 자바스크립트 엔진 외부의 실행 환경(브라우저나 Node.js)이 제공하는 **‘이벤트 루프(Event Loop)‘**에 있다.

이벤트 루프는 다음과 같은 구성 요소와 함께 동작한다.

  1. 호출 스택 (Call Stack): 현재 실행 중인 함수의 목록. 함수가 호출되면 스택에 쌓이고, 실행이 끝나면 스택에서 제거된다. (LIFO: Last In, First Out)

  2. Web API (또는 Background): setTimeout, fetch 같은 비동기 함수들은 호출 스택에서 즉시 Web API로 보내져 백그라운드에서 처리된다. 자바스크립트 엔진의 일부가 아니다.

  3. 태스크 큐 (Task Queue / Callback Queue): Web API에서 완료된 비동기 작업의 콜백 함수들이 대기하는 공간. (FIFO: First In, First Out)

  4. 이벤트 루프 (Event Loop): 호출 스택이 비어있는지 계속 확인하다가, 비어있으면 태스크 큐에서 가장 오래된 작업을 꺼내와 호출 스택으로 옮겨 실행시킨다.

동작 과정:

  1. 비동기 함수(setTimeout 등)가 호출되면, 호출 스택에 잠시 들어왔다가 즉시 Web API로 넘어간다. 자바스크립트는 다음 코드를 계속 실행한다.

  2. Web API는 타이머를 설정하거나 네트워크 요청을 보내는 등 백그라운드 작업을 수행한다.

  3. 작업이 완료되면, Web API는 해당 콜백 함수를 태스크 큐로 보낸다.

  4. 이벤트 루프는 호출 스택이 완전히 비워질 때까지 기다린다.

  5. 호출 스택이 비면, 이벤트 루프는 태스크 큐에 있는 콜백 함수를 호출 스택으로 가져와 실행한다.

이러한 메커니즘 덕분에 자바스크립트는 싱글 스레드임에도 불구하고 블로킹 없이 여러 작업을 효율적으로 처리하는 ‘동시성(Concurrency)‘을 확보할 수 있다.

주의: 동시성(Concurrency) vs 병렬성(Parallelism)

  • 동시성: 하나의 코어(스레드)가 여러 작업을 아주 빠르게 번갈아 가며 처리하여 동시에 실행되는 것처럼 보이는 것. 비동기는 동시성을 의미한다.

  • 병렬성: 여러 개의 코어(스레드)가 실제로 동시에 여러 작업을 처리하는 것.

4. 비동기 프로그래밍의 실제 활용 사례

비동기 프로그래밍은 현대 애플리케이션의 거의 모든 곳에서 사용된다.

  • 웹 프론트엔드: 서버로부터 데이터를 가져오거나(fetch, axios), 사용자의 클릭/입력 이벤트를 처리하고, 무거운 계산을 수행하는 동안 UI가 멈추지 않도록 하는 데 필수적이다.

  • 웹 백엔드 (Node.js): 수많은 클라이언트의 요청을 동시에 처리해야 하는 서버 환경에서 비동기 I/O는 핵심이다. 데이터베이스 조회, 파일 시스템 접근, 외부 API 호출 등 모든 I/O 작업을 비동기로 처리하여 서버의 처리량을 극대화한다.

  • 데이터 처리: 대용량 파일을 읽거나 스트리밍 데이터를 처리할 때, 비동기 방식을 사용하면 메모리를 효율적으로 사용하고 시스템 부하를 줄일 수 있다.

결론

비동기 프로그래밍은 ‘기다림’이라는 비효율을 제거하여 프로그램의 성능과 사용자 경험을 한 단계 끌어올린 혁신적인 패러다임이다. 처음에는 콜백 지옥과 같은 복잡함이 있었지만, 프로미스와 async/await의 등장으로 이제는 동기 코드처럼 명료하고 직관적으로 비동기 로직을 다룰 수 있게 되었다.

이벤트 루프의 동작 원리를 이해하고, 상황에 맞는 비동기 처리 기법을 능숙하게 사용하는 능력은 오늘날 모든 개발자에게 요구되는 핵심 역량 중 하나이다. 비동기의 세계를 마스터하여 더 빠르고, 더 효율적이며, 더 나은 사용자 경험을 제공하는 애플리케이션을 만들어 나가길 바란다.