2026년 4월 14일

비동기 프로그래밍 마스터하기: 논블로킹 방식으로 시스템의 반응성과 효율성을 극대화하는 비법

30
비동기 프로그래밍 마스터하기: 논블로킹 방식으로 시스템의 반응성과 효율성을 극대화하는 비법

비동기 프로그래밍 마스터하기: 논블로킹 방식으로 시스템의 반응성과 효율성을 극대화하는 비법

비동기 프로그래밍 마스터하기: 논블로킹 방식으로 시스템의 반응성과 효율성을 극대화하는 비법

1. 개념 소개

1. 개념 소개

개발자로서 우리는 사용자에게 빠르고 매끄러운 경험을 제공하고, 시스템 자원을 효율적으로 사용하는 애플리케이션을 만들고자 합니다. 이를 위해 현대 소프트웨어 개발에서 필수적인 개념 중 하나가 바로 **비동기 프로그래밍(Asynchronous Programming)**입니다.

정의: 동기(Synchronous)와 비동기(Asynchronous)

가장 쉽게 비동기 프로그래밍을 이해하는 방법은 **동기 프로그래밍(Synchronous Programming)**과 비교하는 것입니다.

  • 동기(Synchronous) 방식: 코드가 위에서 아래로 순차적으로 실행되며, 특정 작업이 완료될 때까지 다음 작업은 시작하지 않고 기다립니다. 마치 한 줄로 서서 차례를 기다리는 것과 같습니다. 이전 작업이 끝나야 다음 작업이 시작되므로, 시간이 오래 걸리는 작업이 있다면 전체 프로그램이 멈춰버리는 현상(블로킹, Blocking)이 발생할 수 있습니다.
  • 비동기(Asynchronous) 방식: 특정 작업이 시작된 후, 해당 작업이 완료되기를 기다리지 않고 즉시 다음 코드를 실행합니다. 시간이 오래 걸리는 작업(예: 네트워크 요청, 파일 읽기/쓰기, 데이터베이스 쿼리 등 I/O 작업)은 백그라운드에서 처리되도록 맡겨두고, 그동안 다른 작업을 수행합니다. 완료되면 특정 방식으로 통보받아(콜백, 프로미스 등) 나머지 작업을 처리합니다. 마치 여러 작업을 동시에 시작하고, 각 작업이 끝나는 대로 결과물을 받는 것과 같습니다. 이는 프로그램이 멈추지 않고 계속 실행되게 하여(논블로킹, Non-blocking) 응답성을 높입니다.

탄생 배경: 왜 필요한가?

비동기 프로그래밍이 중요해진 주된 이유는 다음과 같습니다.

  1. I/O 바운드 작업의 증가: 현대 애플리케이션은 네트워크를 통해 외부 API를 호출하거나, 데이터베이스에서 데이터를 가져오고, 파일 시스템에 접근하는 등 I/O(Input/Output) 작업에 의존하는 경우가 많습니다. 이러한 I/O 작업은 CPU 연산에 비해 훨씬 느리며, 언제 응답이 올지 예측하기 어렵습니다. 동기 방식으로 처리하면 이 모든 대기 시간 동안 애플리케이션이 멈춰버립니다.
  2. 사용자 경험(UX) 개선: 웹 브라우저에서 버튼을 눌렀는데, 서버 응답이 올 때까지 화면이 멈춰있다면 사용자는 답답함을 느낄 것입니다. 비동기 처리를 통해 서버 통신 중에도 사용자 인터페이스(UI)는 계속 반응하도록 할 수 있습니다.
  3. 시스템 자원 효율성: 서버 환경에서는 여러 사용자의 요청을 동시에 처리해야 합니다. 동기 방식은 한 요청을 처리하는 동안 다른 요청을 대기시키므로 자원 활용률이 떨어집니다. 비동기 방식은 CPU가 I/O 대기 상태에 있을 때 다른 요청을 처리하도록 하여 자원을 효율적으로 사용하고, 더 많은 동시 요청을 처리할 수 있게 합니다.

왜 중요한가?

비동기 프로그래밍은 단순히 코드를 작성하는 스타일을 넘어, 애플리케이션의 성능, 확장성, 사용자 경험에 직접적인 영향을 미치는 핵심 기술입니다. 특히 JavaScript(Node.js 포함)와 Python(asyncio)과 같이 단일 스레드 기반의 런타임에서 I/O 바운드 작업을 효율적으로 처리하는 데 필수적입니다. 이를 통해 블로킹 없이 수많은 동시 연결을 처리하고, 실시간 반응성을 유지할 수 있습니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

비동기 프로그래밍은 다양한 방식으로 구현되지만, 핵심은 "어떤 작업이 끝날 때까지 기다리지 않고 다음 작업을 시작하는 것"입니다. 이를 가능하게 하는 주요 원리와 패턴들을 살펴보겠습니다.

비유: 레스토랑 주방장

비동기 프로그래밍을 레스토랑 주방장에 비유해 봅시다.

  • 동기 주방장: 손님 A가 스테이크를 주문하면, 주방장은 스테이크가 다 구워지고 플레이팅까지 마칠 때까지 다른 손님의 주문을 받거나 다른 요리를 시작하지 않습니다. 손님 A의 요리가 끝나야 비로소 손님 B의 주문을 받습니다. 이는 요리 시간이 긴 메뉴가 있다면 다른 손님들은 하염없이 기다려야 한다는 것을 의미합니다.
  • 비동기 주방장: 손님 A가 스테이크를 주문하면, 주방장은 스테이크를 오븐에 넣고 타이머를 맞춘 후, 스테이크가 익기를 기다리지 않고 곧바로 손님 B의 샐러드를 만들기 시작합니다. 샐러드를 만드는 도중 스테이크 타이머가 울리면, 잠시 샐러드 작업을 멈추고 스테이크를 꺼내 플레이팅을 합니다. 그 후 다시 샐러드 작업을 이어서 합니다. 즉, 동시에 여러 요리를 '관리'하며 가장 효율적인 방식으로 처리합니다. 여기서 '오븐에 넣고 타이머를 맞추는 것'이 I/O 작업을 백그라운드에 맡기는 것이고, '타이머가 울리는 것'이 작업 완료를 통보받는 것입니다.

이 비유에서 중요한 점은 비동기 주방장이 '동시에 여러 요리를 처리한다'고 해서 물리적으로 여러 손(스레드)을 가지고 요리하는 것이 아닐 수 있다는 것입니다. 한 명의 주방장이 효율적인 '관리'를 통해 여러 작업을 번갈아 처리하며 마치 동시에 진행되는 것처럼 보이게 하는 것입니다. 이것이 바로 이벤트 루프(Event Loop) 기반의 단일 스레드 비동기 프로그래밍의 핵심입니다.

이벤트 루프 (Event Loop)

JavaScript(Node.js)나 Python의 asyncio 같은 환경에서 비동기 프로그래밍은 주로 이벤트 루프라는 단일 스레드 메커니즘을 통해 이루어집니다. 이벤트 루프는 프로그램의 메인 스레드에서 계속 돌면서, 대기 중인 작업을 확인하고 처리하며, 완료된 비동기 작업의 콜백 함수를 실행합니다.

작동 방식 (간략화된 다이어그램):

┌─────────────────┐
│                 │
│   Call Stack    │ (현재 실행 중인 동기 코드)
│                 │
└─────────────────┘
         │
         │ (동기 함수 실행)
         ▼
┌─────────────────┐
│                 │
│   Event Loop    │ (대기 중인 비동기 작업 및 콜백 확인)
│                 │
└─────────────────┘
         │ ▲
         │ │ (비동기 작업 완료 시 콜백을 Call Stack으로 보냄)
         ▼ │
┌─────────────────┐
│                 │
│   Callback Queue│ (실행 대기 중인 비동기 작업의 콜백 함수들)
│                 │
└─────────────────┘
         │
         │ (I/O 작업 등 비동기 API)
         ▼
┌─────────────────┐
│                 │
│    Web APIs     │ (브라우저 또는 Node.js 런타임의 비동기 기능)
│  (setTimeout,   │
│   fetch, etc.)  │
└─────────────────┘
  1. Call Stack: 실행 중인 함수들이 쌓이는 곳입니다. 동기 코드는 여기서 순차적으로 실행됩니다.
  2. Web APIs (또는 Node.js/Python 런타임의 비동기 API): setTimeout, fetch, readFile 등 시간이 오래 걸리는 비동기 작업을 여기에 위임합니다. 이 작업들은 메인 스레드와 독립적으로 백그라운드에서 처리됩니다.
  3. Callback Queue (태스크 큐): 비동기 작업이 완료되면, 해당 작업에 연결된 콜백 함수가 이 큐에 추가됩니다.
  4. Event Loop: Call Stack이 비어 있을 때(즉, 현재 실행 중인 동기 코드가 없을 때), Callback Queue에서 가장 오래된 콜백 함수를 Call Stack으로 옮겨 실행합니다.

이러한 메커니즘을 통해 단일 스레드에서도 I/O 작업을 기다리는 동안 CPU를 다른 작업에 활용하여 논블로킹 처리를 가능하게 합니다.

콜백 (Callbacks)

콜백은 비동기 프로그래밍의 가장 기본적인 형태입니다. 특정 작업이 완료된 후 실행될 함수를 미리 인자로 전달하는 방식입니다.

function doSomethingAsync(callback) {
  setTimeout(() => {
    console.log("작업 완료!");
    callback(); // 작업이 완료된 후 콜백 함수 호출
  }, 1000);
}

doSomethingAsync(() => {
  console.log("콜백 함수 실행됨!");
});
console.log("다음 작업 실행!"); // doSomethingAsync가 완료되기 전에 먼저 실행됨

// 출력 순서:
// 다음 작업 실행!
// (1초 후)
// 작업 완료!
// 콜백 함수 실행됨!

콜백은 간단하지만, 여러 비동기 작업을 순차적으로 처리해야 할 때 **"콜백 헬(Callback Hell)"**이라는 가독성 문제와 에러 처리의 어려움을 야기할 수 있습니다.

Promise (프로미스)

콜백 헬 문제를 해결하고 비동기 코드를 더 구조적이고 읽기 쉽게 만들기 위해 등장한 것이 Promise입니다. Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다.

Promise는 세 가지 상태를 가집니다.

  • Pending (대기): 비동기 작업이 아직 수행되지 않았거나 완료되지 않은 초기 상태.
  • Fulfilled (이행): 비동기 작업이 성공적으로 완료된 상태. 결과 값을 가집니다.
  • Rejected (거부): 비동기 작업이 실패한 상태. 에러 정보를 가집니다.
function doSomethingAsyncWithPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true; // 가정을 위해 성공/실패 여부 설정
      if (success) {
        console.log("작업 완료!");
        resolve("성공적인 결과!"); // 작업 성공 시 resolve 호출
      } else {
        reject(new Error("작업 실패!")); // 작업 실패 시 reject 호출
      }
    }, 1000);
  });
}

doSomethingAsyncWithPromise()
  .then(result => {
    console.log("Promise 성공:", result);
    return "다음 작업 결과!"; // 체이닝을 위해 Promise를 반환하거나 값을 반환
  })
  .then(nextResult => {
    console.log("다음 Promise 성공:", nextResult);
  })
  .catch(error => {
    console.error("Promise 실패:", error.message);
  })
  .finally(() => {
    console.log("Promise 작업 종료 (성공/실패 무관)");
  });

console.log("Promise 호출 후 다음 작업 실행!");

.then()으로 성공 시 후속 작업을 체인처럼 연결할 수 있고, .catch()로 모든 에러를 중앙에서 처리할 수 있어 콜백보다 훨씬 깔끔합니다.

Async/Await

Promise가 비동기 코드를 개선했지만, 여전히 .then() 체인이 길어지면 가독성이 떨어질 수 있습니다. 이를 더욱 동기 코드처럼 읽기 쉽게 만들어주는 문법적 설탕(Syntactic Sugar)이 바로 **async/await**입니다. async 함수는 항상 Promise를 반환하며, await 키워드는 async 함수 내부에서만 사용할 수 있고 Promise가 resolve될 때까지 함수의 실행을 일시 중지했다가 결과를 반환합니다.

async function doSomethingAsyncWithAsyncAwait() {
  try {
    console.log("비동기 작업 시작...");
    const result1 = await doSomethingAsyncWithPromise(); // Promise가 완료될 때까지 대기
    console.log("첫 번째 Promise 완료:", result1);

    const result2 = await new Promise(res => setTimeout(() => res("두 번째 결과!"), 500));
    console.log("두 번째 Promise 완료:", result2);

    return "모든 작업 성공!";
  } catch (error) {
    console.error("Async/Await 실패:", error.message);
    throw error; // 에러를 다시 던져 외부에서 처리하도록 할 수 있음
  } finally {
    console.log("Async/Await 작업 종료.");
  }
}

// async 함수는 Promise를 반환하므로, 외부에서는 .then/.catch로 처리
doSomethingAsyncWithAsyncAwait()
  .then(finalResult => console.log("최종 결과:", finalResult))
  .catch(err => console.error("최종 에러 처리:", err.message));

console.log("Async/Await 호출 후 다음 작업 실행!");

async/await는 비동기 코드를 동기 코드처럼 순차적으로 읽을 수 있게 해주어 가독성을 극대화합니다. 내부적으로는 Promise를 사용하므로, Promise의 강력한 기능들을 그대로 활용할 수 있습니다.

3. 코드 예제 2개

예제 1: JavaScript (콜백 헬 -> Promise -> Async/Await 리팩토링)

이 예제는 비동기 작업이 순차적으로 이루어져야 할 때 콜백 헬이 어떻게 발생하는지 보여주고, 이를 Promise와 async/await로 개선하는 과정을 담고 있습니다.

// --- 비동기 작업 시뮬레이션 함수 ---
function fetchUser(userId, callback) {
  console.log(`[Fetch] 사용자 ID ${userId} 정보 요청...`);
  setTimeout(() => {
    if (userId === 1) {
      callback(null, { id: 1, name: "Alice" });
    } else {
      callback("User not found", null);
    }
  }, 500);
}

function fetchPosts(userId, callback) {
  console.log(`[Fetch] 사용자 ID ${userId}의 게시글 요청...`);
  setTimeout(() => {
    if (userId === 1) {
      callback(null, [{ postId: 101, title: "Hello World" }, { postId: 102, title: "Async Intro" }]);
    } else {
      callback("Posts not found", null);
    }
  }, 700);
}

function fetchComments(postId, callback) {
  console.log(`[Fetch] 게시글 ID ${postId}의 댓글 요청...`);
  setTimeout(() => {
    if (postId === 101) {
      callback(null, [{ commentId: 1001, text: "Great post!" }]);
    } else {
      callback("Comments not found", null);
    }
  }, 300);
}

// --- 1. 콜백 헬 예제 ---
console.log("--- 1. 콜백 헬 예제 ---");
fetchUser(1, (userError, user) => {
  if (userError) {
    console.error("콜백: 사용자 정보 가져오기 실패:", userError);
    return;
  }
  console.log("콜백: 사용자 정보:", user.name);

  fetchPosts(user.id, (postsError, posts) => {
    if (postsError) {
      console.error("콜백: 게시글 가져오기 실패:", postsError);
      return;
    }
    console.log("콜백: 게시글 수:", posts.length);
    if (posts.length > 0) {
      fetchComments(posts[0].postId, (commentsError, comments) => {
        if (commentsError) {
          console.error("콜백: 댓글 가져오기 실패:", commentsError);
          return;
        }
        console.log("콜백: 첫 게시글 댓글 수:", comments.length);
        console.log("콜백: 모든 데이터 가져오기 완료!");
      });
    } else {
      console.log("콜백: 게시글 없음.");
    }
  });
});
// 출력 순서가 복잡해지고, 에러 처리 로직이 반복되며 깊이가 깊어집니다.

// --- Promise 버전으로 리팩토링 ---
// Promise를 반환하는 래퍼 함수 생성
function fetchUserP(userId) {
  return new Promise((resolve, reject) => {
    fetchUser(userId, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

function fetchPostsP(userId) {
  return new Promise((resolve, reject) => {
    fetchPosts(userId, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

function fetchCommentsP(postId) {
  return new Promise((resolve, reject) => {
    fetchComments(postId, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

console.log("\n--- 2. Promise 예제 ---");
fetchUserP(1)
  .then(user => {
    console.log("Promise: 사용자 정보:", user.name);
    return fetchPostsP(user.id); // 다음 Promise 반환
  })
  .then(posts => {
    console.log("Promise: 게시글 수:", posts.length);
    if (posts.length > 0) {
      return fetchCommentsP(posts[0].postId);
    } else {
      return []; // 게시글이 없으면 빈 배열 Promise로 반환
    }
  })
  .then(comments => {
    console.log("Promise: 첫 게시글 댓글 수:", comments.length);
    console.log("Promise: 모든 데이터 가져오기 완료!");
  })
  .catch(error => {
    console.error("Promise: 에러 발생:", error);
  });

// --- Async/Await 버전으로 리팩토링 ---
console.log("\n--- 3. Async/Await 예제 ---");
async function fetchDataWithAsyncAwait(userId) {
  try {
    const user = await fetchUserP(userId); // await로 Promise가 완료될 때까지 기다림
    console.log("Async/Await: 사용자 정보:", user.name);

    const posts = await fetchPostsP(user.id);
    console.log("Async/Await: 게시글 수:", posts.length);

    if (posts.length > 0) {
      const comments = await fetchCommentsP(posts[0].postId);
      console.log("Async/Await: 첫 게시글 댓글 수:", comments.length);
    } else {
      console.log("Async/Await: 게시글 없음.");
    }
    console.log("Async/Await: 모든 데이터 가져오기 완료!");
  } catch (error) {
    console.error("Async/Await: 에러 발생:", error);
  }
}

fetchDataWithAsyncAwait(1);

예제 2: Python (asyncio를 이용한 비동기 웹 요청)

Python의 asyncio 라이브러리는 비동기 I/O를 위한 프레임워크를 제공합니다. async defawait 문법을 사용하여 비동기 함수를 정의하고 실행합니다. aiohttp와 같은 비동기 HTTP 클라이언트를 사용하면 웹 요청을 효율적으로 처리할 수 있습니다.

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    """
    비동기적으로 주어진 URL에서 데이터를 가져오는 함수
    """
    start_time = time.time()
    print(f"[{url}] 요청 시작...")
    async with session.get(url) as response:
        # await는 비동기 작업이 완료될 때까지 기다리도록 지시합니다.
        # 이 동안 이벤트 루프는 다른 작업을 처리할 수 있습니다.
        data = await response.text()
        end_time = time.time()
        print(f"[{url}] 요청 완료! ({len(data)} 문자, {end_time - start_time:.2f}초)")
        return url, len(data)

async def main():
    """
    여러 URL을 비동기적으로 동시에 요청하는 메인 함수
    """
    urls = [
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/users/1",
        "https://jsonplaceholder.typicode.com/comments/1",
        "https://jsonplaceholder.typicode.com/todos/1"
    ]

    print("--- 비동기 웹 요청 시작 (동시 처리) ---")
    start_total_time = time.time()

    async with aiohttp.ClientSession() as session:
        # asyncio.gather는 여러 코루틴(비동기 함수 호출 결과)을 동시에 실행하고,
        # 모든 코루틴이 완료될 때까지 기다립니다.
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    end_total_time = time.time()
    print(f"--- 모든 비동기 웹 요청 완료! 총 {end_total_time - start_total_time:.2f}초 ---")

    for url, data_len in results:
        print(f"URL: {url}, 데이터 길이: {data_len}")

    print("\n--- 동기 웹 요청 시뮬레이션 (순차 처리) ---")
    # 비교를 위한 동기 시뮬레이션 (실제로는 aiohttp 대신 requests 같은 라이브러리 사용)
    # 아래 코드는 실제 동기 요청이 아니라, 비동기 함수를 순차적으로 await 하는 방식입니다.
    # 하지만 실제 동기 I/O가 발생했다고 가정하고 시간을 측정합니다.
    start_sync_total_time = time.time()
    async with aiohttp.ClientSession() as session:
        for url in urls:
            await fetch_url(session, url) # 각 요청이 완료될 때까지 기다립니다.
    end_sync_total_time = time.time()
    print(f"--- 모든 동기 웹 요청 시뮬레이션 완료! 총 {end_sync_total_time - start_sync_total_time:.2f}초 ---")

if __name__ == "__main__":
    # asyncio.run()은 async main 함수를 실행하고 이벤트 루프를 관리합니다.
    asyncio.run(main())

참고: 위 Python 예제를 실행하려면 aiohttp 라이브러리가 필요합니다. pip install aiohttp

이 예제에서 asyncio.gather(*tasks)는 여러 fetch_url 코루틴을 동시에 실행하도록 스케줄링합니다. 각 fetch_url 내부의 await response.text() 부분에서 HTTP 응답을 기다리는 동안, Python 이벤트 루프는 다른 fetch_url 코루틴으로 전환하여 작업을 계속합니다. 이로 인해 여러 네트워크 요청이 거의 동시에 시작되고, 전체 실행 시간이 각 요청 시간을 합친 것보다 훨씬 짧아집니다.

4. 실무 적용 사례

비동기 프로그래밍은 현대 소프트웨어 개발의 다양한 분야에서 핵심적인 역할을 합니다.

  • 웹 서버 개발 (Node.js, Python FastAPI/Django Channels, C# ASP.NET Core):

    • I/O 바운드 작업 처리: 데이터베이스 쿼리, 외부 API 호출, 파일 시스템 접근 등 시간이 오래 걸리는 I/O 작업을 비동기적으로 처리하여 서버가 수많은 동시 요청을 블로킹 없이 효율적으로 처리할 수 있게 합니다. 이는 서버의 처리량(Throughput)을 크게 향상시킵니다.
    • 실시간 통신: WebSocket 기반의 실시간 채팅, 알림 서비스 등에서 비동기 I/O는 클라이언트와의 연결을 유지하면서도 서버의 다른 작업을 방해하지 않도록 돕습니다.
  • 프론트엔드 개발 (JavaScript):