LLM 서비스나 데이터베이스 중심의 애플리케이션을 개발할 때 성능 최적화의 핵심은 바로 비동기 프로그래밍입니다. 특히 사용자가 늘어날수록 서버가 버벅거리지 않게 하려면 Asyncio에 대한 이해가 필수적입니다.

오늘은 파이썬의 Asyncio를 요리에 비유하여 기초부터 심화 패턴까지 알기 쉽게 정리해 보겠습니다.

1. 동기(Sync) vs 비동기(Async): 라면 끓이기 비유

가장 먼저 동기와 비동기의 차이를 명확히 이해해야 합니다. 라면을 끓이는 과정에 비유해 보겠습니다.

동기(Sync) 방식은 물을 올리고 물이 끓을 때까지 가스불 앞에서 아무것도 하지 않고 기다리는 것과 같습니다. 물이 다 끓어야 비로소 파를 썰기 시작합니다. 매우 비효율적입니다.

비동기(Async) 방식은 물을 올려두고, 물이 끓는 시간 동안 놀지 않고 파를 썰거나 김치를 꺼내는 것입니다. 물이 다 끓으면 그때 면을 넣습니다. 시간을 훨씬 효율적으로 사용할 수 있습니다.

파이썬의 async와 await는 바로 기다리는 시간 동안 다른 작업을 할 수 있게 해주는 문법입니다.

2. 기초 문법: async, await, coroutine

파이썬에서 비동기 함수를 정의할 때는 def 앞에 async를 붙입니다. 이를 코루틴(Coroutine)이라고 부릅니다. 그리고 await는 여기서 잠시 기다릴 테니 그동안 다른 일을 하라고 제어권을 넘겨주는 명령어입니다.

Python
 
import asyncio
import time

# 일반 함수 (동기)
def sync_cooking():
    print("물 올리기 (3초 대기)")
    time.sleep(3) 
    print("물 끓음!")

# 비동기 함수 (코루틴)
async def async_cooking():
    print("물 올리기 (3초 대기)")
    # 3초를 기다리지만 컴퓨터가 멈추지 않고 다른 일을 할 수 있음
    await asyncio.sleep(3) 
    print("물 끓음!")

핵심은 time.sleep은 컴퓨터 전체를 멈추게 하지만, asyncio.sleep은 제어권을 다른 작업에게 양보한다는 점입니다.

3. gather: 멀티 태스킹의 정석

여러 작업을 동시에 처리해야 할 때 asyncio.gather를 사용합니다. 예를 들어 식단 분석과 수면 분석을 순서대로 하면 시간이 오래 걸리지만, 동시에 실행하면 가장 오래 걸리는 작업 시간만큼만 소요됩니다.

Python
 
import asyncio

async def chop_onion():
    print("양파 썰기 시작 (2초)")
    await asyncio.sleep(2)
    print("양파 완료!")
    return "양파"

async def boil_water():
    print("물 끓이기 시작 (3초)")
    await asyncio.sleep(3)
    print("물 완료!")
    return "물"

async def main():
    print("--- 요리 시작 ---")
    # 두 함수를 동시에 실행
    results = await asyncio.gather(chop_onion(), boil_water())
    print(f"--- 요리 끝! 결과물: {results} ---")

if __name__ == "__main__":
    asyncio.run(main())

이 코드를 실행하면 2초와 3초가 더해져 5초가 걸리는 것이 아니라, 가장 긴 시간인 3초 만에 모든 작업이 완료됩니다.

4. asyncpg: 데이터베이스도 비동기로

파이썬의 기본 DB 라이브러리는 동기식이라 DB 응답을 기다리는 동안 서버가 멈춥니다. 이를 해결하기 위해 비동기 전용 드라이버인 asyncpg를 사용해야 합니다.

Python
 
import asyncpg

class UserRepository:
    def __init__(self, dsn):
        self.dsn = dsn
        self.pool = None

    async def connect(self):
        self.pool = await asyncpg.create_pool(self.dsn)

    async def get_user(self, user_id):
        async with self.pool.acquire() as connection:
            row = await connection.fetchrow(
                "SELECT * FROM users WHERE id = $1", user_id
            )
            return row

    async def save_user(self, name):
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                await conn.execute("INSERT INTO users (name) VALUES ($1)", name)

핵심은 with 대신 async with를 사용하고, execute 대신 await execute를 사용한다는 점입니다.

5. AsyncGenerator: 스트리밍 응답 처리

LLM 서비스에서 답변이 한 글자씩 실시간으로 나오는 타다닥 효과를 구현하려면 비동기 제너레이터를 사용해야 합니다. async def와 yield를 조합하여 구현합니다.

Python
 
async def llm_stream_simulator():
    sentence = "안녕하세요"
    for char in sentence:
        await asyncio.sleep(0.5)
        yield char 

async def main():
    print("답변: ", end="")
    async for char in llm_stream_simulator():
        print(char, end="", flush=True)

if __name__ == "__main__":
    asyncio.run(main())

일반적인 for문 대신 async for를 사용하여 데이터가 생성되는 즉시 받아볼 수 있습니다.

6. 동시성 패턴: Semaphore와 Lock

너무 많은 요청이 한꺼번에 몰리는 것을 방지하기 위해 안전장치가 필요합니다.

 

Semaphore(세마포어)는 동시 실행 가능한 작업 수를 제한합니다. 예를 들어 API 요청을 동시에 3개까지만 보내도록 제한할 수 있습니다.

Lock(락)은 여러 작업이 동시에 하나의 변수를 수정할 때 데이터가 꼬이지 않도록 잠그는 역할을 합니다.

Python
 
import asyncio

sem = asyncio.Semaphore(3)

async def access_api(i):
    async with sem: 
        print(f"User {i} 접속 중...")
        await asyncio.sleep(2)
        print(f"User {i} 완료!")

async def main():
    tasks = [access_api(i) for i in range(10)]
    await asyncio.gather(*tasks)

이렇게 하면 10개의 요청이 한 번에 실행되지 않고, 3개씩 끊어서 안정적으로 실행됩니다.


통합 예제: 비동기의 흐름 완벽 이해하기

마지막으로 위에서 배운 개념을 종합하여, await가 기다리는 동안 실제로 다른 작업이 어떻게 끼어드는지 보여주는 예제입니다.

오래 걸리는 물 끓이기(Task)와 그 사이에 쉴 새 없이 진행되는 양파 썰기(Streaming)를 동시에 실행해 보겠습니다.

Python
 
import asyncio

# 3초 걸리는 무거운 작업
async def boil_water():
    print("물: 불 올렸다! (3초 대기 시작)")
    # 여기서 await를 만나면 CPU 제어권을 놓아줍니다.
    await asyncio.sleep(3)  
    print("물: 3초 지났다! 물 다 끓음!")
    return "뜨거운 물"

# 실시간으로 데이터를 주는 스트리밍 작업
async def chop_onion():
    for i in range(1, 4):
        await asyncio.sleep(0.5) 
        yield f"양파 {i}개 썰음" 

async def main():
    print("--- 주방 가동 ---")
    
    # 1. 물 끓이기 작업을 예약 (실행은 시작하되 완료를 기다리지 않고 넘어감)
    task1 = asyncio.create_task(boil_water())
    
    # 2. 물이 끓는 3초 동안, 멍하니 있지 않고 양파를 썹니다 (스트리밍 처리)
    async for onion in chop_onion():
        print(f"   -> {onion} (바로 받음!)")
        
    # 3. 양파를 다 썰고 나서 물 끓이기 작업이 끝났는지 확인하고 결과를 받습니다
    result = await task1
    print(f"--- 요리 끝: {result} ---")

if __name__ == "__main__":
    asyncio.run(main())

실행 결과:

Plaintext
 
--- 주방 가동 ---
물: 불 올렸다! (3초 대기 시작)
   -> 양파 1개 썰음
   -> 양파 2개 썰음
   -> 양파 3개 썰음
물: 3초 지났다! 물 다 끓음!
--- 요리 끝: 뜨거운 물 ---

결과를 보면 물 끓이기(boil_water)가 시작된 후, 3초가 지나기 전에 양파 썰기 작업들이 중간중간 실행되는 것을 확인할 수 있습니다. 이것이 바로 비동기 프로그래밍이 시스템 자원을 효율적으로 사용하는 방식입니다.

핵심 요약

  1. I/O 작업(DB, API)에는 무조건 async/await를 사용합니다.
  2. 여러 작업을 동시에 할 때는 asyncio.gather를 활용합니다.
  3. 스트리밍 데이터는 async for로 처리합니다.
  4. 비동기는 작업을 건너뛰는 게 아니라, 기다리는 시간에 다른 일을 하는 것입니다.

 

🧐 잠깐, 파이썬은 멀티스레드가 안 된다던데?

오늘 비동기 프로그래밍을 배우면서 혹시 이런 의문이 들지 않으셨나요?

"어? 파이썬은 GIL(Global Interpreter Lock) 때문에 진정한 멀티스레딩이 안 된다고 들었는데, 어떻게 동시에 여러 일을 처리하는 거지?"

정말 날카로운 지적입니다. 결론부터 말씀드리면 파이썬의 비동기는 하나의 스레드 안에서 작업 순서를 기가 막히게 교체하는 기술이지, 물리적으로 여러 개의 뇌(CPU)를 동시에 쓰는 것은 아닙니다.

상황 : I/O 바운드 (기다리는 일) - ★우리가 하는 것★

  • 작업: DB 조회, API 요청, 파일 읽기/쓰기.
  • Python: A 스레드가 "DB에 쿼리 날리고 올게" 하고 마이크(GIL)를 내려놓고 나감. 그 사이에 B 스레드가 마이크 잡고 일함.
  • 결과: CPU는 쉬지 않고 계속 일함. 

Asyncio는 이 "기다리는 시간(I/O)"을 극단적으로 효율화한 거야. 스레드를 운영체제 레벨에서 여러 개 만드는 비용조차 아까워서, 단 하나의 스레드 안에서 작업만 휙휙 바꾸는 기술입니다.

 

그렇다면 파이썬은 영원히 한 번에 하나밖에 처리를 못 하는 걸까요? CPU를 100% 활용해서 복잡한 연산을 하려면 어떻게 해야 할까요?

다음 포스팅에서는 파이썬 개발자라면 반드시 알아야 할 GIL(글로벌 인터프리터 락)의 정체와, 이를 극복하고 진정한 병렬 처리를 가능하게 하는 Multiprocessing(멀티 프로세싱)에 대해 다뤄보겠습니다.

+ Recent posts