Python : async with를 이용한 asyncContextManager 사용

    들어가기 전

    이 글은 파이썬 비동기 라이브러리 asyncio 책을 공부하며 작성한 글입니다. 

     


    3.6 비동기 컨텍스트 관리자들 : async with

    네트워크 연결 같은 네트워크 자원의 생명주기를 적절한 범위 내에서 관리 할 때는 AsyncContextManager를 사용하면 편리하다고 한다.

    비동기 컨텍스트 매니저는 특정한 자원의 생명주기를 관리해주는 녀석으로 보면 된다. 기본적인 개념은 __acenter()__, __aexit()__ 메서드가 구현된 클래스이며 with절이 시작할 때 __acenter()__가 호출, with절이 끝날 때 __aexit()__가 호출된다.


    3.6.1 Class를 이용한 비동기 컨텍스트 매니저

    먼저 AsyncContextManager의 내부 동작은 메서드를 호출하면서 이루어지는 것을 이해해야한다. 아래 코드에서 AsyncContextManager의 동작 방식을 확인해본다. 

    class Connection:
        
        def __init__(self, host, port):
            self.host = host
            self.port = port
            
        async def __aenter__(self):
            self.conn = await get_conn(self.host, self.port)
            return self.conn
            
        async def __aexit__(self, exc_type, exc_val, exc_tb):
            await self.conn.close()
            
    async with Connection('localhost', 9001) as conn:
        pass

    각 메서드별 설명은 아래와 같다.

    __acenter()__ 

    • with 문이 시작할 때 __acenter()__ 메서드를 호출하고 그 결과물을 반환받는다. 반환받은 결과물은 as를 이용해서 with 절 내에서 사용할 수 있다.
    • __acenter()__ 메서드는 비동기 함수이기 때문에 반드시 async로 정의해줘야한다. 

    __aexit()__

    • 코드 실행 라인이 with 문에서 벗어날 때 __aexit()__ 메서드가 호출된다. 

    위 코드는 그 자체로 비동기 컨텍스트 매니저가 된다. 그렇지만 비동기 컨텍스트 매니저를 사용할 때 마다 클래스를 생성하는 것은 코드 낭비가 될 수 있다. 이 부분을 개선하기 위해서 파이썬의 contextlib은 @contextmanager, @asynccontextmanager 같은 데코레이터를 지원해준다. 

     


    3.6.2 contextlib 방식

    contextlib 라이브러리에서 제공하는 @contextmanager 데코레이터를 사용하면 더 간략한 방법으로 컨텍스트 매니저를 구현해서 사용할 수 있다.

    from contextlib import contextmanager
    
    def download_page(url): pass
    def update_stats(url): pass
    def process(data): pass
    
    @contextmanager
    def web_page(url):
        data = download_page(url) # --> 비동기 처리 필요
        yield data
        update_stats(url) # --> 비동기 처리 필요
    
    with web_page('google.com') as data: ## --> 비동기 처리 필요
        process(data)

    @contextmanager 데코레이터를 제네레이터 함수에 선언하면, 제네레이터 함수는 컨텍스트 매니저로 변경된다. 그리고 아래와 같이 동작한다.

    • with 문으로 들어갈 때 yield까지 실행되고 yield의 값을 반환한다.
    • with 문이 끝날 때 yield 이후의 내용이 실행된다. 

    각 코드와 관련된 상세한 내용을 더 살펴보면 다음과 같다. 

    @contextmanager

    제네레이터 함수를 컨텍스트 매니저로 바꿔준다. 즉, web_page() 제네레이터 함수는 컨텍스트 매니저로 변경된다.

    download_page()

    네트워크 인터페이스를 하기 위해 생성된 함수다. 네트워크 인터페이스 함수는 CPU 기반 함수보다 느리기 때문에 실행 완료되는데 오랜 시간이 소요된다. 따라서 전용 스레드나 비동기 처리가 되어야 한다. 그렇지 않을 경우 프로그램 전체가 멈추게 될 것이다.

    update_stats()

    URL을 통해 전달받은 데이터를 처리할 때 마다 통계 작업을 하는 함수라고 가정한다. 만약 이 함수가 DB와 직접 인터페이스하며 업데이트 하는 경우 이 함수도 네트워크 인터페이스 함수가 된다. 따라서 전용 쓰레드로 실행이 되거나 비동기로 처리 되어야 한다. 

    process()

    이 함수 호출도 블로킹 일 수 있다. 함수의 기능을 아래와 같이 나눠서 살펴볼 수 있다. 함수의 기능을 살펴보고 필요한 경우라면 비동기 처리도 고려해 볼 수 있다.

    • 무해하고 Non Blocking (빠르고 CPU 위주)
    • 약간의 블로킹(빠르고 I/O 위주. 네트워크 I/O가 아닌 빠른 디스크 접근 I/O)
    • 블로킹 (느리고 I/O 위주)
    • 미칠 듯이 느림 (느리고 CPU 위주)

    일단 process() 함수는 무해하고 Non Blocking의 함수라고 가정하고 코드를 작성해보자.

    위의 코드에서 비동기로 변경하면 좋을만한 요소들을 몇 가지 살펴보았다. 그렇다면 아래에서 실제로 비동기로 코드를 수정하는 작업을 진행해본다.

     

    비동기로 코드 전환

    비동기로 코드를 수정하면 좋을만한 함수들은 다음과 같다

    • download_page()
    • update_stats()

    왜냐하면 네트워크 인터페이스 작업이 있을 것으로 기대되고, 이런 작업들은 CPU는 상대적으로 적게 필요하지만 실행 시간이 매우 오래 걸리기 때문이다. 아래와 같이 코드를 수정할 수 있다.

    from contextlib import asynccontextmanager
    
    async def download_page(url): pass
    async def update_stats(url): pass
    def process(data): pass
    
    @asynccontextmanager
    async def web_page(url):
        data = await download_page(url)
        yield data
        await update_stats(url)
    
    async with web_page('google.com') as data:
        process(data)
    

    코드를 좀 더 자세히 살펴보면 다음과 같다. 

     @asynccontextmanager

    내부에서 코루틴을 사용할 것이기 때문에 비동기 컨텍스트 관리자로 변경한다. 내부에서 코루틴을 사용한다는 것은 코루틴 체이닝이 실행되는 것이기 때문에 async def를 이용해서 비동기 함수로 선언한다. 

    await download_page()

    download_page()가 비동기 함수로 구현할 수 있을 때 await 키워드를 이용해서 결과값을 받아올 수 있다. await 키워드를 사용하면 download_page() 코루틴에서 네트워크 인터페이스가 완료될 때 까지 대기하는 동안 이벤트 루프에서 다른 Task를 처리할 수 있음을 의미한다. 

    만약 download_page()가 비동기 함수로 구현될 수 없다면 코루틴 함수가 아니기 때문에 await 키워드를 사용할 수 없고 다른 방식을 사용해야한다. 

    yield data

    yield 키워드가 함수 내부에 있기 때문에 web_page() 함수는 제네레이터가 된다. 그리고 web_page()에 async 키워드가 붙어있기 때문에 이 함수는 비동기 제네레이터가 된다. 이 함수가 비동기 제네레이터 함수인지를 확인할 수 있도록 inspect 모듈에서는 isasyncgenfunction(), isasyncgen() 메서드를 지원해준다. 

    await update_stats()

    update_stats() 메서드를 수정해서 코루틴을 반환하도록 수정했다고 가정하자. await 키워드를 사용해서 I/O 위주 작업이 완료될 때 까지 대기하며 이벤트 루프에서 컨텍스트 전환이 일어날 수 있도록 허용한다. 

    async with

    비동기 컨텍스트 관리자를 사용하기 때문에 with에서 async with로 키워드를 변경해야한다. 

     

    위에서 코드를 비동기로 변경하는 작업을 했다. 그렇지만 이것은 사용하던 함수를 동기에서 비동기로 변경할 수 있을 때만 가능한 작업이다. 그렇지만 일반적으로 써드 파티 라이브러리 함수를 동기에서 비동기로 수정하는 작업은 매우 어려운 작업이다. 예를 들어 requests 라이브러리는 네트워크 인터페이스를 하지만 전역적으로 동기 함수로만 작성되어있다. 이런 동기 함수들은 어떻게 비동기 함수로 바꿔서 사용할 수 있을까?

     

    executor를 이용한 비동기로의 코드 전환

    원한다고 해서 모든 동기 함수를 비동기 함수로 직접 수정할 수는 없다. 왜냐하면 어려운 작업이기 때문이다. asyncio에서는 동기 함수를 비동기로 수정하지 않고도, 이벤트 루프가 별도의 쓰레드를 통해서 비동기처럼 동작할 수 있도록 하는 기능을 제공해준다. 

    import asyncio
    from contextlib import asynccontextmanager
    
    def download_page(url): pass
    def update_stats(url): pass
    def process(data): pass
    
    @asynccontextmanager
    async def web_page(url):
        loop = asyncio.get_running_loop()
        data = await loop.run_in_executor(None, download_page, url)
        yield data
        await loop.run_in_executor(None, update_stats, url)
    
    async with web_page('google.com') as data:
        process(data)

    아래에 좀 더 자세한 코드를 살펴볼 수 있다.

    await loop.run_in_exeuctor() 

    • download_page(), update_stats() 메서드를 비동기 함수로 바꿀 수 없다고 가정해보자. 이 때는 run_in_executor() 메서드를 호출해서 실행하도록 하면 된다
    • run_in_executor()는 Future 객체를 반환한다. Future 객체는 awaitable한 객체이기 때문에 await 키워드를 이용해서 이벤트 루프에서 전달된 함수가 실행이 완료될 때까지 기다릴 수 있다. 

     


    정리

    • 네트워크 인터페이스처럼 I/O 작업이 길어지는 경우, 자원의 관리를 위해 비동기 컨텍스트 매니저를 이용해서 손쉽게 처리할 수 있다.
    • 모든 동기 함수를 비동기 함수로 고칠 수 없다. 비동기 함수로 고칠 수 없는 경우라면 run_in_executor()를 이용해서 이벤트 루프에서 비동기로 실행될 수 있도록 예약할 수 있다. 

    댓글

    Designed by JB FACTORY