FastAPI에서 Postgresql의 커넥션 관리

@SharkniA · December 15, 2023 · 19 min read

현재 회사의 서비스에서 RDB를 사용하지 않고 NoSQL(DynamoDB)만 사용해서 어플리케이션 서비스를 하고 있었다.

필연적으로 RDB를 도입하게 되었고, supabase를 사용해보고 싶어서 DB는 깊은 고찰 없이 아무도 사용해본 적이 없었던 Postgresql을 고르게 되었다.

이것이 고생의 시작이었다.

그 고생을 기록해둔다.

1. 커넥션 직접 연결

MySQL, Oracle, MsSQL등을 사용해봤지만 Postgresql은 처음이었다. 아무 생각없이 커넥션을 바로 연결했고 곧장 문제가 발생했다.

supabase에서 사용하는 요금제에서 커넥션을 65개까지 만들 수 있게끔 되어있었는데, 수시로 이 수치를 넘어버렸다. 작업 시작 당시 테스트 서버에서는 인스턴스를 5개를 사용하고 있었고, 기본값으로 인스턴스 하나 당 연결 5개를 사용하고 있었서 단순히 산술적으로는 65개를 넘어서는 안되는데 계속해서 65개를 넘겨서 DB 서버가 지속적으로 죽고 있었다.

2. 커넥션 폭증, 첫번째 추측

첫번째 추측으로는 세션의 생명주기를 관리하는 코드가 정상적으로 작동하는지 여부를 의심했다. 비동기에서의 세션 관리를 위해 의존성 주입 방식을 통한 세션 관리 방법을 선택하고 있었는데, 해당 코드를 다음과 같이 강화해주었다.

수정 전 코드

async def get_db_session(db_conn: Engine = Depends(get_db_connection)) -> AsyncIterable[Session]:
    async with sessionmaker(db_conn, class_=AsyncSession, expire_on_commit=False)() as session:
        yield session

수정 후 코드

async def get_db_session(db_conn: Engine = Depends(get_db_connection)) -> AsyncIterable[Session]:
    try:
        async with sessionmaker(db_conn, class_=AsyncSession, expire_on_commit=False)() as session:
            yield session
    except Exception as e:
        logger.error(f"[get_db_session] {e}")
        await session.rollback()
        raise
    finally:
        await session.close()

이론적으로는 위 코드만 사용해도 정상적으로 세션의 생명주기가 관리가 되어야 하는데, 해외에도 나와 같은 이슈를 겪는 사람이 있는 것으로 보였다. 아래처럼 수정 후에 눈에 띄게 커넥션 숫자가 줄었지만 여전히 DB 서버는 개복치 그 자체였다. 조금 나아지긴 했지만 숨만 쉬면 죽어나가는 상황이 반복되었고 여전히 실사용이 불가능한 심각한 상태의 환자였다.

3. 커넥션 폭증, 두번째 추측

두번째 추측으로는 dev는 테스트를 위해서 인스턴스 재생성이 꽤 잦았는데, 이 과정에서 Connection이 계속해서 다시 만들어지면서 연결이 폭증하는 것으로 보였다.

Postgresql 설정에서 IDLE 상태로 들어간지 오래된 커넥션은 강제로 죽이는 옵션도 찾았는데, supabase에서는 해당 옵션값 설정은 지원하지 않았다. 아무래도 일반적인 대응 방법은 아닌것으로 보였다. 인스턴스가 재생성 될때마다 연결이 안끊긴다면? 애초에 연결을 많이 안만들면 되는게 아닌가? 라는 결론에 다다랐다. 따라서 자연스럽게 Pooler를 사용하기로 했다. Supabase에서도 공식적으로 Pooler를 (당연히) 지원하므로 이는 완벽한 해결책으로 생각됐다.

    Pooler란?

데이터베이스 서버와 클라이언트 애플리케이션 간의 연결을 관리하는 소프트웨어이다. 이는 데이터베이스 서버에 대한 연결 요청을 효율적으로 처리하고 성능을 최적화하는 데 도움을 준다.

연결 재사용

풀러는 데이터베이스 서버에 대한 연결을 재사용 할 수 있게 함으로써 매번 새로운 연결을 생성하는 오버헤드를 감소시킨다.

부하 관리

동시에 많은 클라이언트 요청을 처리할 수 있도록 도와주며 데이터베이스 서버에 가해지는 부하를 분산시킨다.

성능 최적화

연결 설정과 해제에 소요되는 시간을 줄임으로써 전반적인 애플리케이션 성능을 향상시킨다.

자원 관리

데이터베이스 연결에 사용되는 리소스를 효율적으로 관리한다.

스케일링

애플리케이션 확장성을 지원하며 사용자 요청 증가 시에도 안정적인 서비스 제공을 돕는다.

풀러의 사용은 특히 많은 수의 동시 사용자와 트랜잭션이 있는 대규모 시스템에서 매우 중요하다. 데이터베이스에 대한 연결을 효과적으로 관리함으로써 시스템의 안정성, 성능, 그리고 확장성을 향상시킬 수 있다.

4. Pooler 도입

supabase의 pooler는 PgBouncer를 제공하며, 사용법이 매우 간단했다. 기존에 사용하던 5432번 포트 대신 6543 포트로 바꿔서 연결해주기만 하면 됐다.

또 다시 곧장 문제가 발생했다. Supabase의 PgBouncer Poolmode는 기본적으로 Transation 모드였는데, 해당 모드에서는 SqlAlchemy의 비동기 엔진의 캐싱 기능을 지원하지 않았다.

    Pgbouncer의 Pool Mode

Session

가장 기본적인 풀링 모드로 클라이언트가 연결을 끊을 때까지 PgBouncer는 해당 클라이언트 연결을 데이터베이스 서버에 계속 연결 상태로 유지한다.

Transaction

이 모드에서 PgBouncer는 각 SQL 트랜잭션이 완료될 때마다 연결을 풀로 반환한다. 이는 트랜잭션간에 서버 세션 상태를 유지하지 않아므로 세션 수준의 상태 설정이 트랜잭션간에 유지되지 않는다. Supabase의 Postgresql의 Pool mode는 기본적으로 Transacion 모드로 설정되어있다.

Statement

가장 제한적인 풀링 모드로 각 SQL 문(statement)이 완료될 때마다 연결을 풀로 반환한다. Supabase의 Postgresql은 해당 모드를 지원하지 않는다.

5. Pooler 사용시 인스턴스 생성 에러 발생

이번엔 문제가 바로 발생하지는 않았다. 당일은 지나갔으나 다음날이 되어서야 발견했다. 인스턴스 오류가 발생해서 항사 연결이 끊기는 것도 아니고 간헐적으로 연결이 끊기고 있었다.

당시 발생한 인스턴스 생성 에러

Log ID
5c0963cf-d16a-4cf3-b3fe-33c99147f879
Log Timestamp (UTC)
2023-12-11T02:36:52.732Z
Log Event Message
password authentication failed for user "postgres"
Log Metadata
[
  {
    "file": null,
    "host": "db-fskkusemekciragirqyd",
    "metadata": [],
    "parsed": [
      {
        "application_name": null,
        "backend_type": "client backend",
        "command_tag": "authentication",
        "connection_from": "61.1.172.3:56746",
        "context": null,
        "database_name": "postgres",
        "detail": "Connection matched pg_hba.conf line 91: \"host  all  all  0.0.0.0/0     scram-sha-256\"",
        "error_severity": "FATAL",
        "hint": null,
        "internal_query": null,
        "internal_query_pos": null,
        "leader_pid": null,
        "location": null,
        "process_id": 89111,
        "query": null,
        "query_id": 0,
        "query_pos": null,
        "session_id": "657675c4.15c17",
        "session_line_num": 2,
        "session_start_time": "2023-12-11 02:36:52 UTC",
        "sql_state_code": "28P01",
        "timestamp": "2023-12-11 02:36:52.732 UTC",
        "transaction_id": 0,
        "user_name": "postgres",
        "virtual_transaction_id": "47/2627"
      }
    ],
    "parsed_from": null,
    "project": null,
    "source_type": null
  }
]

같은 설정에 같은 서버인데 뜬금없이 간헐적으로 패스워드 에러가 발생하니 죽을맛이었다.

그러면 안되지만, 오류의 빈도수라도 적다면 그냥 무시하고 넘어갈텐데 무시하기엔 높은 빈도로 에러가 발생했다. PgBouncer의 지원이 2024년 1월로 끝난다고 하니 애초에 비동기와의 호환성 자체가 좋지 않아보였다. 이 시점에서 Pooler 사용을 포기하고 다른 방법을 찾기로 했다.

PgBouncer 지원이 끊기고 대안으로 Superbase에서는 https://github.com/supabase/supavisor를 제시하고 있었는데, 아직 그다지 완성도가 높지 않아보였으며(본인들의 공식 문서에도 없으므로) 시간이 넉넉하지 않아 도입이 망설여졌다. 그래서 다시 커넥션 직접 연결을 선택하고, 커넥션이 폭증하는 문제 자체를 해결하기로 했다.

6. 커넥션 직접 연결로 회귀

여담으로, 일을 하면서 챗지피티의 도움을 참 많이 받는다. 그런데 이번 업무를 처리하면서 Deep한 업무일수록 챗지피티가 4.0이라도 헛소리를 많이 한다는 사실을 알게됐다. 어떻게 알았냐고? 나도 알고 싶지 않았다.

아예 처음으로 돌아와서 커넥션 폭증에 대해서 다시 한 번 알아보기로 했다. 이쯤에서 이미 실서버는 오픈을 해야 했기 때문에, 커넥션 직접 연결로 바꾸고 추가 결제를 해서 커넥션 총 개수를 200여개로 늘려버렸다. 실서버에서는 인스턴스가 바뀌는 일이 자주 있는 일이 아니었으므로, 모니터링 결과 IDLE 상태로 접어든 죽은 커넥션도 12시간 안에 종료가 되는 것을 경험적으로 확인했으므로 실서버에서는 200개 정도로 커넥션 연결 가능 개수를 늘려두면 어쨌건 서비스가 가능했다.

이렇게 실서버를 열어두고 문제를 해결하기 위해 시간을 녹이기 시작했다.

커넥션 자체의 관리는 이론적으로는 아주 간단했다. 앱을 시작할 때에 커넥션을 열며, 앱을 종료할 때 커넥션을 닫는다. 이를 위해서 FastAPI의 on_event(”startup”)on_event(”shutdown”) 을 사용했다. 챗지피티도 이 코드를 알려줬고, 어느 블로그를 봐도 모두 이 코드를 사용하고 있었다.

https://fastapi.tiangolo.com/advanced/events/#alternative-events-deprecated

근데 충격적이게도 이 방법은 이미 지원이 중단된 방법이었다. 이 방법 대신 lifespan 방법을 선택해야 했는데, 이 방법에 대해서 챗지피티는 알고 있음에도 불구하고 여전히 지원 중단된 방법을 추천하는 것을 보고 이 시점에서 챗지피티에 대한 신뢰를 상당 부분 잃어버리고 사용 빈도가 확 줄게 되었다. 나중에 알게 되지만 이 코드의 문제는 아니었지만! 아무튼 신뢰가 엄청나게 없어져버렸다.

해당 코드를 lifespan을 활용한 코드로도 변경하고도 여전히 문제는 반복됐다. 앱이 시작될 때는 코드가 정상적으로 작동했지만, 컨테이너 안에서 앱이 종료될 때에는 종료 코드가 정상적으로 작동하지 않았다.

이를 위해서 sigterm을 활용해 직접 종료 코드를 잡아서 실행하는 방법도 고려했지만 이 방법 역시 작동하지 않았다.

하지만 아주 삽질은 아니어서, 로컬에서 실행한 경우에는 정상적으로 종료 코드가 작동하고 커넥션이 끊어지는 것을 확인해버렸다.

7. 문제 해결

힞;민 모드를 바꿨음에도 캐싱은 여전히 작동하지 않아서 캐싱을 강제로 꺼야 했고 잘 꺼지지도 않았는지 이후에도 Sqlalchemy의 캐싱 과정에서 키값이 중복되게 생성되는 문제가 있어 해당 부분을 오버라이드 해서 키 값이 중복되지 않게 만들게 해주는 등 문제가 있었지만 일단은 연결이 끊기거나 커넥션이 폭증하지 않아 해결이 된 것으로 보였다.

사실 이 전에 종료 신호가 정상적으로 어플리케이션에 도달하지 못하는 것이 아닌가? 에 대해서 인지를 하고 있었다. 도커 컨테이너를 강제 종료 하면 기본적으로 docker는 1번 프로세스에만 종료 명령을 정상적으로 전달하고 나머지 프로세스에게는 종료 명령을 정상 전달하지 않는다. 도커 작업은 다른 개발자가 진행했는데, 컨테이너 안에서 실행되는 작업이라곤 Uvicorn 하나이므로 당연히 1번 프로세스일것이다 라는 말을 들어서 이 부분을 점검해볼 생각을 못하고 있었다.

근데, 로컬에서 실행했을때에는 정상적으로 커넥션이 끊어지는데 도커 컨테이너를 통해서 실행한 경우에는 정상 종료가 되지 않는다? 이 문제를 확인하고 나서야 이 부분을 점검해볼 생각이 들었고, 점검 결과 충격적이게도 Uvicorn이 1번 프로세스로 실행되지 않고 있었다.

Dumb-init이라는 Docker 컨테이너의 모든 프로세스에게 종료 신호를 보내주는 소프트웨어도 잠깐 고려했지만, 간단하게 Docker exec 부분을 수정해 Uvicorn이 1번 프로세스로 컨테이너 안에서 실행되게끔 하자 거짓말처럼 모든 문제가 해결되었다.

8. 회고

전 회사에서도 이미 만들어진 커넥션을 쓰거나, 그냥 가이드대로 연결하면 대충 됐기 때문에 DB와 서버의 연결에 관해서 깊게 고찰해본 적이 없었다. 이번 일을 겪으면서 일단 DB와 서버의 연결에 대해.. 대충 알고 있던 부분들에 대해서 깊은 학습이 되었다.

문제해결 방법을 가장 마지막에 발견했지만, 사실 꼼꼼히 살폈다면 초장에 해결할 수 있던 간단한 문제였다. 돌고 돌아 삥 돌아 해결을 했지만 최종적인 코드 수정은 굉장히 적었다. 공부 했으니 한잔해~ 하고 넘어가기에는 무보수 야근이 너무 잦았기 때문에 (덕분인지 시원하게 A형 독감도 걸렸다. 아무튼 야근 탓이다. ) 억울한 부분이 크다.

챗지피티에 대한 신뢰가 컸는데, 이번 일을 진행하면서는 챗지피티 때문에 뱅뱅 돌아가게 된 부분이 많았다. 확실히 겉핥기 부분만 챗지피티에게 기대야 하며(또는 반복적인 일) 딥 한 문제나 조금이라도 인터넷에 자료가 적은 문제들에 대해서는 공식문서를 신뢰해야 한다는 사실을 다시 한 번 뼈저리게 느꼈다. 이후로 나는 예전처럼 챗지피티를 많이 쓰지는 않는다.. 쓰더라도 조심히 사용한다.

한편으로, 커넥션 직접 연결 방법을 선택해 문제를 해결했지만 이게 완벽히 올바른 방법이라고는 생각이 들지 않는다. 아직은 서비스의 크기가 완벽하게 크지는 않기 때문에 이 방법으로도 가능하지만, Pooler를 사용하는 것이 올바른 해결 방법이 아닐까? 지금도 단순히 문제를 피해 도망친 것이라고밖에 생각이 들지 않는다. 특히 Pooler로 인해 발생한 문제들은 풀리지 않은 미스테리가 너무 많이 남았다.

추후 기회가 있다면 supavisor를 기반으로 다시 만들어보고 싶은 생각이 있다. 아니, 반드시 그래야 한다고 생각이 든다.

@SharkniA
만 6살 백엔드 개발자
© SharkniA, Built with Gatsby.