CrewAI로 에이전트를 만들던 중, 실행 속도가 느려지는 문제가 발생했다. 이에 한 팀원이 실행 성능을 개선하기 위해 각 에이전트의 실행을 비동기 처리하는 방안을 제안했다. 하지만 해당 로직은 본질적으로 비동기 처리로 해결할 수 있는 문제가 아니었고, 오히려 불필요한 구조 변경이 될 가능성이 있었다.
그럼에도 불구하고 팀원 간 논의가 이어졌고, 멘토링 시간에 멘토님께서 해당 팀원의 접근 방식에 대해 피드백을 주셨다. 비동기를 모든 경우에 적용할 수 있는 것은 아니며, 상황에 따라 적절한 방식이 필요하다는 점을 강조하셨다. 또한, 우리 팀에게도 비동기의 개념과 적절한 활용법을 명확히 이해하는 것이 중요하다는 조언을 해주셨다.
이번 글에서는 동기와 비동기의 개념, 코드 예제, 실행 흐름, 관련 라이브러리, 사용해야 하는 상황, 장단점까지 정리해보겠다.
1. 동기(Synchronous)란?
코드가 위에서 아래로 순차적으로 실행되는 방식이다. 하나의 작업이 끝나야 다음 작업을 수행할 수 있다.
코드 예제
import time
def task(name, duration):
print(f"{name} 시작")
time.sleep(duration) # 지정된 시간 동안 실행 중지
print(f"{name} 완료")
def main():
task("작업 1", 2)
task("작업 2", 3)
task("작업 3", 1)
main()
실행 흐름
작업 1 시작
(2초 대기)
작업 1 완료
작업 2 시작
(3초 대기)
작업 2 완료
작업 3 시작
(1초 대기)
작업 3 완료
작업 1이 끝나야 작업 2가 시작되고, 작업 2가 끝나야 작업 3이 실행되는 것이다.
장점
- 작업이 순차적으로 실행되므로 코드가 직관적이다.
단점
- CPU 작업이 많은 경우에는 적절하지만, I/O(입출력) 대기 시간이 긴 작업에서는 비효율적이다.
- 실행 속도가 느릴 수 있다.
동기를 사용해야하는 경우
- 단순한 연산 작업(ex: 리스트 정렬, 데이터 변환)
- 순차적인 로직이 중요한 경우(ex: 단계별 처리 로직)
- 멀티스레딩이 필요 없는 간단한 프로그램
2. 비동기란(Asynchronous)?
여러 작업을 동시에 실행할 수 있도록 한다. 특정 작업이 완료될 때까지 기다리지 않고, 다음 작업을 진행할 수 있다.
코드 예제 (asyncio 사용)
import asyncio
async def task(name, duration):
print(f"{name} 시작")
await asyncio.sleep(duration) # 지정된 시간 동안 실행 중지 (비동기 방식)
print(f"{name} 완료")
async def main():
await asyncio.gather(
task("작업 1", 2),
task("작업 2", 3),
task("작업 3", 1)
)
asyncio.run(main())
실행 흐름
작업 1 시작
작업 2 시작
작업 3 시작
(1초 후) 작업 3 완료
(1초 후) 작업 1 완료
(1초 후) 작업 2 완료
모든 작업이 동시에 시작되었고, 실행 시간이 짧은 작업이 먼저 끝나는 것이다.
장점
I/O 작업이 많을 때 유리하다. (네트워크 요청, 파일 읽기/쓰기, DB 작업 등)
여러 작업이 동시에 실행되므로 CPU 자원을 효과적으로 사용할 수 있다.
단점
코드가 조금 더 복잡해질 수 있다. (비동기 함수는 async def로 정의해야하며, await 키워드가 필요함)
비동기를 사용해야 하는 경우
- 네트워크 요청을 여러 개 처리할 때(ex: 웹 크롤링, API 호출)
- 파일을 읽거나 쓸 때(ex: 로그 저장, 대용량 데이터 처리)
- 데이터베이스와 상호작용할 때(ex: 다중 쿼리 실행)
- 사용자 인터페이스(UI) 응답성을 높일 때(ex: 웹 애플리케이션, 게임)
파이썬에서는 동기/비동기 처리를 위한 다양한 라이브러리를 제공한다.
라이브러리 | 설명 |
asyncio | 파이썬 내장 비동기 라이브러리, await 사용 |
threading | 멀티스레딩 지원 (병렬 작업) |
multiprocessing | 멀티프로세싱 지원 (CPU 작업 병렬 처리) |
aiohttp | 비동기 HTTP 요청 라이브러리 |
concurrent.futures | 스레드풀, 프로세스풀을 제공하여 병렬 실행 가능 |
여기서 특히 asyncio가 많이 사용되는 것을 자주 봤다.
asyncio는 코루틴(Coroutine) 이라는 핵심 개념을 기반으로 동작한다.
3. 코루틴(Coroutine)이란?
파이썬에서 비동기 실행을 위한 함수다. async def 키워드로 정의되며, 호출 즉시 실행되지 않고 코루틴 객체(Coroutine Object) 를 반환한다. 코루틴을 실행하려면 await 키워드를 사용하거나 asyncio.run()을 호출해야 한다.
코드 예제
import asyncio
async def my_coroutine():
print("코루틴 시작")
await asyncio.sleep(1)
print("코루틴 종료")
# 코루틴 함수를 호출했을 때 반환되는 객체 확인
coroutine_obj = my_coroutine()
print(coroutine_obj) # <coroutine object my_coroutine at 0x...>
# 코루틴을 실행하는 방법
asyncio.run(my_coroutine())
- async def my_coroutine() → 코루틴 함수 정의 (즉시 실행되지 않고 코루틴 객체를 반환함)
- await asyncio.sleep(1) → 1초 동안 비동기 대기 (다른 작업을 실행할 수 있도록 이벤트 루프에 제어권을 넘김)
- asyncio.run(my_coroutine()) → 이벤트 루프를 생성하고 코루틴 실행 (이벤트 루프가 종료될 때까지 대기)
출력결과
<coroutine object my_coroutine at 0x...>
코루틴 시작
(1초 대기)
코루틴 종료
코루틴은 일반 함수처럼 호출한다고 실행되지 않으며, 실행하려면 이벤트 루프(Event Loop)가 필요하다.
4. 이벤트 루프(Event Loop)란?
이벤트 루프는 비동기 작업을 스케줄링하고 실행하는 역할을 한다. 파이썬의 asyncio.run() 함수는 이벤트 루프를 자동으로 생성하고 실행해주며, 여러 개의 코루틴을 동시에 처리할 수 있도록 해준다. 실행 중인 코루틴이 await를 만나면 해당 작업이 일시 중단되고, 이벤트 루프는 다른 코루틴을 실행하면서 효율적으로 작업을 분배한다.
코드 예제(이벤트 루프에서 여러 개의 코루틴 실행)
- asyncio.gather()를 사용하면 여러 개의 코루틴을 동시에 실행할 수 있다.
- await asyncio.sleep(duration)을 만나면 해당 코루틴은 대기하고, 이벤트 루프는 다른 코루틴을 실행한다.
- 따라서 실행 속도가 빨라지며, 비동기 처리가 가능해진다.
import asyncio
async def task(name, duration):
print(f"{name} 시작")
await asyncio.sleep(duration)
print(f"{name} 완료")
async def main():
await asyncio.gather(
task("작업 1", 2),
task("작업 2", 3),
task("작업 3", 1)
)
asyncio.run(main())
5. await이란?
비동기 함수를 호출할 때 사용하는 키워드다. await을 사용하면 해당 작업이 완료될 때까지 기다렸다가 다음 코드를 실행한다. await 없이 async 함수(코루틴)를 호출하면 코루틴 객체만 반환될 뿐 실행되지 않는다.
잘못된 사용 예제
import asyncio
async def say_hello():
print("Hello!")
await asyncio.sleep(1)
print("Bye!")
async def main():
say_hello() # 실행되지 않고 코루틴 객체만 반환됨
asyncio.run(main())
이렇게 작성할 경우 아무것도 출력되지 않는다.
올바른 사용 예제
async def main():
await say_hello() # 반드시 await로 호출해야 실행됨
asyncio.run(main())
출력 결과
Hello!
(1초 대기)
Bye!
6. asyncio에서 동시 실행 방식
1. asyncio.gather()
asyncio.gather()는 여러 개의 코루틴을 동시에 실행하고, 모든 작업이 끝날 때까지 기다린다.
import asyncio
async def task1():
await asyncio.sleep(2)
return "Task 1 완료"
async def task2():
await asyncio.sleep(1)
return "Task 2 완료"
async def main():
result = await asyncio.gather(task1(), task2())
print(result)
asyncio.run(main())
출력 결과
task2()가 먼저 종료되더라도 asyncio.gather()는 모든 작업이 끝날 때까지 기다린다.
['Task 1 완료', 'Task 2 완료']
2. asyncio.create_task()
asyncio.create_task()는 코루틴을 백그라운드에서 실행할 수 있도록 태스크(Task)로 감싸준다.
이를 사용하면 기다리지 않고 다른 작업을 먼저 실행할 수 있다.
import asyncio
async def background_task():
print("백그라운드 작업 시작")
await asyncio.sleep(2)
print("백그라운드 작업 완료")
async def main():
task = asyncio.create_task(background_task()) # 백그라운드 실행
print("메인 작업 실행 중...")
await asyncio.sleep(1)
print("메인 작업 완료")
await task # 백그라운드 작업이 끝날 때까지 기다림
asyncio.run(main())
출력 결과
출력 결과를 보면, asyncio.create_task()를 사용하면 background_task()가 백그라운드에서 실행되며, await task를 만나기 전까지 main()의 다른 코드들이 먼저 실행된다. 즉, 특정 작업을 미리 실행하고 싶을 때 유용하며, 메인 로직과 동시에 실행해야 하는 작업이 있을 때 효과적으로 활용할 수 있다.
백그라운드 작업 시작
메인 작업 실행 중...
메인 작업 완료
백그라운드 작업 완료
여기서 핵심은 background_task()가 실행되는 동안 main()의 다른 코드도 함께 실행된다는 점이다. 만약 동기 코드였다면 background_task()가 끝난 후에야 main() 코드가 실행되었겠지만, 비동기이므로 동시에 실행되며 실행 흐름이 더욱 효율적이다.
관련 포스팅
'Python' 카테고리의 다른 글
[Python] Redis를 활용한 중복 추천 방지 캐싱 서비스 개발 (0) | 2025.02.19 |
---|---|
[Python] print와 logging, 디버깅할 때 무엇을 쓸까? (0) | 2025.02.17 |
[Python] FastAPI와 React 연동 및 데이터 흐름과 처리 과정 (0) | 2025.01.31 |
[Python] Google Maps & SerpAPI를 활용한 맛집 추천 개발기 (0) | 2025.01.30 |
[Python] SQLAlchemy와 SQLModel의 모든 것: 데이터베이스 작업의 핵심 도구 (0) | 2025.01.26 |