Published on

aiohttp ClientSession is closed 재현과 근본 해결

Authors

서버에서 외부 API를 비동기로 호출하다가 어느 날부터 로그에 RuntimeError: Session is closed 혹은 ClientSession is closed가 간헐적으로 터지기 시작합니다. 재시도 로직을 붙여도 해결되지 않고, 특정 트래픽 패턴(배치, 타임아웃, 취소, 앱 재시작)에서만 재현되기도 하죠.

이 글은 에러를 의도적으로 재현한 뒤, **왜 닫힌 세션을 참조하게 되는지(수명주기/이벤트 루프/취소/전역 캐시)**를 원인별로 쪼개서 설명하고, 현업에서 바로 적용 가능한 해결 패턴과 트러블슈팅 체크리스트를 제공합니다.


에러 메시지의 의미부터 정리

aiohttp.ClientSession은 커넥션 풀, DNS 캐시, 쿠키/헤더 상태 등을 관리하는 객체입니다. 세션이 닫히면(await session.close() 또는 컨텍스트 매니저 종료) 내부 커넥터가 정리되고 이후 요청을 보내려 하면 다음과 같은 형태로 실패합니다.

  • RuntimeError: Session is closed
  • aiohttp.client_exceptions.ClientConnectionError (닫힌 커넥터/연결 재사용 시)
  • Unclosed client session 경고(반대로 세션을 닫지 못했을 때)

핵심은 단 하나입니다.

> 요청을 보내는 시점에, 해당 세션이 이미 close된 상태

그럼 “누가 언제 닫았나?”를 찾아야 합니다.


재현 1 전역 세션을 만들고 함수마다 with로 닫아버리기

가장 흔한 실수는 전역 세션을 만들어두고, 호출 함수에서 async with session:로 감싸 세션을 닫아버리는 패턴입니다.

import aiohttp
import asyncio

session = aiohttp.ClientSession()  # 전역 생성 (문제의 씨앗)

async def fetch(url: str) -> str:
    # ❌ 전역 session을 컨텍스트로 감싸면 함수가 끝날 때 close됨
    async with session:
        async with session.get(url) as resp:
            return await resp.text()

async def main():
    print(await fetch("https://example.com"))
    # 두 번째 호출에서 'Session is closed' 재현 가능
    print(await fetch("https://example.com"))

asyncio.run(main())

해결

  • ClientSession요청 단위가 아니라 애플리케이션 수명주기 단위로 관리하세요.
  • async with세션을 생성한 곳에서만 사용하세요.
import aiohttp
import asyncio

async def fetch(session: aiohttp.ClientSession, url: str) -> str:
    async with session.get(url) as resp:
        resp.raise_for_status()
        return await resp.text()

async def main():
    async with aiohttp.ClientSession() as session:
        print(await fetch(session, "https://example.com"))
        print(await fetch(session, "https://example.com"))

asyncio.run(main())

재현 2 이벤트 루프가 바뀌는데 세션을 캐시해버리기

특히 웹 프레임워크(uvicorn/fastapi), 테스트(pytest-asyncio), 노트북 환경에서 자주 발생합니다.

  • 세션을 모듈 전역에 만들거나 싱글톤으로 캐시
  • 앱 리로드/테스트마다 이벤트 루프가 새로 생성
  • 이전 루프에 묶인 세션을 새 루프에서 재사용 → 닫힘/연결 오류/이상 동작

대표적인 안티패턴:

# client.py
import aiohttp

_session: aiohttp.ClientSession | None = None

def get_session() -> aiohttp.ClientSession:
    global _session
    if _session is None:
        _session = aiohttp.ClientSession()
    return _session

이 코드는 “잘 되는 것처럼 보이다가” 아래 상황에서 터집니다.

  • 개발 서버 --reload
  • 테스트가 루프를 새로 만들 때
  • 워커 프로세스/스레드 모델이 바뀔 때

해결 1 프레임워크 수명주기에 세션을 귀속

FastAPI 예시(권장):

from fastapi import FastAPI, Depends
import aiohttp

app = FastAPI()

@app.on_event("startup")
async def startup():
    app.state.http = aiohttp.ClientSession()

@app.on_event("shutdown")
async def shutdown():
    await app.state.http.close()

async def get_http() -> aiohttp.ClientSession:
    return app.state.http

@app.get("/proxy")
async def proxy(http: aiohttp.ClientSession = Depends(get_http)):
    async with http.get("https://example.com") as resp:
        return {"status": resp.status}

해결 2 루프별 세션을 분리

루프가 자주 바뀌는 환경(테스트 등)이라면 루프마다 세션을 만들고 닫는 구조로 강제하는 게 안전합니다.


재현 3 태스크 취소 CancelledError가 세션 close 타이밍을 꼬이게 만들기

타임아웃/클라이언트 취소(예: 요청 중단)로 코루틴이 취소되면, finally에서 세션을 닫거나 커넥션을 정리하는 과정이 예상과 다르게 실행됩니다.

예를 들어 요청 핸들러에서 세션을 만들고, 백그라운드 태스크로 넘겼는데 핸들러가 끝나며 세션이 닫히면 백그라운드가 닫힌 세션을 잡고 터집니다.

import aiohttp
import asyncio

async def background(session: aiohttp.ClientSession):
    await asyncio.sleep(0.2)
    async with session.get("https://example.com") as resp:
        return await resp.text()

async def handler():
    async with aiohttp.ClientSession() as session:
        task = asyncio.create_task(background(session))
        # handler가 먼저 끝나면 session이 닫힘
        return task

async def main():
    task = await handler()
    print(await task)  # 여기서 'Session is closed' 가능

asyncio.run(main())

해결

  • 세션 수명주기 > 그 세션을 쓰는 모든 태스크 수명주기가 되게 설계
  • 핸들러 내부에서 만든 세션을 백그라운드로 넘기지 말고, 앱 전역 세션을 주입
  • 백그라운드 작업은 별도 세션을 생성하거나, 작업 관리자(큐/워커)에서 세션을 관리

실전 해결 패턴 1 세션은 앱 단위로 하나만, 요청은 커넥션 풀로 처리

ClientSession을 요청마다 만들면 느리고(커넥션 재사용 불가), 닫힘/누수 문제도 늘어납니다. 기본 방향은 다음입니다.

  • 앱 시작 시 ClientSession 생성
  • 앱 종료 시 close
  • 요청 처리 함수에는 session만 주입

추가로 커넥션 풀/타임아웃을 명시하면 운영에서 사고가 줄어듭니다.

import aiohttp

TIMEOUT = aiohttp.ClientTimeout(
    total=30,
    connect=5,
    sock_connect=5,
    sock_read=25,
)

connector = aiohttp.TCPConnector(
    limit=200,           # 전체 동시 연결 상한
    limit_per_host=50,   # 호스트별 상한
    ttl_dns_cache=300,
    enable_cleanup_closed=True,
)

session = aiohttp.ClientSession(
    timeout=TIMEOUT,
    connector=connector,
    raise_for_status=False,
)

실전 해결 패턴 2 응답 컨텍스트를 끝까지 유지하고 반드시 읽거나 해제

async with session.get(...) as resp: 블록을 벗어나기 전에 바디를 읽지 않으면 커넥션이 풀로 정상 반환되지 않아, 시간이 지나면 리소스 고갈/이상 종료로 이어질 수 있습니다.

권장 패턴:

async with session.get(url) as resp:
    resp.raise_for_status()
    data = await resp.json()  # 또는 await resp.read()
    return data

스트리밍/대용량이면 청크로 읽고, 중간 취소 시 resp.release()를 고려합니다.


트러블슈팅 체크리스트

1) 어디서 close되는지 로그로 고정

세션을 감싸는 래퍼를 두고 close 시점을 기록하면 원인 추적이 빨라집니다.

import aiohttp
import traceback

class TracedSession(aiohttp.ClientSession):
    async def close(self):
        print("[ClientSession.close] called")
        traceback.print_stack(limit=8)
        await super().close()

운영 코드에 그대로 넣기보다는, 재현 환경에서만 사용하세요.

2) Unclosed client session 경고도 같이 보라

  • Session is closed만 보이면 “너무 일찍 닫힘”
  • Unclosed client session이 같이 보이면 “닫아야 할 때 못 닫음”

둘이 동시에 나타나는 시스템은 보통 수명주기 설계가 뒤엉킨 상태입니다.

3) 개발 서버 리로드/멀티워커 영향 확인

  • uvicorn --reload는 프로세스가 재시작되며 전역 상태가 꼬일 수 있음
  • gunicorn/uvicorn worker 모델에서는 워커마다 세션을 따로 가져야 함

웹 서버 타임아웃/워커 종료가 겹치면 요청이 취소되면서 리소스 정리가 엉킬 수 있습니다. 워커 종료/타임아웃 이슈가 함께 보인다면 Gunicorn Uvicorn Worker timeout 재현과 해결도 같이 점검하는 게 좋습니다.

4) 타임아웃을 무조건 늘리기 전에 “취소/재시도” 설계를 분리

외부 API가 느려서 취소가 잦다면, 세션이 닫혀서가 아니라 요청 취소 후 재사용 경로에서 문제가 발생할 수 있습니다. 타임아웃 재현/대응 패턴은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드의 방식(요청 단 타임아웃 + 재시도/폴백 분리)이 그대로 적용됩니다.


Best Practice 정리

세션 수명주기 규칙

  • 세션은 앱 시작 시 생성, 앱 종료 시 close
  • 세션을 전역 싱글톤으로 두더라도, 이벤트 루프가 바뀌는 환경에서는 금지(테스트/리로드)
  • 세션을 함수 안에서 만들었다면, 그 함수 밖(백그라운드 태스크)으로 넘기지 말 것

요청 처리 규칙

  • async with session.request(...) as resp: 블록에서 바디를 읽고 나가기
  • 타임아웃/커넥션 풀/limit 설정을 명시
  • 예외 처리에서 CancelledError는 별도로 다루고(보통 재시도 금지), 필요한 정리만 수행

운영 관점

  • 커넥션 풀 limit이 낮아 대기열이 길어지면 취소가 늘고, 그 과정에서 닫힘 문제가 표면화될 수 있음
  • 프록시/인그레스 타임아웃이 짧아 클라이언트 취소가 잦으면, 서버는 “정상 처리 중이었는데 클라이언트가 끊음” 패턴이 증가

스트리밍/장시간 연결을 다룬다면 프록시 타임아웃/버퍼링도 함께 봐야 합니다. (SSE/스트리밍 환경이라면 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트에서 다루는 idle timeout/keepalive 개념이 aiohttp 클라이언트 증상과 함께 나타나는 경우가 많습니다.)


결론

aiohttp ClientSession is closed는 단순히 “세션을 다시 만들면 된다” 문제가 아니라, 세션 수명주기를 누가 소유하는지가 불명확할 때 터지는 설계 결함에 가깝습니다.

  • 세션은 앱 단위로 생성/종료하고
  • 요청 함수에는 세션을 주입하며
  • 이벤트 루프가 바뀌는 환경에서는 전역 캐시를 피하고
  • 취소/백그라운드 태스크로 세션이 넘어가지 않게 구조를 정리하세요.

지금 코드에서 ClientSession() 생성 위치와 close() 호출 스택을 먼저 고정해 보세요. 그 두 군데만 정리해도, 간헐 장애의 대부분이 깔끔하게 사라집니다.