[에러 해결] [Python asyncio] RuntimeError: Event loop is closed 원인과 해결 방법

안녕하세요, 구글 SEO와 사용자 경험 최적화에 진심인 기술 블로그 에디터입니다. 오늘은 Python 비동기 프로그래밍에서 흔히 마주칠 수 있는 RuntimeError: Event loop is closed 예외에 대해 심도 있게 다뤄보고자 합니다. 특히 aiofiles를 사용하여 비동기 파일 I/O를 처리할 때 이 에러가 발생하는 경우와 그 해결책에 집중하겠습니다.

1. 에러 발생 상황

이 에러는 주로 다음과 같은 상황에서 발생합니다. 비동기 로그 관리자(LogManager) 클래스를 사용하여 파일을 열고(create_new_log_file) 로그를 작성하는(write_info, write_error) 도중, 파일을 명시적으로 닫는(close) 메서드를 호출하지 않았음에도 불구하고 RuntimeError: Event loop is closed 예외가 발생하는 경우입니다.

문제의 원인이 된 코드는 아래와 유사합니다:

import aiofiles
import datetime
from pathlib import Path

class LogManager():
    def __init__(self):
        current_dir = Path(__file__).resolve().parent
        now = datetime.datetime.now()
        file_string = now.strftime("%Y-%m-%d %H-%M-%S") + ".log"

        log_file_path = current_dir.parent / 'logs' / file_string
        self.path = log_file_path
        self.f = None # 파일 핸들 초기화 추가

    async def create_new_log_file(self):
            self.f = await aiofiles.open(str(self.path), mode="a")
            message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + "Login Service successfuly started!" + "\n"
            await self.f.write(message)

    async def write_info(self, info_text: str):
        if not self.f or self.f.closed: # 파일이 열려있는지 확인하는 로직 추가 (디버깅용)
            print("Warning: Attempting to write to a closed or uninitialized file handle.")
            raise RuntimeError("File handle is not open or already closed.")
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + info_text + "\n"
        await self.f.write(message)
        
    async def write_error(self, error_text: str):
        if not self.f or self.f.closed: # 파일이 열려있는지 확인하는 로직 추가 (디버깅용)
            print("Warning: Attempting to write to a closed or uninitialized file handle.")
            raise RuntimeError("File handle is not open or already closed.")
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [ERROR] " + error_text + "\n" # 원래 코드의 쉼표(,)를 +로 수정
        await self.f.write(message)

    async def close(self):
        if self.f and not self.f.closed: # 파일이 열려있을 때만 닫도록 수정
            await self.f.write(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + "Stopping Login Service" + "\n")
            await self.f.close()
        self.f = None # 닫은 후 핸들 초기화

개발자는 LogManager의 인스턴스를 생성하고 create_new_log_file을 호출한 다음, write_info를 사용합니다. 이때 close 메서드를 한 번도 호출하지 않았음에도 불구하고 RuntimeError: Event loop is closed가 발생하여 당황할 수 있습니다. 일반적으로 이 에러는 이미 닫힌 객체와 상호작용하려 할 때 발생하므로, 파일 객체(self.f)가 어떻게 닫혔는지 의문이 생기게 됩니다.

2. 명확한 발생 원인

이 에러의 핵심 원인은 Python의 asyncio 이벤트 루프의 생명 주기 관리와 관련이 있습니다.

aiofiles는 비동기 파일 I/O를 수행하기 위해 내부적으로 asyncio 이벤트 루프에 의존합니다. 파일이 aiofiles.open()을 통해 열리면, 이 파일 핸들(self.f)은 해당 이벤트 루프의 컨텍스트 내에서 동작하게 됩니다.

문제는 다음과 같습니다:

  1. 이벤트 루프의 예기치 않은 종료 또는 재설정: 개발자가 close 메서드를 명시적으로 호출하지 않았음에도 불구하고, 애플리케이션의 메인 스크립트가 종료되거나, 테스트 프레임워크가 테스트를 마친 후 이벤트 루프를 종료하거나 재설정하는 경우가 있습니다. 특히 짧게 실행되는 스크립트나 특정 환경에서는 이벤트 루프가 자동으로 시작되고 종료될 수 있습니다.
  2. 정리되지 않은 자원: 이벤트 루프가 종료될 때, 해당 루프에 연결된 모든 비동기 작업과 자원들이 적절히 정리되어야 합니다. 만약 LogManagerself.f와 같이 열려 있는 aiofiles 핸들이 이벤트 루프가 종료되기 전에 명시적으로 닫히지 않았다면, 이벤트 루프가 닫힌 후 해당 핸드를 통해 I/O 작업을 시도할 때 RuntimeError: Event loop is closed가 발생하게 됩니다. 이는 마치 이미 전원이 꺼진 기계에 명령을 내리는 것과 같습니다.
  3. `LogManager` 객체의 생명 주기와 무관한 이벤트 루프 종료: LogManager 인스턴스가 여전히 메모리에 살아있더라도, 이 인스턴스가 의존하고 있던 asyncio 이벤트 루프 자체가 종료되면 더 이상 비동기 I/O 작업을 수행할 수 없게 됩니다.

결론적으로, self.f는 직접 닫히지 않았지만, self.f가 동작하는 데 필수적인 asyncio 이벤트 루프가 닫힘으로써 더 이상 유효한 I/O 작업을 수행할 수 없게 된 것입니다.

3. 해결 방법 및 코드 예시

이러한 문제를 해결하는 가장 안전하고 권장되는 방법은 비동기 컨텍스트 매니저(async with)를 사용하는 것입니다. async with 문은 비동기 자원의 생명 주기를 자동으로 관리하여, 자원이 사용되는 블록을 벗어나면 해당 자원을 자동으로 정리(닫는)하도록 보장합니다. 이는 예외가 발생하더라도 마찬가지입니다.

해결 방법 1: `async with`를 사용한 LogManager 구현 (권장)

LogManager 클래스를 비동기 컨텍스트 매니저로 만들어 __aenter____aexit__ 메서드를 구현합니다. 이 방법을 사용하면 개발자가 명시적으로 close()를 호출할 필요 없이, async with 블록이 끝날 때 자동으로 파일이 닫힙니다.

import aiofiles
import datetime
from pathlib import Path

class LogManager():
    def __init__(self):
        current_dir = Path(__file__).resolve().parent
        now = datetime.datetime.now()
        file_string = now.strftime("%Y-%m-%d %H-%M-%S") + ".log"

        log_file_path = current_dir.parent / 'logs' / file_string
        self.path = log_file_path
        self.f = None

    async def __aenter__(self):
        # async with 블록에 진입할 때 파일 열기
        self.f = await aiofiles.open(str(self.path), mode="a")
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + "Login Service successfuly started!" + "\n"
        await self.f.write(message)
        return self # self를 반환하여 async with 문에서 사용할 수 있게 함

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        # async with 블록을 벗어날 때 파일 닫기
        if self.f and not self.f.closed:
            await self.f.write(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + "Stopping Login Service" + "\n")
            await self.f.close()
        self.f = None # 핸들 초기화

    async def write_info(self, info_text: str):
        if not self.f or self.f.closed:
            raise RuntimeError("Log file is not open or already closed. Use 'async with LogManager()' to ensure proper file handling.")
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [INFO] " + info_text + "\n"
        await self.f.write(message)
        
    async def write_error(self, error_text: str):
        if not self.f or self.f.closed:
            raise RuntimeError("Log file is not open or already closed. Use 'async with LogManager()' to ensure proper file handling.")
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " [ERROR] " + error_text + "\n"
        await self.f.write(message)

# 사용 예시
async def main():
    async with LogManager() as log_manager:
        await log_manager.write_info("User logged in successfully.")
        await log_manager.write_error("Database connection failed.")
    print("LogManager has finished its operation and file is automatically closed.")

if __name__ == '__main__':
    import asyncio
    asyncio.run(main())

해결 방법 2: `try…finally`를 사용한 명시적 `close` 호출 (차선책)

만약 `async with` 패턴을 클래스 구조상 적용하기 어렵다면, LogManager 인스턴스를 사용하는 코드에서 try...finally 블록을 사용하여 close() 메서드를 항상 호출하도록 보장해야 합니다. 하지만 이 방법은 개발자의 실수로 close() 호출을 누락할 위험이 있어 덜 권장됩니다.

# LogManager 클래스는 첫 번째 원본 코드에서 init, create_new_log_file, write_info, write_error, close 메서드만 사용한다고 가정합니다.
# (단, write_error 내의 쉼표(,) 오류는 수정되어야 합니다.)
#
# class LogManager():
#     ... (위 원본 코드 참고, __init__에 self.f = None 추가, write_error 쉼표 수정, close에 if self.f and not self.f.closed 추가)
#     ...

async def main_with_finally():
    log_manager = LogManager()
    try:
        await log_manager.create_new_log_file()
        await log_manager.write_info("Operation started.")
        # 중간에 에러가 발생해도 finally가 실행되어 close가 호출됩니다.
        await log_manager.write_error("An unexpected error occurred.")
    finally:
        await log_manager.close() # 항상 close 호출 보장
    print("LogManager has finished its operation and file is closed.")

if __name__ == '__main__':
    import asyncio
    asyncio.run(main_with_finally())

4. 향후 예방을 위한 팁

RuntimeError: Event loop is closed와 같은 비동기 자원 관련 에러를 예방하기 위한 몇 가지 팁입니다.

  1. `async with` 적극 활용: 비동기 파일, 네트워크 소켓, 데이터베이스 연결 등 생명 주기가 있는 모든 비동기 자원은 async with 문을 사용하여 안전하게 관리하는 습관을 들이세요. 이는 코드의 가독성을 높이고 자원 누수를 방지하는 가장 효과적인 방법입니다.
  2. 이벤트 루프 생명 주기 이해: 애플리케이션의 시작과 종료 시점에 asyncio 이벤트 루프가 어떻게 관리되는지 명확히 이해해야 합니다. 특히 웹 프레임워크(FastAPI, Quart 등)나 테스트 환경에서는 이벤트 루프가 자동으로 생성되고 소멸될 수 있으므로, 이에 맞춰 자원 정리 로직을 구현해야 합니다.
  3. 테스트 환경에서의 주의: pytest-asyncio와 같은 비동기 테스트 라이브러리를 사용하면 각 테스트 함수 또는 스코프에서 이벤트 루프를 적절하게 관리해 줍니다. 수동으로 asyncio.run()을 호출하는 경우, 각 테스트가 종료될 때마다 자원을 확실히 닫도록 설계해야 합니다.
  4. 로깅 및 디버깅 강화: self.f.closed와 같이 자원의 상태를 확인하는 로직을 추가하여 문제가 발생했을 때 어떤 상태에서 에러가 났는지 빠르게 파악할 수 있도록 합니다. 또한, asyncio의 디버그 모드를 활성화하면 잠재적인 자원 누수나 잘못된 이벤트 루프 사용에 대한 경고를 받을 수 있습니다.

이 문서를 통해 RuntimeError: Event loop is closed 에러의 원인을 정확히 이해하고, async with와 같은 강력한 비동기 컨텍스트 매니저 패턴을 활용하여 더욱 견고하고 안정적인 비동기 애플리케이션을 개발하는 데 도움이 되기를 바랍니다.

댓글 남기기