[에러 해결] Python asyncio: await 위치에 따른 비동기 객체 생명주기 변화와 CPython 내부 동작 분석

Python의 asyncio를 사용하여 비동기 코드를 작성하다 보면, 객체의 생명주기(lifetime)가 예상과 다르게 동작하는 경우가 있습니다. 특히 await 키워드의 위치를 변경하는 것만으로 객체가 가비지 컬렉션되는 시점이 달라지는 미묘한 현상은 개발자를 혼란스럽게 만들 수 있습니다. 이 글에서는 이러한 현상의 원인을 CPython의 내부 동작 및 asyncio의 코루틴 관리 메커니즘과 연관 지어 자세히 분석하고, 해결 방법과 예방 팁을 제시합니다.

1. 에러 발생 상황

우리는 asyncio 환경에서 Resource 객체의 생명주기를 관찰하고자 합니다. 다음은 weakref를 사용하여 객체에 대한 약한 참조를 생성하고, 가비지 컬렉션(GC) 여부를 확인하는 코드입니다.

import asyncio
import gc
import weakref


class Resource:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"destroyed: {self.name}")


async def producer_version1():
    resource = Resource("A")
    reference = weakref.ref(resource)

    await asyncio.sleep(0)  # await가 return 전에 위치

    return reference


async def main():
    reference = await producer_version1()

    print("before gc:", reference())

    gc.collect()

    print("after gc:", reference())


asyncio.run(main())

위 코드를 실행하면 다음과 같은 출력을 얻습니다:

before gc: None
destroyed: A
after gc: None

producer_version1에서 생성된 resource 객체는 main 함수에서 gc.collect()를 호출하기 전에 이미 소멸되었음을 확인할 수 있습니다. weakrefNone을 반환하는 것은 강한 참조가 없어 객체가 가비지 컬렉션되었다는 의미입니다.

이제 await asyncio.sleep(0)의 위치를 return reference 뒤로 이동시킨 작은 변화를 줍니다:

# await 위치만 변경된 producer
async def producer_version2():
    resource = Resource("A")
    reference = weakref.ref(resource)

    return reference  # return이 await 전에 위치

    await asyncio.sleep(0) # 이 코드는 실행되지 않음

main 함수에서 producer_version2를 호출하도록 변경하고 실행하면 다음과 같은 출력을 얻습니다:

before gc: <__main__.Resource object at 0x...>
after gc: <__main__.Resource object at 0x...>

이번에는 gc.collect()를 명시적으로 호출했음에도 불구하고, resource 객체가 소멸되지 않고 계속 살아있음을 알 수 있습니다. 단지 await 키워드의 위치를 변경했을 뿐인데, 객체의 생명주기가 완전히 달라지는 이 현상은 매우 혼란스럽습니다.

핵심 질문은 다음과 같습니다: 어떤 참조가 resource 객체를 계속 유지시키며, await의 위치 이동이 참조 그래프를 어떻게 변화시키는가?

2. 명확한 발생 원인

이 현상의 원인은 파이썬 코루틴의 내부 동작, 특히 코루틴 프레임의 생명주기와 asyncio.Task의 코루틴 관리 방식에 있습니다.

코루틴 프레임과 로컬 변수

async def로 정의된 함수는 호출될 때 바로 실행되는 것이 아니라, 코루틴 객체를 반환합니다. 이 코루틴 객체는 자신의 실행 상태를 저장하는 프레임(Frame)을 가지고 있습니다. await 키워드가 실행되면 코루틴은 일시 중단(suspend)되고, 이 프레임과 그 안에 정의된 모든 로컬 변수들은 asyncio.Task에 의해 강하게 참조되어 유지됩니다. 코루틴이 재개되어 모든 작업을 마치고 return하면, 이 프레임은 일반적으로 해제됩니다.

1. awaitreturn 전에 있을 때 (producer_version1)

  1. producer_version1()이 호출되면 코루틴 객체가 생성되고, main 함수의 await에 의해 실행이 시작됩니다.
  2. resource = Resource("A") 문장이 실행되어 resource 객체가 생성됩니다. 이 객체는 producer_version1 코루틴의 로컬 변수로, 해당 코루틴의 프레임에 강하게 참조됩니다.
  3. await asyncio.sleep(0) 문장이 실행되면, producer_version1 코루틴은 일시 중단됩니다. 이때 asyncio 이벤트 루프는 이 코루틴을 관리하는 asyncio.Task를 통해 코루틴 객체와 그 프레임(그리고 resource를 포함한 로컬 변수들)에 대한 강한 참조를 유지합니다.
  4. asyncio.sleep(0)이 완료되면 코루틴이 재개되고, return reference가 실행됩니다. 이 시점에서 producer_version1 코루틴은 완전히 “완료” 상태가 됩니다.
  5. 코루틴이 완료되면, 일반적으로 해당 코루틴 객체는 더 이상 자신의 프레임에 대한 강한 참조를 유지하지 않고, 프레임도 해제됩니다. 따라서 resource 객체에 대한 강한 참조가 사라지게 됩니다.
  6. main 함수의 print("before gc:", reference()) 시점에서 resource 객체는 이미 가비지 컬렉션되어 None을 반환하고, 뒤이어 destroyed: A가 출력되는 것을 볼 수 있습니다.

2. awaitreturn 후에 있을 때 (producer_version2)

이 경우가 바로 예상치 못한 객체 생명주기 문제의 핵심입니다.

  1. producer_version2()가 호출되면 코루틴 객체가 생성되고, main 함수의 await에 의해 실행이 시작됩니다.
  2. resource = Resource("A") 문장이 실행되어 resource 객체가 생성되고, producer_version2 코루틴의 프레임에 강하게 참조됩니다.
  3. return reference가 즉시 실행됩니다. await asyncio.sleep(0) 문장은 결코 실행되지 않습니다. 코루틴은 일시 중단 과정 없이 “완료” 상태가 됩니다.
  4. 문제의 핵심: 코루틴이 일시 중단 없이 즉시 완료되었음에도 불구하고, resource 객체는 가비지 컬렉션되지 않고 계속 유지됩니다.

이러한 현상은 순수한 파이썬 언어 명세보다는 CPython의 특정 구현 세부 사항asyncio의 코루틴 관리 방식에서 기인합니다.

  • 코루틴 객체 자체의 참조 유지: CPython에서 코루틴 객체(내부적으로는 제너레이터 객체와 유사)는 자신의 실행 프레임에 대한 강한 참조를 유지하는 경향이 있습니다. 심지어 코루틴이 return 문을 통해 “완료” 상태가 되어 더 이상 실행되지 않더라도, 해당 코루틴 객체 자체가 가비지 컬렉션되기 전까지는 내부적으로 자신의 프레임(cr_frame 또는 gi_frame 속성)을 참조할 수 있습니다. 이 프레임이 여전히 로컬 변수인 resource를 참조하고 있는 것입니다.

  • asyncio.Task의 역할: asyncio는 코루틴을 asyncio.Task로 감싸서 관리합니다. main 함수에서 await producer_version2()를 호출할 때, 내부적으로 producer_version2 코루틴 객체는 Task에 의해 관리됩니다. 코루틴이 즉시 완료되더라도, 이 Task는 코루틴 객체에 대한 강한 참조를 계속 유지할 수 있습니다. Task가 완료된 코루틴 객체에 대한 참조를 해제하지 않는 한, 코루틴 객체는 프레임을 붙들고, 프레임은 resource를 붙들게 되는 참조 체인(Python namecoroutine framecoroutine objectasyncio.Taskevent loop)이 유지되는 것입니다.

  • 참조 카운팅과 순환 가비지 컬렉션: CPython의 기본 가비지 컬렉션은 참조 카운팅 방식입니다. 객체의 참조 카운트가 0이 되면 즉시 해제됩니다. 하지만 순환 참조(cyclic reference)의 경우 참조 카운트가 0이 되지 않으므로, 순환 가비지 컬렉터(cyclic garbage collector)가 주기적으로 동작하여 이를 해제합니다. 위의 경우, resource 객체에 대한 강한 참조는 직접적인 순환 참조가 아니지만, asyncio.Taskcoroutine objectframeresource로 이어지는 간접적인 참조가 유지되므로, gc.collect()로도 즉시 해제되지 않는 것입니다. 이는 asyncio.Task나 이벤트 루프가 내부적으로 완료된 코루틴 객체를 특정 시점까지 보관하기 때문일 수 있습니다.

따라서, producer_version2에서는 코루틴이 일시 중단되지 않고 즉시 완료되었기 때문에, asyncio.Task가 관리하는 코루틴 객체가 자신의 프레임과 그 안에 있는 resource에 대한 강한 참조를 예상보다 오랫동안 유지하게 되는 것입니다.

3. 해결 방법 및 코드 예시

이러한 현상을 해결하거나, 객체 생명주기를 예측 가능하게 관리하기 위한 방법은 다음과 같습니다.

1. 명시적인 로컬 변수 참조 해제

코루틴 내에서 더 이상 필요 없는 로컬 변수, 특히 외부 자원이나 무거운 객체를 참조하는 변수는 return 전에 명시적으로 강한 참조를 해제해야 합니다. 이를 통해 객체의 참조 카운트를 0으로 만들고 가비지 컬렉션 대상이 되도록 합니다.

# producer_version2를 수정하여 명시적으로 참조를 해제합니다.
async def producer_fixed():
    resource = Resource("A")
    reference = weakref.ref(resource)
    print(f"producer_fixed: Resource '{resource.name}' created.")

    # 더 이상 'resource' 객체에 대한 강한 참조가 필요 없음을 명시합니다.
    resource = None # 로컬 변수 'resource'가 더 이상 객체를 참조하지 않도록 함 (del resource도 가능)

    return reference
    await asyncio.sleep(0) # 이 줄은 실행되지 않습니다.

async def main_fixed():
    reference = await producer_fixed()

    print("main_fixed: before gc:", reference())

    gc.collect()

    print("main_fixed: after gc:", reference())

asyncio.run(main_fixed())

위 코드를 실행하면 다음과 같은 출력을 얻을 수 있습니다:

producer_fixed: Resource 'A' created.
before gc: None
destroyed: A
after gc: None

resource = None 한 줄의 추가만으로 Resource("A") 객체가 정상적으로 가비지 컬렉션되는 것을 확인할 수 있습니다.

2. 비동기 컨텍스트 관리자 (async with) 사용

외부 자원(파일, 네트워크 연결, 데이터베이스 세션 등)을 다루는 객체의 경우, async with 문을 사용하여 자원 해제를 명시적으로 보장하는 것이 가장 강력하고 권장되는 방법입니다. __aenter____aexit__ 메서드를 구현하여 자원 초기화 및 해제 로직을 자동화할 수 있습니다.

class ManagedResource:
    def __init__(self, name):
        self.name = name
        print(f"ManagedResource '{self.name}' initialized.")

    async def __aenter__(self):
        print(f"ManagedResource '{self.name}' entered context.")
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        print(f"ManagedResource '{self.name}' exited context.")
        # 자원 해제 로직
        self.name = None # 내부 상태 정리
        # return False # 예외를 전파하려면 False 또는 None 반환

    def __del__(self):
        print(f"destroyed: {self.name} (ManagedResource)")

async def consumer_with_managed_resource():
    async with ManagedResource("B") as res:
        print(f"Using managed resource: {res.name}")
        await asyncio.sleep(0) # await가 있어도 없어도 __aexit__는 호출됨
    print("Managed resource context finished.")

async def main_managed():
    await consumer_with_managed_resource()
    gc.collect() # 명시적 GC
    print("main_managed: After explicit gc.")

asyncio.run(main_managed())
ManagedResource 'B' initialized.
ManagedResource 'B' entered context.
Using managed resource: B
ManagedResource 'B' exited context.
Managed resource context finished.
destroyed: None (ManagedResource)
main_managed: After explicit gc.

async with를 사용하면 await 위치에 관계없이 __aexit__ 메서드가 호출되어 자원 해제를 보장하므로, 객체 생명주기 관리가 훨씬 명확해집니다.

4. 향후 예방을 위한 팁

  • asyncio.Task와 코루틴의 생명주기 이해: asyncio 애플리케이션에서 Task가 코루틴 객체를 어떻게 관리하고, 언제 참조를 해제하는지에 대한 깊은 이해가 필요합니다. 특히 `create_task()`로 생성된 태스크는 완료된 후에도 결과가 `await`되거나 `cancel()`될 때까지 해당 코루틴 객체에 대한 참조를 유지할 수 있습니다.
  • weakref를 활용한 디버깅: 예상치 못한 객체 유지 현상을 발견했을 때, weakref는 객체의 강한 참조 존재 여부를 확인하는 유용한 도구입니다.
  • gc 모듈 심층 활용: gc.get_referrers(obj)는 특정 객체를 참조하는 모든 객체 목록을 반환하여 디버깅에 큰 도움이 됩니다. 비록 결과가 복잡할 수 있지만, 메모리 누수의 근본 원인을 파악하는 데 필수적인 정보를 제공합니다. gc.get_referents(obj)는 해당 객체가 참조하는 객체들을 보여줍니다.
  • 명시적인 자원 관리 습관화: 비동기 코드에서 생성되는 모든 자원은 그 생명주기를 명시적으로 관리하는 것이 좋습니다. 특히 외부 시스템과 연동하는 객체는 async with 패턴을 적극적으로 사용해야 합니다.
  • 간단한 코루틴에도 주의: `asyncio` 환경에서는 await 없이 즉시 완료되는 아주 짧은 코루틴이라도 내부적인 오버헤드나 참조 유지 문제가 발생할 수 있음을 인지해야 합니다.

이러한 원칙들을 잘 숙지하고 적용함으로써, 파이썬 asyncio 환경에서 객체 생명주기 관련 문제로 인한 혼란을 줄이고 더욱 안정적인 비동기 애플리케이션을 개발할 수 있을 것입니다.

댓글 남기기