2026년 5월 25일

비동기 프로그래밍 마스터하기: `async/await`로 논블로킹 I/O와 반응형 애플리케이션 구축하기

40
비동기 프로그래밍 마스터하기: `async/await`로 논블로킹 I/O와 반응형 애플리케이션 구축하기

비동기 프로그래밍 마스터하기: async/await로 논블로킹 I/O와 반응형 애플리케이션 구축하기

비동기 프로그래밍 마스터하기: async/await로 논블로킹 I/O와 반응형 애플리케이션 구축하기

1. 개념 소개

1. 개념 소개

현대 소프트웨어 개발에서 성능과 응답성은 더 이상 선택 사항이 아닌 필수 요소입니다. 특히 네트워크 통신이나 파일 I/O와 같은 입출력(I/O) 작업이 많은 애플리케이션에서는 이 부분에서 병목 현상이 발생하기 쉽습니다. 이러한 문제를 해결하기 위해 등장한 강력한 패러다임이 바로 비동기 프로그래밍이며, 이를 우아하게 구현하는 핵심 문법이 **async/await**입니다.

정의

**비동기 프로그래밍(Asynchronous Programming)**은 특정 작업의 완료를 기다리지 않고 다음 작업을 즉시 수행하며, 작업이 완료되면 결과나 콜백을 통해 알림을 받는 프로그래밍 방식입니다. 이와 반대되는 개념은 **동기 프로그래밍(Synchronous Programming)**으로, 한 작업이 완료될 때까지 다음 작업을 시작하지 않고 기다리는 방식입니다.

**async/await**는 비동기 코드를 동기 코드처럼 읽고 쓸 수 있게 해주는 문법적 설탕(Syntactic Sugar)입니다.

  • async 키워드: 함수 앞에 붙어 해당 함수가 비동기 함수임을 선언합니다. 이 함수는 항상 Promise(JavaScript) 또는 Awaitable(Python) 객체를 반환합니다.
  • await 키워드: async 함수 내에서만 사용할 수 있으며, 비동기 작업(Promise 또는 Awaitable 객체)의 완료를 기다립니다. await는 작업이 완료될 때까지 해당 async 함수의 실행을 일시 중지하고, 그 동안 다른 작업이 이벤트 루프를 통해 실행될 수 있도록 제어권을 넘깁니다. 작업이 완료되면, await 다음의 코드가 다시 실행을 재개합니다. 중요한 것은 이 일시 중지가 블로킹(Blocking)이 아니라는 점입니다.

탄생 배경

초기의 많은 프로그래밍 모델은 동기적이었습니다. 한 번에 하나의 작업만 순차적으로 처리했죠. 하지만 웹 애플리케이션, GUI(Graphical User Interface) 애플리케이션, 그리고 고성능 서버 애플리케이션이 등장하면서 이러한 동기 방식의 한계가 명확해졌습니다.

  • GUI 애플리케이션: 데이터 로딩이나 네트워크 요청 같은 시간이 오래 걸리는 작업을 동기적으로 처리하면, 해당 작업이 완료될 때까지 UI가 멈춰 사용자 경험이 매우 나빠집니다.
  • 웹 서버: 수많은 클라이언트 요청을 처리해야 하는 웹 서버에서 각 요청마다 블로킹 I/O가 발생한다면, 동시에 처리할 수 있는 요청 수가 현저히 줄어들어 서버의 처리량이 급격히 떨어집니다.
  • 콜백 지옥(Callback Hell) 문제: async/await가 등장하기 전, 비동기 처리는 주로 콜백 함수를 통해 이루어졌습니다. 여러 비동기 작업이 순차적으로 의존성을 가질 때, 콜백 함수가 중첩되어 코드가 복잡해지고 가독성이 떨어지는 '콜백 지옥' 현상이 발생했습니다. async/await는 이러한 복잡성을 해소하고 비동기 코드를 훨씬 더 직관적으로 작성할 수 있게 해줍니다.

왜 중요한가?

async/await를 활용한 비동기 프로그래밍은 현대 소프트웨어 개발에서 다음과 같은 이유로 매우 중요합니다.

  1. 사용자 경험 향상: 웹 브라우저나 모바일 앱에서 UI가 멈추지 않고 부드럽게 동작하여 사용자가 쾌적하게 앱을 이용할 수 있도록 합니다.
  2. 시스템 처리량 증대: 서버 측 애플리케이션에서 I/O 대기 시간 동안 다른 요청을 처리함으로써, 적은 자원으로도 훨씬 많은 요청을 동시에 처리할 수 있어 시스템의 처리량(Throughput)이 크게 향상됩니다.
  3. 자원 효율성: 블로킹 방식은 I/O 대기 동안 스레드가 아무 일도 하지 않고 자원을 점유하지만, 비동기 방식은 I/O 대기 동안 스레드를 다른 유용한 작업에 활용하므로 자원 활용 효율이 극대화됩니다.
  4. 코드 가독성 및 유지보수성: 콜백 지옥과 같은 문제 없이 비동기 로직을 동기 코드처럼 순차적으로 읽을 수 있게 해주어 코드 이해와 유지보수가 훨씬 쉬워집니다.
  5. 현대 웹/네트워크 프로그래밍의 필수 요소: Node.js, Python의 FastAPI/Sanic, C#의 ASP.NET Core 등 최신 고성능 웹 프레임워크들은 비동기 I/O를 기본으로 하며, async/await는 이러한 환경에서 효과적인 프로그래밍을 위한 핵심 도구입니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

비동기 프로그래밍, 특히 async/await의 핵심 원리는 논블로킹(Non-blocking) I/O이벤트 루프(Event Loop) 기반의 **협력적 멀티태스킹(Cooperative Multitasking)**에 있습니다.

동기 vs 비동기 (식당 비유)

우리가 식당에서 음식을 주문하는 상황을 상상해봅시다.

  • 동기 방식: 당신은 카운터에서 주문을 하고, 음식이 나올 때까지 카운터 앞에서 꼼짝 않고 기다립니다. 음식이 나오면 그제서야 자리에 앉아 식사를 합니다. 이 방식은 한 번에 한 고객만 주문과 수령을 할 수 있어, 다른 고객들은 긴 줄을 서서 기다려야 합니다. (블로킹)
  • 비동기 방식: 당신은 카운터에서 주문을 하고, 진동벨을 받아서 자리에 앉아 다른 일을 하거나 친구와 대화를 나눕니다. 음식이 준비되면 진동벨이 울리고, 그때서야 카운터로 가서 음식을 받아옵니다. 이 방식은 여러 고객이 동시에 주문하고 대기할 수 있어, 식당 전체의 처리량이 훨씬 높아집니다. (논블로킹)

여기서 '음식이 준비되는 시간'이 바로 I/O 작업(네트워크 요청, 파일 읽기 등)에 해당합니다. await는 마치 진동벨을 들고 기다리는 행위에 비유할 수 있습니다. 해당 작업이 완료될 때까지 기다리되, 그 동안 다른 일을 할 수 있도록 해주는 것이죠.

이벤트 루프와 태스크 큐

async/await는 단일 스레드 환경에서 I/O 작업을 효율적으로 처리하기 위해 **이벤트 루프(Event Loop)**라는 메커니즘을 활용합니다. JavaScript(Node.js, 브라우저)와 Python(asyncio) 모두 유사한 개념을 사용합니다.

  1. 단일 스레드: 기본적인 비동기 런타임은 하나의 메인 스레드에서 실행됩니다.
  2. 이벤트 루프: 이 스레드에서 계속해서 반복적으로 실행되는 특별한 루프입니다. 이 루프는 할 일이 있는지 지속적으로 확인합니다.
  3. 태스크 큐(Task Queue) / 마이크로태스크 큐(Microtask Queue): 비동기 작업이 완료되면, 그 결과와 함께 실행해야 할 콜백 함수(또는 후속 처리 로직)가 이 큐에 추가됩니다.
  4. 작동 방식:
    • async 함수가 await 키워드를 만나면, 현재 실행 중인 작업을 일시 중지하고 제어권을 이벤트 루프에게 넘깁니다.
    • 이벤트 루프는 태스크 큐에 대기 중인 다른 작업(예: 이전 I/O 작업의 완료 콜백)이 있는지 확인하고, 있다면 해당 작업을 메인 스레드에서 실행합니다.
    • await가 기다리던 외부 I/O 작업(예: 네트워크 요청)이 완료되면, 그 결과와 함께 await 다음 코드를 실행해야 한다는 정보가 태스크 큐에 추가됩니다.
    • 이벤트 루프는 현재 실행 중인 작업이 없고 태스크 큐에 대기 중인 작업이 있을 때, 큐에서 작업을 꺼내어 메인 스레드에서 실행합니다. 이 과정에서 await가 일시 중지했던 async 함수가 재개되어 남은 코드를 실행합니다.

이러한 방식 덕분에 단일 스레드임에도 불구하고 I/O 대기 시간 동안 다른 유용한 작업을 처리할 수 있어 높은 처리량을 달성할 수 있습니다. 이는 "동시성(Concurrency)"을 제공하지만, "병렬성(Parallelism)"과는 다릅니다. 병렬성은 여러 작업을 동시에 물리적으로 실행하는 것이고, 동시성은 여러 작업을 번갈아 가며 빠르게 전환하여 동시에 실행되는 것처럼 보이게 하는 것입니다.

(다이어그램 설명: 메인 스레드, 이벤트 루프, 태스크 큐, 그리고 네트워크/파일 I/O와 같은 외부 작업 간의 화살표 흐름을 상상해보세요. 메인 스레드가 await를 만나면 I/O 요청을 외부로 보내고, 자신은 이벤트 루프로 가서 태스크 큐를 확인합니다. I/O 작업이 완료되면 결과가 태스크 큐에 들어가고, 이벤트 루프가 이를 감지하여 메인 스레드에 다시 실행을 위임합니다.)

3. 코드 예제 2개

예제 1: Python - asyncio를 사용한 비동기 HTTP 요청

Python의 asyncio 모듈은 비동기 프로그래밍을 위한 표준 라이브러리입니다. aiohttp와 같은 라이브러리를 사용하면 비동기 HTTP 요청을 쉽게 할 수 있습니다.

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    """주어진 URL로부터 비동기적으로 데이터를 가져옵니다."""
    start_time = time.time()
    async with session.get(url) as response:
        content = await response.text()
        end_time = time.time()
        print(f"[{url}] fetching took {end_time - start_time:.2f} seconds.")
        return content[:50] # 내용의 처음 50자만 반환

async def main_python_async():
    """여러 URL을 비동기적으로 동시에 가져옵니다."""
    urls = [
        "https://www.google.com",
        "https://www.github.com",
        "https://www.python.org",
        "https://www.naver.com"
    ]
    
    print("--- 비동기 HTTP 요청 시작 ---")
    start_total_time = time.time()

    async with aiohttp.ClientSession() as session:
        # 모든 fetch_url 코루틴을 동시에 실행하도록 스케줄링
        tasks = [fetch_url(session, url) for url in urls]
        # 모든 태스크가 완료될 때까지 기다림
        results = await asyncio.gather(*tasks)
    
    end_total_time = time.time()
    print(f"--- 비동기 HTTP 요청 완료, 총 {end_total_time - start_total_time:.2f} seconds 소요 ---")
    for i, result in enumerate(results):
        print(f"URL {urls[i]}: {result}...")

# 동기 방식으로 동일한 작업을 수행하는 함수 (비교용)
import requests

def fetch_url_sync(url):
    """주어진 URL로부터 동기적으로 데이터를 가져옵니다."""
    start_time = time.time()
    response = requests.get(url)
    content = response.text
    end_time = time.time()
    print(f"[{url}] fetching took {end_time - start_time:.2f} seconds.")
    return content[:50]

def main_python_sync():
    """여러 URL을 동기적으로 순차적으로 가져옵니다."""
    urls = [
        "https://www.google.com",
        "https://www.github.com",
        "https://www.python.org",
        "https://www.naver.com"
    ]
    
    print("\n--- 동기 HTTP 요청 시작 ---")
    start_total_time = time.time()

    results = []
    for url in urls:
        results.append(fetch_url_sync(url))
    
    end_total_time = time.time()
    print(f"--- 동기 HTTP 요청 완료, 총 {end_total_time - start_total_time:.2f} seconds 소요 ---")
    for i, result in enumerate(results):
        print(f"URL {urls[i]}: {result}...")

if __name__ == "__main__":
    # 비동기 함수 실행 (Python 3.7+에서 asyncio.run 사용)
    asyncio.run(main_python_async())
    # 동기 함수 실행
    main_python_sync()

예제 설명: main_python_async 함수에서 asyncio.gather(*tasks)를 사용하면 여러 fetch_url 코루틴(비동기 함수)을 동시에 실행할 수 있습니다. 각 fetch_url 함수는 await session.get(url)에서 네트워크 I/O가 완료될 때까지 일시 중지되고, 그 동안 이벤트 루프는 다른 fetch_url 코루틴의 I/O 작업을 처리합니다. 결과적으로 여러 URL을 동기적으로 요청하는 것보다 훨씬 빠르게 모든 데이터를 가져올 수 있습니다.

예제 2: JavaScript - fetch API를 사용한 비동기 데이터 가져오기

JavaScript는 브라우저 환경과 Node.js 환경 모두에서 async/await를 강력하게 지원합니다. fetch API는 네트워크 요청을 위한 현대적인 비동기 인터페이스입니다.

// JavaScript 코드 (브라우저 개발자 도구의 콘솔이나 Node.js 환경에서 실행 가능)

async function fetchData(url) {
    /** 주어진 URL로부터 비동기적으로 데이터를 가져옵니다. */
    const startTime = performance.now(); // Node.js에서는 process.hrtime.bigint() 사용 가능
    try {
        const response = await fetch(url); // await 키워드로 응답을 기다립니다.
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json(); // 응답 본문을 JSON으로 파싱하는 작업도 비동기입니다.
        const endTime = performance.now();
        console.log(`[${url}] fetching took ${(endTime - startTime).toFixed(2)} ms.`);
        return data;
    } catch (error) {
        console.error(`Error fetching ${url}:`, error);
        return null;
    }
}

async function mainJsAsync() {
    /** 여러 API 엔드포인트에서 비동기적으로 데이터를 가져옵니다. */
    const apiEndpoints = [
        "https://jsonplaceholder.typicode.com/todos/1",
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/users/1"
    ];

    console.log("--- 비동기 API 요청 시작 ---");
    const startTotalTime = performance.now();

    // Promise.all을 사용하여 모든 fetchData Promise가 완료될 때까지 기다립니다.
    const results = await Promise.all(apiEndpoints.map(url => fetchData(url)));

    const endTotalTime = performance.now();
    console.log(`--- 비동기 API 요청 완료, 총 ${(endTotalTime - startTotalTime).toFixed(2)} ms 소요 ---`);
    results.forEach((result, index) => {
        if (result) {
            console.log(`API ${apiEndpoints[index]}: ID=${result.id}, Title=${result.title || result.name}...`);
        }
    });
}

// 동기 방식으로 동일한 작업을 수행하는 함수 (비교용 - 실제 웹 환경에서는 fetch가 비동기이므로 순차적 비동기)
async function mainJsSyncSequential() {
    const apiEndpoints = [
        "https://jsonplaceholder.typicode.com/todos/1",
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/users/1"
    ];

    console.log("\n--- 순차적 비동기 API 요청 시작 (동기적 코드 흐름) ---");
    const startTotalTime = performance.now();

    const results = [];
    for (const url of apiEndpoints) {
        const data = await fetchData(url); // await가 각 호출을 순차적으로 기다리게 만듭니다.
        results.push(data);
    }

    const endTotalTime = performance.now();
    console.log(`--- 순차적 비동기 API 요청 완료, 총 ${(endTotalTime - startTotalTime).toFixed(2)} ms 소요 ---`);
    results.forEach((result, index) => {
        if (result) {
            console.log(`API ${apiEndpoints[index]}: ID=${result.id}, Title=${result.title || result.name}...`);
        }
    });
}

// 함수 실행
mainJsAsync();
mainJsSyncSequential();

예제 설명: mainJsAsync 함수에서는 Promise.allmap을 사용하여 여러 fetchData 호출을 동시에 시작하고 모든 Promise가 완료될 때까지 await로 기다립니다. fetchData 함수 내부에서도 fetch(url)response.json() 모두 비동기 작업이므로 await를 사용합니다. 이렇게 하면 각 API 요청의 대기 시간 동안 JavaScript 이벤트 루프는 다른 요청을 처리하거나 다른 스크립트를 실행할 수 있어 전체 처리 시간이 단축됩니다. mainJsSyncSequential은 각 await가 이전 호출이 끝날 때까지 기다리므로, 비록 비동기 함수를 사용했지만 동작 방식은 동기 코드와 유사하게 순차적으로 실행됩니다.

4. 실무 적용 사례

async/await를 활용한 비동기 프로그래밍은 다양한 실무 환경에서 핵심적인 역할을 수행합니다.

  1. 고성능 웹 서버 개발: Node.js의 Express/Koa, Python의 FastAPI/Sanic과 같은 비동기 웹 프레임워크는 async/await를 적극적으로 활용하여 높은 처리량과 낮은 지연 시간을 제공합니다. 데이터베이스 쿼리, 외부 API 호출 등 I/O 병목이 발생할 수 있는 모든 곳에 비동기 처리를 적용하여 서버 자원(특히 스레드)을 효율적으로 사용합니다.
    # FastAPI 예시
    from fastapi import FastAPI
    import httpx # 비동기 HTTP 클라이언트
    
    app = FastAPI()
    
    @app.get("/items/{item_id}")
    async def read_item(item_id: int):
        # 외부 API 호출 (비동기)
        async with httpx.AsyncClient() as client:
            response = await client.get(f"https://some-external-api.com/items/{item_id}")
            data = response.json()
        
        # 데이터베이스 쿼리 (비동기)
        # item_from_db = await db_session.get_item(item_id)
        
        return {"item_id": item_id, "external_data": data}
    
  2. 프론트엔드 웹 애플리케이션: React, Vue, Angular와 같은 프레임워크에서 사용자 인터페이스의 반응성을 유지하는 데 필수적입니다. 데이터 로딩, 이미지 처리, 애니메이션 등 시간이 걸리는 작업을 비동기로 처리하여 UI가 멈추는 현상(Freezing)을 방지하고 부드러운 사용자 경험을 제공합니다.
    // React 컴포넌트 예시
    import React, { useState, useEffect } from 'react';
    
    function UserProfile({ userId }) {
        const [user, setUser] = useState(null);
        const [loading, setLoading] = useState(true);
        const [error, setError] = useState(null);
    
        useEffect(() => {
            async function fetchUser() {
                try {
                    setLoading(true);
                    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
                    if (!response.ok) {
                        throw new Error('Failed to fetch user');
                    }
                    const userData = await response.json();
                    setUser(userData);
                } catch (err) {
                    setError(err);
                } finally {
                    setLoading(false);
                }
            }
            fetchUser();
        }, [userId]);
    
        if (loading) return <div>Loading user...</div>;
        if (error) return <div>Error: {error.message}</div>;
        if (!user) return <div>No user found.</div>;
    
        return (
            <div>
                <h1>{user.name}</h1>
                <p>Email: {user.email}</p>
                <p>Phone: {user.phone}</p>
            </div>
        );
    }
    
  3. 데이터 처리 및 배치 작업: 대량의 데이터를 외부 서비스에서 가져오거나, 여러 데이터베이스에 분산된 정보를 통합할 때, 비동기 처리를 통해 작업 시간을 크게 단축할 수 있습니다. 예를 들어, 수백 개의 API 엔드포인트에서 정보를 동시에 가져와야 할 때 유용합니다.
  4. IoT 및 실시간 시스템: 센서 데이터 수집, 장치 제어 등 I/O 중심의 실시간 시스템에서 지연 시간을 최소화하고 여러 이벤트를 동시에 처리하는 데 활용됩니다.

5. 자주 하는 실수와 해결법

async/await는 코드를 간결하게 만들지만, 몇 가지 흔한 오해와 실수로 인해 예상치 못한 문제가 발생할 수 있습니다.

  1. await를 사용하지 않아 비동기 작업이 제대로 완료되지 않거나 오류 발생:

    • 문제: async 함수 내에서 비동기 함수를 호출했지만 await를 붙이지 않으면, 해당 비동기 함수는 실행되지만 그 결과가 반환되기 전에 다음 코드가 실행될 수 있습니다. 이 경우 비동기 작업의 결과를 사용하려고 할 때 문제가 발생하거나, 작업이 완료되기 전에 프로그램이 종료될 수 있습니다. 특히 JavaScript에서 Promise 객체만 반환하고 완료를 기다리지 않는 경우가 흔합니다.
    • 해결법: 비동기 작업의 결과가 필요하거나, 해당 작업이 완료될 때까지 기다려야 할 때는 반드시 await를 사용하세요. 여러 비동기 작업을 동시에 시작하고 모두 완료될 때까지 기다리려면 Python에서는 asyncio.gather, JavaScript에서는 Promise.all을 사용합니다.
    # 잘못된 예 (Python)
    async def do_something_async():
        await asyncio.sleep(1)
        return "Done"
    
    async def main_bad():
        result_promise = do_something_async() # await가 없어 Promise/Awaitable 객체만 반환
        print("작업 시작!") # 즉시 출력
        # print(await result_promise) # 나중에 await를 해도 되지만, 위 라인에서 결과가 필요하면 문제
    
    # 올바른 예 (Python)
    async def main_good():
        print("작업 시작!")
        result = await do_something_async() # await로 결과 대기
        print(result)
    
  2. 모든 것을 비동기로 만들 필요는 없음 (CPU-Bound vs I/O-Bound):

    • 문제: async/await는 주로 I/O-bound(네트워크, 디스크) 작업의 효율성을 높이는 데 적합합니다. CPU-bound(복잡한 계산, 이미지 처리) 작업에 async/await를 적용해도 성능 향상이 미미하거나 오히려 오버헤드 때문에 느려질 수 있습니다. await는 I/O 대기 시 다른 작업을 할 수 있게 할 뿐, CPU 코어를 추가로 활용하여 병렬로 계산하는 것이 아니기 때문입니다.
    • 해결법: CPU-bound 작업에는 멀티스레딩(GIL이 있는 Python의 경우 concurrent.futures.ThreadPoolExecutor로 I/O-bound 병렬 처리)이나 멀티프로세싱(multiprocessing 모듈)을 고려해야 합니다. async/await는 단일 스레드 내에서 I/O 작업을 효율적으로 처리하는 데 집중하세요.
  3. async/await는 병렬 처리가 아님:

    • 문제: async/await를 사용하면 여러 작업이 동시에 진행되는 것처럼 보이지만, 대부분의 async/await 런타임(JavaScript의 이벤트 루프, Python의 asyncio)은 단일 스레드에서 협력적 멀티태스킹을 수행합니다. 즉, 물리적인 동시 실행(병렬 처리)이 아니라, I/O 대기 시간에 다른 작업을 번갈아 가며 실행하는 논리적인 동시성입니다. CPU-bound 작업이 await 없이 길게 실행되면 여전히 전체 시스템이 블로킹될 수 있습니다.
    • 해결법: 진정한 병렬 처리가 필요하다면, 여러 CPU 코어를 활용하는 멀티프로세싱(Python)이나 워커 스레드(Node.js worker_threads)를 사용해야 합니다. 비동기 프로그래밍은 I/O 효율성에 초점을 맞추고, CPU 집약적인 작업은 별도의 프로세스나 스레드로 분리하여 처리하는 것이 좋습니다.