FastAPI 요청 내 비동기 작업 관리의 함정 이해하기
Min-jun Kim
Dev Intern · Leapcell

소개
FastAPI는 비동기 기능과 Python의 asyncio 라이브러리에 대한 내재적 지원을 통해 고성능 웹 API 구축의 초석이 되었습니다. 오늘날 요구되는 웹 환경에서 중요한 이점인 많은 동시 요청을 효율적으로 처리할 수 있도록 개발자를 지원합니다. 비동기 프로그래밍, 특히 FastAPI에서 일반적인 패턴은 메인 요청-응답 주기를 차단하지 않고 백그라운드에서 실행되는 장기 실행 작업 또는 부수 효과를 오프로드하는 것입니다. 이는 종종 asyncio.create_task 또는 FastAPI의 BackgroundTasks 기능을 사용하여 달성됩니다. 이러한 기능은 매우 강력하지만, FastAPI 요청 컨텍스트 내에서 잘못 사용하면 리소스 누수부터 예상치 못한 요청 동작에 이르기까지 미묘하지만 중요한 함정을 초래할 수 있습니다. 이 글에서는 이러한 일반적인 함정을 탐구하고, FastAPI 애플리케이션이 견고하고 효율적으로 유지되도록 명확하고 실행 가능한 조언을 제공할 것입니다.
비동기 작업 관리의 공통 함정
함정에 대해 자세히 알아보기 전에 관련된 핵심 개념에 대한 공통된 이해를 확립해 봅시다.
asyncio.create_task(): 이 함수는asyncio이벤트 루프에서 독립적인 작업으로 실행되도록 코루틴을 예약합니다. 즉시asyncio.Task객체를 반환하여 호출자가 작업이 완료될 때까지 기다리지 않고 실행을 계속할 수 있도록 합니다. 작업은 이벤트 루프의 다른 작업과 병렬로 실행됩니다.BackgroundTasks: FastAPI의BackgroundTasks는 HTTP 응답이 전송된 후 실행되어야 하는 작업을 관리하기 위해 특별히 설계된 종속성 주입 메커니즘입니다. 이것은 이 특정 사용 사례에 대한asyncio.create_task의 편리한 래퍼로, 백그라운드 작업의 수명 주기가 요청 완료에 연결되도록 보장합니다.- 요청-응답 주기: FastAPI와 같은 웹 프레임워크에서 이는 서버에 도착하는 HTTP 요청부터 클라이언트로 HTTP 응답을 다시 보내는 것까지의 전체 여정을 의미합니다.
 
FastAPI 요청 처리기 내에서 asyncio.create_task 또는 BackgroundTasks를 사용하는 주요 목표는 클라이언트에 대한 응답을 지연시키지 않아야 하는 작업을 수행하는 것입니다. 이는 일반적으로 이메일 알림 보내기, 분석 로깅, 검색 인덱스 업데이트 또는 계산적으로 집중적인 데이터 처리와 같은 작업을 포함합니다.
함정 1: 중요 경로 작업에 대한 asyncio.create_task 미해결
가장 흔한 실수 중 하나는 사실상 응답에 중요한 작업에 asyncio.create_task를 사용하는 것입니다. create_task를 사용하면 작업 객체가 즉시 반환되지만, 후속 코드가 해당 작업의 완료 또는 결과에 의존하는 경우 create_task만 호출하고 해결(await)하지 않으면 부정확하거나 불완전한 응답이 발생합니다.
이 예제를 고려해 보세요:
import asyncio from fastapi import FastAPI, HTTPException app = FastAPI() async def fetch_user_data(user_id: int): # 네트워크 호출 또는 데이터베이스 쿼리 시뮬레이션 await asyncio.sleep(2) return {"id": user_id, "name": f"User {user_id}"} @app.get("/user/{user_id}") async def get_user_status(user_id: int): # 잘못된 사용: 응답에 중요한 작업 시작 user_data_task = asyncio.create_task(fetch_user_data(user_id)) # ... 다른 빠른 작업들 ... # user_data_task가 완료되지 않았기 때문에 여기의 응답은 사용자 데이터가 없거나 비어 있을 가능성이 높습니다. return {"message": "User request received", "user_status": "processing"}
이 시나리오에서 get_user_status 엔드포인트는 사용자 데이터를 반환하려고 하지만, user_data_task를 해결(await)하지 않고 asyncio.create_task를 사용함으로써 응답이 fetch_user_data가 완료될 기회를 갖기 전에 전송됩니다. 클라이언트는 불완전하거나 오해의 소지가 있는 응답을 받습니다.
수정 방법: 즉각적인 응답에 작업의 결과가 필요한 경우, 직접 해결(await)해야 합니다:
import asyncio from fastapi import FastAPI, HTTPException app = FastAPI() async def fetch_user_data(user_id: int): await asyncio.sleep(2) return {"id": user_id, "name": f"User {user_id}"} @app.get("/user/{user_id}") async def get_user_status_correct(user_id: int): # 올바른 사용: 중요한 작업 해결 user_data = await fetch_user_data(user_id) return {"message": "User data retrieved", "user": user_data}
함정 2: 백그라운드 작업의 오류 처리 무시
BackgroundTasks 또는 asyncio.create_task 작업이 실패하면 기본적으로 예외가 원래 요청 처리기로 다시 전파되지 않습니다. 왜냐하면 작업이 독립적으로 실행되기 때문입니다. 이는 오류가 백그라운드에서 발생하지만 사용자에게 또는 애플리케이션 모니터링 시스템에 전혀 보고되지 않는 사일런트 실패로 이어질 수 있습니다.
import asyncio from fastapi import FastAPI, BackgroundTasks, HTTPException app = FastAPI() async def send_welcome_email(email_address: str): await asyncio.sleep(1) # 이메일 전송 시뮬레이션 if "@" not in email_address: raise ValueError("Invalid email address for background task!") print(f"Welcome email sent to {email_address}") @app.post("/register/") async def register_user( username: str, email: str, background_tasks: BackgroundTasks ): # 잘못된 사용: 백그라운드 작업에 대한 오류 처리 없음 background_tasks.add_task(send_welcome_email, email) return {"message": f"User {username} registered. Email sending in background."}
send_welcome_email이 ValueError를 발생시키면 클라이언트는 200 OK 응답을 받지만 이메일은 전송되지 않고, 백그라운드 작업 자체 내에 특정 로깅/모니터링이 구현되지 않는 한 애플리케이션은 실패를 알지 못합니다.
수정 방법: 백그라운드 작업 내에 강력한 오류 처리 및 모니터링을 구현하세요. asyncio.create_task의 경우, 작업의 결과나 예외를 처리하는 콜백을 연결하기 위해 task.add_done_callback을 사용할 수 있습니다. BackgroundTasks의 경우, 백그라운드 함수에 적절한 try...except 블록과 로깅이 있는지 확인하세요. 재시도 메커니즘과 보장된 전달이 필요한 중요 백그라운드 작업의 경우 전용 메시지 큐(예: Celery, Redis Queue) 사용을 고려하세요.
import asyncio from fastapi import FastAPI, BackgroundTasks, HTTPException import logging app = FastAPI() logger = logging.getLogger(__name__) async def send_welcome_email_safe(email_address: str): try: await asyncio.sleep(1) if "@" not in email_address: raise ValueError("Invalid email address for background task!") logger.info(f"Welcome email sent to {email_address}") except Exception as e: logger.error(f"Failed to send welcome email to {email_address}: {e}") # 잠재적으로 지연된 편지함 또는 재시도 메커니즘으로 푸시 @app.post("/register-safe/") async def register_user_safe( username: str, email: str, background_tasks: BackgroundTasks ): # 올바른 사용: 내부 오류 처리가 있는 백그라운드 작업 background_tasks.add_task(send_welcome_email_safe, email) return {"message": f"User {username} registered. Email sending initiated."} # asyncio.create_task로 생성된 작업의 경우 완료 콜백을 추가할 수 있습니다: async def my_long_running_job(): await asyncio.sleep(5) raise RuntimeError("Something went wrong in the background!") def handle_task_result(task: asyncio.Task): try: task.result() # 예외가 발생한 경우 여기에 다시 발생시킵니다. except Exception as e: logger.error(f"Error in background job: {e}") else: logger.info("Background job completed successfully.") @app.get("/start-job/") def start_job(): task = asyncio.create_task(my_long_running_job()) task.add_done_callback(handle_task_result) return {"message": "Background job started."}
함정 3: 리소스 누수 및 관리되지 않는 작업
asyncio.Task 객체에 대한 참조를 유지하거나 이들을 정상적으로 종료할 메커니즘 없이 자주 생성하면 의도치 않게 리소스 누수를 만들 수 있습니다. 작업은 메모리를 소비하고 이벤트 루프의 오버헤드에 기여합니다. BackgroundTasks는 FastAPI에서 관리하지만(요청 수명 주기에 연결됨) 순수 asyncio.create_task 인스턴스는 더 신중한 관리가 필요합니다.
각 활성 세션에 대해 "모니터링" 작업을 시작하는 애플리케이션을 고려해 보세요. 그러나 이러한 작업은 세션이 종료될 때 명시적으로 취소되거나 해결(await)되지 않습니다. 시간이 지남에 따라 이것은 좀비 작업의 증가로 이어질 수 있습니다.
import asyncio from fastapi import FastAPI app = FastAPI() # 관리를 위해 활성 작업을 전역적으로 저장 (예시용으로 단순화) active_monitoring_tasks = {} async def monitor_session(session_id: str): try: while True: await asyncio.sleep(1) # 모니터링 작업 시뮬레이션 print(f"Monitoring session: {session_id}") except asyncio.CancelledError: print(f"Monitoring session {session_id} cancelled.") @app.get("/start-monitor/{session_id}") def start_monitor(session_id: str): # 잘못된 사용: 적절한 정리 논리 없이 전역적으로 작업 저장 if session_id not in active_monitoring_tasks: task = asyncio.create_task(monitor_session(session_id)) active_monitoring_tasks[session_id] = task return {"message": f"Monitoring started for session {session_id}"} return {"message": f"Monitoring already active for session {session_id}"} # 해당 /stop-monitor 또는 애플리케이션 종료 논리가 없으면 # 이러한 작업은 계속 실행되거나 참조로 남습니다.
관리되지 않으면 이러한 작업은 무기한 또는 애플리케이션이 종료될 때까지 실행될 수 있으며, 원래 목적이 더 이상 관련이 없을 때에도 리소스를 소비할 수 있습니다.
수정 방법: asyncio.create_task의 경우, 장기 실행 작업에 대한 명확한 수명 주기가 있는지 확인하세요. 여기에는 종종 다음이 포함됩니다:
- 작업에 대한 참조를 관리 가능한 컬렉션에 저장합니다.
 - 작업이 더 이상 필요하지 않을 때 
cancel()하기 위한 메커니즘을 구현합니다. - 작업이 취소를 인지하고 정리(예: 
task.cancel()후await task사용)를 완료하도록 취소된 작업을 해결(await)합니다. - 모든 활성 작업을 정상적으로 종료하여 애플리케이션 수명 주기 이벤트(예: FastAPI의 
@app.on_event("shutdown"))를 사용합니다. 
BackgroundTasks의 경우, FastAPI에서 암시적으로 관리되며 결국 완료되거나 가비지 수집된다는 점을 기억하세요. 여기서 주요 관심사는 애플리케이션의 전반적인 가동 시간에 비해 작업의 지속 시간입니다.
import asyncio from fastapi import FastAPI, BackgroundTasks app = FastAPI() active_monitoring_tasks = {} async def monitor_session(session_id: str): try: while True: await asyncio.sleep(1) print(f"Monitoring active: {session_id}") # 루프를 중지하거나 상태 변경을 처리하는 조건 추가 except asyncio.CancelledError: print(f"Monitoring for {session_id} was cancelled gracefully.") except Exception as e: print(f"Error in monitoring {session_id}: {e}") finally: print(f"Monitoring task for {session_id} finished.") @app.get("/start-monitor-safe/{session_id}") def start_monitor_safe(session_id: str): if session_id not in active_monitoring_tasks or active_monitoring_tasks[session_id].done(): task = asyncio.create_task(monitor_session(session_id)) active_monitoring_tasks[session_id] = task return {"message": f"Monitoring started for session {session_id}"} return {"message": f"Monitoring already active or restarting for session {session_id}"} @app.get("/stop-monitor/{session_id}") def stop_monitor(session_id: str): if session_id in active_monitoring_tasks and not active_monitoring_tasks[session_id].done(): task = active_monitoring_tasks.pop(session_id) task.cancel() try: await task # 작업이 취소를 인지하고 정리할 수 있도록 확인 return {"message": f"Monitoring for session {session_id} gracefully stopped."} except asyncio.CancelledError: return {"message": f"Monitoring for session {session_id} was already cancelled or shut down."} return {"message": f"No active monitoring for session {session_id}."} @app.on_event("shutdown") async def shutdown_event(): print("Application shutting down. Cancelling active monitoring tasks...") for session_id, task in list(active_monitoring_tasks.items()): if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass print(f"Monitoring for {session_id} cancelled during shutdown.") active_monitoring_tasks.clear() print("All active monitoring tasks stopped.")
결론
asyncio.create_task와 FastAPI의 BackgroundTasks는 응답성 있고 효율적인 비동기 웹 서비스를 구축하는 데 필수적인 도구입니다. 그러나 그 힘에는 신중한 구현의 책임이 따릅니다. 중요 작업과 백그라운드 작업의 차이를 이해하고, 강력한 오류 처리를 구현하며, 비동기 작업의 수명 주기를 신중하게 관리함으로써 일반적인 함정을 피하고 FastAPI의 전체 잠재력을 활용하여 애플리케이션이 성능이 뛰어나고 안정적인지 확인할 수 있습니다. 백그라운드에서 실행되는 작업은 눈에 보이지 않지만, 결코 염두에 두지 않아서는 된다는 점을 항상 기억하십시오.

