💻 프로그래밍/Python

[SQLAlchemy] async_scoped_session과 context-local

피트웨어 제이 (FitwareJay) 2022. 10. 1. 20:42

안녕하세요! 개발자 Jay입니다! 오늘은 지난번 Session vs scoped_session에 이어서 async_scoped_session에 대해서 정리해보려고 합니다!

 

async_scoped_session이 나오게 된 배경


 

async_scoped_session은 1.4.19 release에서 추가된 클래스입니다. 그럼 async_scoped_session은 왜 추가되었을까요? 릴리즈 내용을 먼저 확인해 보겠습니다.

 

async_scoped_session 릴리즈 내용

scoped_session과 AsyncSession 사이의 비호환성을 해결하기 위해 async_scoped_session이 구현되었다고 합니다. 그리고 async_scoped_session.remove()함께 사용되어야 한다고 하네요.

 

그럼 어떤 비호환성이 있었을까요?

#6583 이슈

#6584 이슈를 보면 scoped_sessionAsyncSession을 함께 사용한 코드에서 AsyncSessionclose가 호출되지 않는 오류입니다. 에러 메세지로는 이런 에러를 뿜어내고요!

RuntimeWarning: coroutine 'AsyncSession.close' was never awaited

뭐가 문제였을까요? scoped_session의 remove는 async/await를 지원하지 않기 때문에 이런 문제가 생겼던 것입니다. 

 

두 번째로는 위 PR에도 이야기가 나오긴 하는데... scoped_session은 thread-local을 사용합니다. 하나의 스레드에 하나의 세션(Session)을 할당하는 방식이죠.

 

출처: https://eng.paxos.com/python-3s-killer-feature-asyncio

Asyncio 코루틴(coroutine)에서는 하나의 thread로 여러 컨텍스트(context)를 실행합니다. 이때 컨텍스트들은 동일한 세션에 접근하게 됩니다. 그럼 어떤 일이 벌어질까요? [SQLAlchemy] Session vs scoped_session 에서 설명했듯이 thread-safe하지 않은... 여기서는 context-safe라고 해야 하나... 아무튼 의도하지 않은 동작과 오류가 날 수 있습니다. 

비동기를 지원하려면 context-local을 보장해야 한다는 말입니다!
 

async_scoped_sesison은 어떻게 context-local을 보장하는가?


async_scoped_session이 context-local을 보장하는 코드

async_scoped_sessionasyncio.current_task와 같은 scopedfunc을 주입받아서 self.registry 딕셔너리에 키값으로 사용합니다. 즉 각각의 context의 current_task를 key값으로 사용하고 Session(AsyncSession)을 vaule로 넣어줍니다.

 

이렇게 되면 context-local을 보장하는 형태가 되는 거지요! ㅎㅎ 그럼 실제로 이렇게 동작하는지 확인해보겠습니다.

MySQL 커넥션을 담당하는 클래스를 만들고 내부에서 async_scoped_session을 사용하도록 정의

쿼리를 날려서 정보를 출력

쿼리는 select()이며 중간 생략하였습니다. 먼저 context-local에 대해 테스트를 해보기 위해 async_scoped_session.remove()를 마지막에 호출하지는 않았습니다.

async_scoped_session.remove()를 꼭 호출해야 하는 이유는 위에서 언급한 context-local을 지원하기 위해 context마다 AsyncSession을 저장하고 작업이 완료되면 해당 AsyncSession을 삭제해야 하기 때문입니다. async_scoped_session.remove()를 호출하지 않으면 AsyncSession이 메모리에 남게 되고 메모리 누수에 대한 위험이 있습니다.

 

저는 테스트를 위해 일부러(?) async_scoped_session.remove() 호출하지 않겠습니다. API를 호출해서 get_detail_info()를 한번 호출해보도록 하겠습니다.

 

첫 호출에는 아무값도 없기 때문에 KeyError

당연히 처음 호출할 때는 self.registry에는 아무 값도 없기 때문에 예외처리로 들어가서 AsyncSession을 생성하겠죠? 그럼 두 번째 호출을 해봅시다.

 

오...무언가 들어있다

self.registry에 무언가 들어있습니다. AsyncSession객체가 하나 들어있는데 이 객체는 방금 전 호출한 Task의 AsyncSession입니다. 우리가 async_scoped_session.remove()를 호출하지 않았기 때문에 메모리에 계속 남아있는 것입니다.

 

그럼 좀 더 많이 호출을 해보겠습니다.

엄청난게 쌓인 AsyncSession객체들

와우... 일단 첫 번째로 async_scoped_session이 context-local을 어떻게 보장하는지 눈으로 직접 확인할 수 있네요.

 

두 번째로는 async_scoped_session.remove()를 호출하지 않으면 메모리 누수를 일으킬 수 있다. 무조건 마지막에 호출을 해줘야 한다. 하지만 여기서 끝이 아닙니다...

 

커넥션이 계속 생성됨

더 큰 문제는 MySQL의 커넥션 스레드가 계속 증가한다는 점입니다. 다만 계속 호출하다 보면 max_overflow에 걸려서 연결된 커넥션 풀이 특정 개수 이상이 되면 오류를 냅니다.

연결할 수 있는 최대 connection pool을 넘어가면 오류
pool 설정 관련

저의 경우는 default를 사용해서 self._overflow = 0 - 5(default) 이기 때문에 최대  커넥션이 -5부터 +1씩 증가해서 최대 15개까지 만들어진 후에 그때부터 오류가 났습니다. 결과적으로 API 호출에 대해 응답이 오지도 않았고요 (프로덕션이었으면 장애)

https://docs.sqlalchemy.org/en/14/errors.html#connections-and-transactions

 

Error Messages — SQLAlchemy 1.4 Documentation

Previous: Third Party Integration Issues Next: Changes and Migration Up: Home On this page: Error Messages Connections and Transactions DBAPI Errors SQL Expression Language Object Relational Mapping AsyncIO Exceptions Core Exception Classes ORM Exception C

docs.sqlalchemy.org

연결된 커넥션은 줄어들지만 생성된 커넥션 스레드는 줄어들지 않음

그리고 특이하게 MySQL에서 Threads_connected는 당연히 연결된 커넥션이니까 애플리케이션을 종료하면 줄어드는데 Threads_created는 줄어들지 않더라고요. 이 부분도 문제가 될 것 같고 정확한 내용은 MySQL 쪽 공부를 좀 더 해봐야 할 것 같습니다.

 

그래서 결론은...

 

공식문서에서도 주의사항으로 나타내주고 있네요

async_scoped_session을 사용할 때는 항상 async_scoped_session.remove()를 호출해라

https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#using-asyncio-scoped-session

 

Asynchronous I/O (asyncio) — SQLAlchemy 1.4 Documentation

Asynchronous I/O (asyncio) Support for Python asyncio. Support for Core and ORM usage is included, using asyncio-compatible dialects. Tip The asyncio extension as of SQLAlchemy 1.4.3 can now be considered to be beta level software. API details are subject

docs.sqlalchemy.org

 

 

 

 

마치며


며칠 동안 SQLAlchemy의 Session, scopred_session, async_scoped_session을 살펴보면서 상당히 많은 지식들을 공부했던 것 같고 습득했습니다.

 

그냥 단순히 기술을 사용하는 것보다는 왜 이렇게 동작하고 어떤 문제가 있을 수 있는지에 대해 알아보고 테스트해봤습니다. 확실히 시간과 이해에 대한 시간이 오래 걸리긴 하지만 기술에 대해 제대로 알 수 있었던 것 같고 공식문서와 PR 등을 살펴보는 것도 나름 재밌었던 것 같습니다.

 

혹시나 위 글을 읽으면서 잘못된 내용이 있다면 댓글로 첨언 부탁드립니다! 큰 도움이 될 거예요!

 

 

참고자료

https://www.hides.kr/1081?category=666044 

 

FastAPI SQLAlchemy 연동하며 발생한 문제 정리

개요 기존과 다른 패러다임을 가진 비동기 프레임워크 FastAPI와 SQLAlchemy를 연동하는 과정에서 발생했던 문제들과 어떠한 형태로 해결했는지에 대해 다뤄본다. 사전 지식 SQLAlchemy는 scoped_session()

www.hides.kr