개요
이메일 전송 기능을 구현하고 이에 대해 Race Condition 테스트를 진행하던 중 발생한 일이다. 참고로 이 환경에서는 FastAPI + AsyncSqlalchemy or pool + posgresql 조합을 사용한다.
처음에는 단순히 DB URL만 연결하면 끝날 줄 알았는데, SQLAlchemy 및 asyncpg를 사용하는 과정에서 이상한 에러가 발생했고, 원인을 파고들다 보니 Supabase의 커넥션 방식(Direct / Transaction Pooler / Session Pooler)에 따라 동작이 완전히 달라진다는 걸 알게 됐다.
그리고 그 커넥션 방식에 따라 옵션의 미묘한 변화를 줘야 한다는 부분도 새롭게 알게 됐는데 이를 기반으로
- Session Pooler 방식을 사용하던 기존환경의 문제점
- Transaction Pooler로 변경하면 DB 연결 옵션을 어떻게 변경했는지
을 정리한다.
Session Pooler 방식을 사용하던 기존 환경의 문제점
개요에서 언급했지만 문제점은 새롭게 구현한 이메일 전송 기능에 대해 Race Condition 테스트를 진행하던 중 발견했다.
여러 연결 요청이 들어오면 이메일이 중복으로 발송되는지를 보려고 했으며 그 결과 다음과 같은 에러가 발생하는 걸 확인할 수 있었다.
asyncpg.exceptions.InternalServerError: MaxClientsInSessionMode: max clients reached - in Session mode max clients are limited to pool_size
왜 이런 Exception이 발생했는지를 구글링과 GPT로 조사를 하던 중 SupaBase는 DB 연결에 3가지 방식을 사용한다는 것을 알게됬다.
인계받은 FastAPI의 DB 연결을 위한 CONNECTION STRING을 보니 다음과 같이 설정되어 있었다.
postgresql+asyncpg://USER:PASSWORD@aws-1-ap-northeast-2.pooler.supabase.com:5432/postgres
SupaBase의 공식 문서를 통해 알고보니 위의 연결 형태는 Session Pooler 방식임을 알 수 있다.
Session Pooler에 대한 설명은 이러하다.
session mode restricts the pooler, forcing it to grant an underlying direct connection exclusively to a single client connection.
세션 모드는 풀러의 동작을 제한하여, 하나의 클라이언트 연결에 대해 해당 풀러가 관리하는 기본 직접 연결을 독점적으로 할당하도록 강제합니다.
https://supabase.com/docs/guides/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO
정리해 보면 하나의 클라이언트가 하나의 연결을 사용하기에 여러 요청이 오게 되면 WAS가 DB로부터 커넥션을 얻지 못하게 되는 상황이 발생하니 DB 연결을 하지 못하는 상황이 되는 셈이다.
긴 연결을 유지할 필요가 없고, 짧은 요청이 여러 번 발생하는 성격의 API에서는 이러한 연결 방식을 사용하기엔 어려운 부분이다.
이 문제를 해결하려면 여러 클라이언트가 동일한 데이터베이스 연결을 공유하는 방식인 Transaction Pooler 방식으로 설정이 필요했다.
Transaction Pooler로 변경하기
Transaction Pooler에 대한 설명은 supabase 문서에서는 다음과 같이 설명된다.
Transaction mode gives the pooler permission to share direct connections among multiple clients. It is used when the pooler connection string is listening on port 6543:
트랜잭션 모드는 풀러가 여러 클라이언트 간에 직접 연결을 공유할 수 있도록 허용합니다. 이 모드는 풀러의 연결 문자열이 포트 6543에서 수신 대기 중일 때 사용됩니다
https://supabase.com/docs/guides/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO
연결 포트가 변경되었다는 점에 유의해 connection string을 다음과 같이 변경해서 연결을 시도했다.
postgresql+asyncpg://USER:PASSWORD@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres
이후 API에서 DB에 SQL을 날릴 때 다음과 같음 에러가 발생했다.
sqlalchemy.exc.DBAPIError: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.InvalidSQLStatementNameError'>: prepared statement "__asyncpg_stmt_12__" does not exist
HINT:
NOTE: pgbouncer with pool_mode set to "transaction" or
"statement" does not support prepared statements properly.
You have two options:
* if you are using pgbouncer for connection pooling to a
single server, switch to the connection pool functionality
provided by asyncpg, it is a much better option for this
purpose;
* if you have no option of avoiding the use of pgbouncer,
then you can set statement_cache_size to 0 when creating
the asyncpg connection object.
해당 오류는 asyncpg와 supabase를 사용하기에 발생하는 문제라는 점을 알 수 있었다. 이 문제는 다음이 원인이다.
asyncpg는 내부적으로 쿼리를 실행할 때 최초 한 번 “PREPARE stmt_xxx”를 수행하고, 이후 동일 쿼리는 캐싱된 prepared statement를 재사용하여 “EXECUTE stmt_xxx”만 수행한다.
그러나 Supabase의 pooler(PgBouncer, transaction 모드)는 요청마다 다른 connection을 할당하기 때문에, 이전 connection에 존재하던 prepared statement를 이후 connection에서는 찾을 수 없게 되어 에러가 발생한다.
즉, asyncpg는 prepared statement를 재사용하는데, PgBouncer(transaction)가 connection을 바꿔서 그 캐시가 깨진다라고 요약할 수 있다.
SQLAlchemy에서 해결 방법을 찾던 중 이 문제를 다뤘던 링크를 발견할 수 있었다.
https://github.com/MagicStack/asyncpg/issues/1058
이를 참고해 다음과 같이 옵션을 설정해 해결했다.
_engine = create_async_engine(
database_url,
connect_args={
"statement_cache_size": 0,
"prepared_statement_cache_size": 0,
"prepared_statement_name_func": lambda: f"__asyncpg_{uuid.uuid4()}__",
}
)
마치며
이 문제를 해결하기 위해 GPT에게 물어봐서 나온 방식을 여러 차례 적용했다. 그러나 해당 방식만 적용해서는 해결되지 않았으며 결국 Github Issue와 이와 비슷한 사례를 참고하여 적용했더니 해결했다. 어떤 정보가 도움이 될것인지는 결국 스스로의 선택에 달린 문제가 아닐런지 싶다.
'개발 일지' 카테고리의 다른 글
| Railway에서 SMTP가 막힌다. (0) | 2026.03.28 |
|---|---|
| Docker를 이용한 Swap 메모리 증설과 OOM 테스트 (0) | 2025.12.30 |
| Cookie에 검색 조건을 설정하는 사이트도 있더라 (0) | 2025.11.27 |
| 크롤링 대상의 사이트가 변경되면 어떻게 대처하면 좋을까 ? (0) | 2025.11.20 |
| [품앗이] FastAPI에 적용했던 두 가지 포인트 (1) | 2025.11.10 |