커넥션 풀의 연결(Connection Checkout)은 쿼리가 실행되는 순간부터 데이터베이스에 실제 요청을 보내거나 응답을 받는 순간(session.execute(), session.select(), …)부터 with 블록을 나갈 때까지(session.close() 호출 시까지) 계속 점유된다. with Session() 또는 async with AsyncSession() 블록에 진입하는 순간 커넥션 풀에 꺼내 쓰는 것이 아니지만(ref1), 최악의 경우 with 블록의 범위가 곧 커넥션의 점유 범위가 될 수 있음을 인지해야 한다.

특히 인터넷에서 흔히 찾을 수 있는 FastAPI 튜토리얼처럼 다음과 같이 컨트롤러 인자의 Depends()를 통해 세션을 주입하는 경우, 컨트롤러 내부에서 쿼리가 처음 실행되는 순간부터 get_user 함수가 끝날 때까지 연결을 유지할 수 있어 위험하다.

@router.get('...')
def problematic_controller(session = Depends(get_session)):
	...
	# problematic_controller 함수가 끝날 때까지 커넥션 점유 유지

아래처럼 세션 객체를 클래스의 멤버 변수로 관리하는 패턴도 마찬가지다. 보통 영속성 클래스는 생명 주기가 길다. 하지만 인스턴스가 삭제될 때까지 연결을 유지하기 때문에 더욱 위험하다.

class ProblematicRepository:
	def __init__(self, session = Depends(get_session)):
		self.session = session
		...
	
	def get_user(self):
	  stmt = select(User).where(User.id == 1)
	  user = self.session.query(User).filter_by(id=1).one()  # 커넥션 점유 시작
		# 이 순간부터 MyRepository 인스턴스가 삭제될 때까지 커넥션 점유 유지

위 두 경우에서 쿼리가 처음 실행되는 순간부터 요청에 응답하기까지 5초정도의 시간이 걸린다고 생각해 보자. 커넥션 풀의 최대 연결 수가 10개라고 할 때, 5초 안에 요청이 10개 이상 들어와 DB 연결을 시도한다면, 뒤늦게 들어온 요청들은 커넥션 자원을 대기하며 블로킹된다.

조금 더 나은 방법은 영속성 관련 함수(레포지토리 메서드)를 데코레이팅 패턴으로 감싸는 방법이다.

@with_db_session
def problematic_sync_function(session):  # `session`은 데코레이터를 통해 전달받음

	... # 앞의 코드

  stmt = select(User).where(User.id == 1)
  user = session.query(User).filter_by(id=1).one()  # 커넥션 점유 시작

  response = requests.get("<https://slow-api.com/process>")  # 5초 소요: 이때도 커넥션 점유 중
  user.external_data = response.json()

  session.commit()

# 함수가 끝나야지만 커넥션 반납

이렇게 하면 연결을 지속하는 범위가 영속성 관련 함수 내부로 고정된다. 깜빡하고 세션을 닫지 않는 실수도 줄일 수 있다.

하지만 위와 같은 코드는 첫 번째 DB 호출 이후 함수가 종료되기 전까지 session.close()가 불가능하다. 이렇게 하나의 영속성 함수 내에서 DB와 상관없는 I/O 바운드 작업이 존재하여 추가적인 최적화가 필요하다면, 아래와 같이 DB 입출력 부분에만 컨텍스트 매니저를 사용하여 불필요한 부분에 DB 커넥션을 반납해야 한다.

def recommended_sync_function():
  with Session() as session:
    user = session.query(User).filter_by(id=1).one()  # 커넥션 점유 시작
  # with를 벗어나며 커넥션 반납

  # 이 동안에는 DB 커넥션을 점유하지 않음
  response = requests.get("<https://slow-api.com/process>") # 5초 소요
  external_data = response.json()

  with Session() as session:
    user = session.query(User).filter_by(id=1).one()  # 커넥션 점유 시작
    user.external_data = external_data
    session.commit()
  # with를 벗어나며 커넥션 반납

parse me : 언젠가 이 글에 쓰이면 좋을 것 같은 재료을 보관해 두는 영역입니다.

  1. None

from : 과거의 어떤 원자적 생각이 이 생각을 만들었는지 연결하고 설명합니다.

  1. DI는 “소스코드 입장”에서 논리적으로 모든 의존성이 컨테이너로 향하도록 하고, 컨테이너에서 의존성을 중앙화된 방식으로 관리할 수 있다는 장점 때문에 사용한다.
  2. 데이터베이스 응답은 빠른 속도가 중요하므로, 연결을 끊어버리면 매번 TCP 핸드셰이크와 DB 핸드셰이크를 다시 해야 한다는 문제가 있다. 그래서 전송 계층으로 HTTP이 상태 유지가 기본인 TCP 프로토콜에 의존하고, 커넥션 풀을 이용해 한 번 맺어둔 TCP 연결을 재사용한다.
  3. 클린 아키텍처를 유지하며 FastAPI 코드 작성을 LLM에게 위임하기 위한 프롬프트