[SQLAlchemy] async_scoped_session과 context-local
안녕하세요! 개발자 Jay입니다! 오늘은 지난번 Session vs scoped_session에 이어서 async_scoped_session에 대해서 정리해보려고 합니다!
async_scoped_session이 나오게 된 배경
async_scoped_session은 1.4.19 release에서 추가된 클래스입니다. 그럼 async_scoped_session은 왜 추가되었을까요? 릴리즈 내용을 먼저 확인해 보겠습니다.
scoped_session과 AsyncSession 사이의 비호환성을 해결하기 위해 async_scoped_session이 구현되었다고 합니다. 그리고 async_scoped_session.remove()와 함께 사용되어야 한다고 하네요.
그럼 어떤 비호환성이 있었을까요?
#6584 이슈를 보면 scoped_session과 AsyncSession을 함께 사용한 코드에서 AsyncSession의 close가 호출되지 않는 오류입니다. 에러 메세지로는 이런 에러를 뿜어내고요!
RuntimeWarning: coroutine 'AsyncSession.close' was never awaited
뭐가 문제였을까요? scoped_session의 remove는 async/await를 지원하지 않기 때문에 이런 문제가 생겼던 것입니다.
두 번째로는 위 PR에도 이야기가 나오긴 하는데... scoped_session은 thread-local을 사용합니다. 하나의 스레드에 하나의 세션(Session)을 할당하는 방식이죠.
Asyncio 코루틴(coroutine)에서는 하나의 thread로 여러 컨텍스트(context)를 실행합니다. 이때 컨텍스트들은 동일한 세션에 접근하게 됩니다. 그럼 어떤 일이 벌어질까요? [SQLAlchemy] Session vs scoped_session 에서 설명했듯이 thread-safe하지 않은... 여기서는 context-safe라고 해야 하나... 아무튼 의도하지 않은 동작과 오류가 날 수 있습니다.
async_scoped_sesison은 어떻게 context-local을 보장하는가?
async_scoped_session은 asyncio.current_task와 같은 scopedfunc을 주입받아서 self.registry 딕셔너리에 키값으로 사용합니다. 즉 각각의 context의 current_task를 key값으로 사용하고 Session(AsyncSession)을 vaule로 넣어줍니다.
이렇게 되면 context-local을 보장하는 형태가 되는 거지요! ㅎㅎ 그럼 실제로 이렇게 동작하는지 확인해보겠습니다.
쿼리는 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()를 한번 호출해보도록 하겠습니다.
당연히 처음 호출할 때는 self.registry에는 아무 값도 없기 때문에 예외처리로 들어가서 AsyncSession을 생성하겠죠? 그럼 두 번째 호출을 해봅시다.
self.registry에 무언가 들어있습니다. AsyncSession객체가 하나 들어있는데 이 객체는 방금 전 호출한 Task의 AsyncSession입니다. 우리가 async_scoped_session.remove()를 호출하지 않았기 때문에 메모리에 계속 남아있는 것입니다.
그럼 좀 더 많이 호출을 해보겠습니다.
와우... 일단 첫 번째로 async_scoped_session이 context-local을 어떻게 보장하는지 눈으로 직접 확인할 수 있네요.
두 번째로는 async_scoped_session.remove()를 호출하지 않으면 메모리 누수를 일으킬 수 있다. 무조건 마지막에 호출을 해줘야 한다. 하지만 여기서 끝이 아닙니다...
더 큰 문제는 MySQL의 커넥션 스레드가 계속 증가한다는 점입니다. 다만 계속 호출하다 보면 max_overflow에 걸려서 연결된 커넥션 풀이 특정 개수 이상이 되면 오류를 냅니다.
저의 경우는 default를 사용해서 self._overflow = 0 - 5(default) 이기 때문에 최대 커넥션이 -5부터 +1씩 증가해서 최대 15개까지 만들어진 후에 그때부터 오류가 났습니다. 결과적으로 API 호출에 대해 응답이 오지도 않았고요 (프로덕션이었으면 장애)
https://docs.sqlalchemy.org/en/14/errors.html#connections-and-transactions
그리고 특이하게 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
마치며
며칠 동안 SQLAlchemy의 Session, scopred_session, async_scoped_session을 살펴보면서 상당히 많은 지식들을 공부했던 것 같고 습득했습니다.
그냥 단순히 기술을 사용하는 것보다는 왜 이렇게 동작하고 어떤 문제가 있을 수 있는지에 대해 알아보고 테스트해봤습니다. 확실히 시간과 이해에 대한 시간이 오래 걸리긴 하지만 기술에 대해 제대로 알 수 있었던 것 같고 공식문서와 PR 등을 살펴보는 것도 나름 재밌었던 것 같습니다.
혹시나 위 글을 읽으면서 잘못된 내용이 있다면 댓글로 첨언 부탁드립니다! 큰 도움이 될 거예요!
참고자료
https://www.hides.kr/1081?category=666044