본문 바로가기
C.W.K.
Stream
Lesson 04 of 07 · published

Agent SDK 서브프로세스 풀은 독립 인프라야

~17 min · subprocess, pool, agent-sdk

Level 0Observer
0 XP0/64 lessons0/13 achievements
0/150 XP to next level150 XP to go0% complete

대화마다 매번 띄우는 비용을 없애

Agent SDK 서브프로세스를 요청마다 시작하면 프로세스 준비, MCP 연결, 시스템 프롬프트 예열을 반복해. 채팅에서는 conversation_id로 키를 둔 장기 클라이언트 풀이 지연을 크게 줄여. cwkPippa의 ClaudeSessionManager도 처음엔 딱 이런 풀이었어. 2026-05-14부터는 턴마다 새 클라이언트를 띄우고 대화 전체를 다시 넣어. 그래서 지금 그 풀은 턴 단위 lock, 턴이 끝난 뒤의 반납, 에러 경로가 남긴 항목을 치우는 정리기를 위해 있어. 어느 모양이든 아래 위생 규칙은 똑같이 필요해.

풀은 수명과 고장을 함께 소유해

첫 사용 때 연결하고 TTL이나 종료 때 끊으며, 죽은 클라이언트를 교체하고, 최대 동시 프로세스 수를 제한해야 해. 활성 수와 수명, 마지막 사용 시각도 측정해. DB 연결 풀과 같은 운영 규율이 필요한 인프라야.

쓰지 않는 프로세스를 주기적으로 거둬

오래 산다는 이유로 잊어버리면 자원과 MCP 연결이 새어. N분 동안 아무도 안 쓴 클라이언트를 정리하고 이유를 기록해. 풀이 가득 찼을 때는 끝없이 대기시키기보다 새 세션을 거부하거나 정한 큐 정책을 적용해 상태를 드러내.

쉰다는 건 아무도 안 쥐고 있다는 뜻이야

함정은 '안 쓴'이라는 말에 있어. 10분 동안 스트리밍하는 턴은 시작한 뒤로 마지막 사용 시각을 한 번도 안 건드려. 그러니 그 시각만 보고 쉬는지 판단하는 정리기는 한창 일하는 클라이언트를 논다고 보고 스트림 도중에 끊어 버려. 살아 있는지는 소유로 재. 호출자는 턴 내내 클라이언트의 lock 을 쥐고, 정리기는 lock 이 잡힌 클라이언트를 건너뛰고, 쉬는 시간은 턴이 끝난 뒤부터 세. 아래 풀이 딱 그렇게 해. 함께 붙인 테스트는 정리기 시계로 20분째 열려 있는 턴에 진짜 정리 함수를 불러. 정리 조건을 테스트 안에 베껴 쓰면 그 사본만 검사하는 거라, 진짜 정리기가 턴을 죽이는 동안에도 테스트는 계속 통과해.

원칙: 서브프로세스 풀은 접착 코드가 아니야. 수명·용량·관측을 가진 독립 구성요소로 만들어.

Code

정리기가 도는 턴은 절대 안 건드리는 세션 풀·python
import asyncio, time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

@dataclass
class Entry:
    client: ClaudeSDKClient
    last_used: float = field(default_factory=time.monotonic)
    turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock)

class SessionManager:
    def __init__(self, max_size: int = 50, ttl_seconds: int = 1800):
        self._entries: dict[str, Entry] = {}
        self._pool_lock = asyncio.Lock()
        self.max_size = max_size
        self.ttl = ttl_seconds

    @asynccontextmanager
    async def turn(self, conv_id: str, options: ClaudeAgentOptions):
        """Lend one client for one whole turn."""
        async with self._pool_lock:
            await self.sweep()
            entry = self._entries.get(conv_id)
            if entry is None:
                if len(self._entries) >= self.max_size:
                    raise RuntimeError("session pool full")
                client = ClaudeSDKClient(options=options)
                await client.connect()
                entry = self._entries[conv_id] = Entry(client)
            entry.last_used = time.monotonic()  # checked out, so not idle
        async with entry.turn_lock:  # held until the turn is over
            try:
                yield entry.client
            finally:
                entry.last_used = time.monotonic()  # the idle clock starts here

    async def sweep(self, now: float | None = None):
        """One pass. A client whose turn holds its lock is never idle."""
        now = time.monotonic() if now is None else now
        for conv_id, entry in list(self._entries.items()):
            if entry.turn_lock.locked():
                continue
            if now - entry.last_used > self.ttl:
                del self._entries[conv_id]
                await entry.client.disconnect()

    async def reaper(self, every: float = 60):
        """Background loop; it runs the same pass the tests call."""
        while True:
            await asyncio.sleep(every)
            async with self._pool_lock:
                await self.sweep()


# One turn, start to finish, inside the lock:
#
#     async with pool.turn(conv_id, options) as client:
#         await client.query(prompt)
#         async for message in client.receive_response():
#             ...
조건을 베끼지 않고 진짜 정리 함수를 부르는 테스트·python
import asyncio, time
import pytest
import session_pool

class FakeClient:
    def __init__(self, options=None):
        self.connected = False
    async def connect(self):
        self.connected = True
    async def disconnect(self):
        self.connected = False

@pytest.fixture
def pool(monkeypatch):
    monkeypatch.setattr(session_pool, "ClaudeSDKClient", FakeClient)
    return session_pool.SessionManager(ttl_seconds=300)

def test_a_turn_in_progress_is_never_swept(pool):
    async def scenario():
        async with pool.turn("conv-a", options=None) as client:
            # Twenty minutes into one long turn, by the sweep's clock.
            await pool.sweep(now=time.monotonic() + 1200)
            return client.connected
    assert asyncio.run(scenario())  # delete the lock check: this goes red

def test_a_client_idle_past_the_ttl_is_swept(pool):
    async def scenario():
        async with pool.turn("conv-a", options=None) as client:
            pass
        await pool.sweep(now=time.monotonic() + 301)
        return client.connected
    assert not asyncio.run(scenario())

External links

Exercise

Claude 세션 풀에 TTL 정리기를 붙여(없으면 위 코드로 만들어). 조건을 베끼지 말고 진짜 정리 함수를 불러서 시험해. TTL 보다 1초 더 쉰 클라이언트는 끊기고, 정리기 시계로 20분이 지났어도 턴이 아직 도는 클라이언트는 살아남아야 해. 그다음 lock 검사를 지우고, 도는 턴 테스트가 정말 실패하는지 확인해.
Hint
풀이 계속 커진다면 SDK 누수가 아니라 상한과 정리가 없는 풀 버그부터 의심해. 긴 턴이 매번 같은 시각에 죽으면 벤더보다 네 정리기부터 봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.