API Key 없이 Claude Agent 서버 만들기! #8 - 세션 관리(대화 이력 조회, 분기, 삭제)

조현상·2026년 4월 2일

ClaudeCode

목록 보기
16/17
post-thumbnail

들어가며

Claude Code CLI를 쓰다 보면, 과거 대화를 다시 보고 싶을 때가 있습니다. "어제 리팩토링할 때 Claude가 뭐라고 했더라?" 같은 순간이죠.

CLI에서는 claude --resume이나 세션 목록에서 선택하면 되지만, 프로그래밍적으로 접근하려면 어떻게 해야 할까요?

claude_agent_sdk는 세션 데이터를 읽고 수정하는 7개의 함수를 제공합니다. 이 글에서는 세션이 어떻게 저장되는지부터, 이 함수들로 대시보드, 히스토리 뷰어, 세션 분석 도구를 만드는 방법까지 다룹니다.


세션은 어디에 저장되나

Claude Code의 모든 대화는 JSONL 파일로 저장됩니다.

~/.claude/projects/
├── -Users-hyeonsang-Documents-my-project/    ← 프로젝트 경로를 디렉토리 이름으로 변환
│   ├── 550e8400-e29b-41d4-a716-446655440000.jsonl    ← 세션 1
│   ├── 660e8400-e29b-41d4-a716-446655440001.jsonl    ← 세션 2
│   └── ...
├── -Users-hyeonsang-Documents-other-project/
│   └── ...

.jsonl 파일이 하나의 세션입니다. 파일 안에는 사용자 메시지, Claude 응답, 도구 호출, 메타데이터 등이 한 줄씩 JSON으로 기록되어 있습니다.

SDK의 세션 함수들은 이 JSONL 파일을 직접 읽고 쓰는 함수입니다. Claude CLI나 query()를 실행하지 않아도 동작합니다.


조회 함수 3종

list_sessions() — 세션 목록 조회

from claude_agent_sdk import list_sessions

# 특정 프로젝트의 세션 목록
sessions = list_sessions(directory="/path/to/project")

# 전체 프로젝트의 세션 목록
sessions = list_sessions()

# 페이지네이션
page1 = list_sessions(limit=20)
page2 = list_sessions(limit=20, offset=20)

# git worktree 제외
sessions = list_sessions(
    directory="/path/to/project",
    include_worktrees=False,
)

반환 타입은 list[SDKSessionInfo]이며, last_modified 기준 내림차순으로 정렬됩니다.

매개변수:

매개변수타입기본값설명
directorystr \| NoneNone프로젝트 경로. 생략하면 전체 프로젝트
limitint \| NoneNone최대 반환 수
offsetint0건너뛸 수
include_worktreesboolTruegit worktree 세션 포함 여부

내부 동작:

  • .jsonl 파일의 앞 64KB와 뒤 64KB만 읽습니다 (전체 파싱 없이 빠르게)
  • 파일 앞부분에서 첫 프롬프트, 뒤부분에서 마지막 제목/태그를 추출
  • git worktree 경로도 인식하여 여러 브랜치의 세션을 통합 조회

SDKSessionInfo — 세션 메타데이터

list_sessions()get_session_info()가 반환하는 타입입니다.

@dataclass
class SDKSessionInfo:
    session_id: str           # 세션 UUID
    summary: str              # 표시 제목 (custom_title > aiTitle > first_prompt)
    last_modified: int        # 마지막 수정 시각 (밀리초 epoch)
    file_size: int | None     # 파일 크기 (bytes)
    custom_title: str | None  # 사용자/AI가 설정한 제목
    first_prompt: str | None  # 첫 번째 사용자 프롬프트 (200자 이내)
    git_branch: str | None    # 세션의 git 브랜치
    cwd: str | None           # 작업 디렉토리
    tag: str | None           # 사용자 태그
    created_at: int | None    # 생성 시각 (밀리초 epoch)

summary는 표시용 제목으로, 우선순위는:
1. custom_title (사용자가 직접 설정한 제목)
2. AI가 생성한 제목 (aiTitle)
3. 마지막 프롬프트 (lastPrompt)
4. 첫 번째 프롬프트 (first_prompt)

get_session_info() — 단일 세션 조회

특정 세션 하나의 메타데이터만 빠르게 조회합니다. list_sessions()와 달리 디렉토리 전체를 스캔하지 않습니다.

from claude_agent_sdk import get_session_info

info = get_session_info("550e8400-e29b-41d4-a716-446655440000")
if info:
    print(f"제목: {info.summary}")
    print(f"브랜치: {info.git_branch}")
    print(f"태그: {info.tag}")

# 특정 프로젝트에서만 검색
info = get_session_info(
    "550e8400-e29b-41d4-a716-446655440000",
    directory="/path/to/project",
)

세션이 없으면 None을 반환합니다.

get_session_messages() — 대화 내역 조회

세션의 전체 대화 메시지를 시간순으로 반환합니다.

from claude_agent_sdk import get_session_messages

messages = get_session_messages("550e8400-e29b-41d4-a716-446655440000")

for msg in messages:
    print(f"[{msg.type}] {msg.uuid}")
    
    if msg.type == "user":
        content = msg.message.get("content", "")
        if isinstance(content, str):
            print(f"  {content[:100]}")
        elif isinstance(content, list):
            for block in content:
                if block.get("type") == "text":
                    print(f"  {block['text'][:100]}")
    
    elif msg.type == "assistant":
        content = msg.message.get("content", [])
        for block in content:
            if block.get("type") == "text":
                print(f"  {block['text'][:100]}")

매개변수:

매개변수타입기본값설명
session_idstr(필수)세션 UUID
directorystr \| NoneNone프로젝트 경로
limitint \| NoneNone최대 메시지 수
offsetint0건너뛸 메시지 수

SessionMessage 타입:

@dataclass
class SessionMessage:
    type: Literal["user", "assistant"]  # 메시지 타입
    uuid: str                           # 메시지 UUID
    session_id: str                     # 세션 ID
    message: Any                        # Anthropic API 메시지 형식 (role, content)
    parent_tool_use_id: None = None     # 항상 None (도구 체인은 필터링됨)

message 필드는 Anthropic Messages API 형식입니다:

# user 메시지
{
    "role": "user",
    "content": "pyproject.toml을 분석해줘"
}

# assistant 메시지
{
    "role": "assistant",
    "content": [
        {"type": "text", "text": "분석 결과입니다..."},
        {"type": "tool_use", "id": "...", "name": "Read", "input": {...}},
    ]
}

내부 동작:

  • JSONL 파일을 전체 파싱
  • parentUuid 체인을 따라가서 대화 순서를 재구성
  • 사이드체인(서브 에이전트 대화), 메타 메시지, 팀 메시지는 필터링
  • 컨텍스트 압축 후의 요약 메시지(isCompactSummary)는 포함 (VS Code IDE와 동일한 동작)

변경 함수 4종

rename_session() — 세션 제목 변경

from claude_agent_sdk import rename_session

rename_session(
    "550e8400-e29b-41d4-a716-446655440000",
    "리팩토링 대화",
)

# 특정 프로젝트 지정
rename_session(
    "550e8400-e29b-41d4-a716-446655440000",
    "리팩토링 대화",
    directory="/path/to/project",
)

동작 원리: JSONL 파일 끝에 {"type": "custom-title", "customTitle": "리팩토링 대화"} 엔트리를 추가합니다. list_sessions()가 파일 뒤쪽에서 마지막 customTitle을 읽으므로, 여러 번 호출해도 가장 마지막 값이 사용됩니다.

예외:

  • ValueError — 유효하지 않은 UUID이거나 빈 제목
  • FileNotFoundError — 세션 파일이 없음

tag_session() — 세션 태그 설정/해제

from claude_agent_sdk import tag_session

# 태그 설정
tag_session("550e8400-...", "experiment")

# 태그 해제
tag_session("550e8400-...", None)

태그는 세션을 분류하는 용도입니다. list_sessions()로 조회한 뒤 tag 필드로 필터링할 수 있습니다.

동작 원리: rename_session()과 같은 방식으로 JSONL 파일 끝에 추가합니다. Unicode 위험 문자(zero-width, 방향 제어 등)는 자동으로 제거됩니다.

delete_session() — 세션 삭제

from claude_agent_sdk import delete_session

delete_session("550e8400-e29b-41d4-a716-446655440000")

주의: 하드 삭제입니다. JSONL 파일이 영구적으로 삭제됩니다. 복구 불가능합니다.

소프트 삭제가 필요하면 태그를 사용하세요:

# 소프트 삭제 (숨기기)
tag_session(session_id, "__hidden")

# 조회할 때 필터링
sessions = list_sessions()
visible = [s for s in sessions if s.tag != "__hidden"]

fork_session() — 세션 분기

세션의 대화를 복제하여 새로운 세션을 만듭니다. 원본은 변경되지 않습니다.

from claude_agent_sdk import fork_session

# 전체 대화를 복제
result = fork_session("550e8400-e29b-41d4-a716-446655440000")
print(f"새 세션: {result.session_id}")

# 특정 메시지까지만 복제 (그 시점에서 분기)
result = fork_session(
    "550e8400-e29b-41d4-a716-446655440000",
    up_to_message_id="660e8400-e29b-41d4-a716-446655440001",
)

# 분기 세션에 커스텀 제목 부여
result = fork_session(
    "550e8400-e29b-41d4-a716-446655440000",
    title="접근법 B 시도",
)

반환 타입:

@dataclass
class ForkSessionResult:
    session_id: str   # 새로 생성된 세션의 UUID

내부 동작:
1. 원본 JSONL을 전체 파싱
2. 사이드체인(서브 에이전트) 메시지 제거
3. 모든 메시지의 UUID를 새로 생성하고, parentUuid 체인을 재매핑
4. up_to_message_id가 지정되면 해당 메시지까지만 복제
5. 마지막 메시지의 타임스탬프만 현재 시각으로 업데이트 (CLI가 최신 세션으로 인식)
6. 제목이 없으면 원본 제목 + " (fork)"
7. 같은 프로젝트 디렉토리에 새 .jsonl 파일로 저장

사용 시나리오:

  • "여기서 다른 접근법을 시도해보자" — 특정 시점에서 분기
  • "이 대화를 템플릿으로 쓰자" — 전체 복제 후 이어서 진행
  • "롤백하고 싶다" — 이전 시점에서 fork 후 원본 삭제

실전 패턴

패턴 1: 세션 히스토리 뷰어

from claude_agent_sdk import list_sessions, get_session_messages
from datetime import datetime


def show_history(directory: str, limit: int = 10):
    """프로젝트의 최근 세션과 대화 미리보기를 출력"""
    sessions = list_sessions(directory=directory, limit=limit)

    if not sessions:
        print("세션이 없습니다.")
        return

    for i, session in enumerate(sessions):
        # 시간 변환
        modified = datetime.fromtimestamp(session.last_modified / 1000)
        created = (
            datetime.fromtimestamp(session.created_at / 1000)
            if session.created_at
            else None
        )

        print(f"\n{'─' * 60}")
        print(f"[{i + 1}] {session.summary}")
        print(f"    ID: {session.session_id}")
        print(f"    생성: {created:%Y-%m-%d %H:%M}" if created else "    생성: 알 수 없음")
        print(f"    수정: {modified:%Y-%m-%d %H:%M}")
        if session.git_branch:
            print(f"    브랜치: {session.git_branch}")
        if session.tag:
            print(f"    태그: {session.tag}")
        if session.file_size:
            print(f"    크기: {session.file_size / 1024:.1f} KB")

        # 대화 미리보기 (처음 3개 메시지)
        messages = get_session_messages(
            session.session_id,
            directory=directory,
            limit=3,
        )
        if messages:
            print(f"    대화 미리보기:")
            for msg in messages:
                role = "사용자" if msg.type == "user" else "Claude"
                content = msg.message.get("content", "")
                if isinstance(content, str):
                    text = content
                elif isinstance(content, list):
                    texts = [b["text"] for b in content if b.get("type") == "text"]
                    text = " ".join(texts)
                else:
                    text = str(content)
                preview = text[:80] + "..." if len(text) > 80 else text
                print(f"      [{role}] {preview}")

패턴 2: 세션 검색

def search_sessions(keyword: str, directory: str | None = None) -> list:
    """세션 제목과 첫 프롬프트에서 키워드를 검색"""
    sessions = list_sessions(directory=directory)
    results = []

    for session in sessions:
        searchable = " ".join(filter(None, [
            session.summary,
            session.first_prompt,
            session.custom_title,
            session.tag,
        ])).lower()

        if keyword.lower() in searchable:
            results.append(session)

    return results


# 사용
matches = search_sessions("리팩토링")
for s in matches:
    print(f"{s.summary} (태그: {s.tag})")

패턴 3: 세션 통계 대시보드

from collections import Counter


def session_stats(directory: str | None = None):
    """세션 통계를 집계"""
    sessions = list_sessions(directory=directory)

    if not sessions:
        print("세션이 없습니다.")
        return

    # 기본 통계
    total = len(sessions)
    total_size = sum(s.file_size or 0 for s in sessions)
    tagged = sum(1 for s in sessions if s.tag)

    print(f"총 세션: {total}개")
    print(f"총 용량: {total_size / 1024 / 1024:.1f} MB")
    print(f"태그됨: {tagged}개")

    # 브랜치별 세션 수
    branches = Counter(s.git_branch for s in sessions if s.git_branch)
    if branches:
        print(f"\n브랜치별:")
        for branch, count in branches.most_common(10):
            print(f"  {branch}: {count}개")

    # 태그별 세션 수
    tags = Counter(s.tag for s in sessions if s.tag)
    if tags:
        print(f"\n태그별:")
        for tag, count in tags.most_common(10):
            print(f"  {tag}: {count}개")

    # 월별 활동
    monthly = Counter()
    for s in sessions:
        if s.created_at:
            dt = datetime.fromtimestamp(s.created_at / 1000)
            monthly[dt.strftime("%Y-%m")] += 1
    if monthly:
        print(f"\n월별 활동:")
        for month, count in sorted(monthly.items(), reverse=True)[:6]:
            print(f"  {month}: {count}개")

패턴 4: 대화 내보내기 (Markdown)

def export_session_markdown(session_id: str, directory: str | None = None) -> str:
    """세션을 마크다운 문자열로 변환"""
    from claude_agent_sdk import get_session_info

    info = get_session_info(session_id, directory=directory)
    messages = get_session_messages(session_id, directory=directory)

    lines = []

    # 헤더
    title = info.summary if info else session_id
    lines.append(f"# {title}\n")
    if info:
        if info.created_at:
            dt = datetime.fromtimestamp(info.created_at / 1000)
            lines.append(f"**날짜:** {dt:%Y-%m-%d %H:%M}\n")
        if info.git_branch:
            lines.append(f"**브랜치:** {info.git_branch}\n")
    lines.append("---\n")

    # 메시지
    for msg in messages:
        role = "사용자" if msg.type == "user" else "Claude"
        lines.append(f"### {role}\n")

        content = msg.message.get("content", "")
        if isinstance(content, str):
            lines.append(f"{content}\n")
        elif isinstance(content, list):
            for block in content:
                if block.get("type") == "text":
                    lines.append(f"{block['text']}\n")
                elif block.get("type") == "tool_use":
                    lines.append(f"```\n[도구: {block['name']}]\n```\n")
        lines.append("")

    return "\n".join(lines)


# 사용
md = export_session_markdown("550e8400-...")
with open("exported_session.md", "w") as f:
    f.write(md)

패턴 5: fork를 활용한 A/B 테스트

async def ab_test_from_session(
    session_id: str,
    branch_point_msg_id: str,
    prompt_a: str,
    prompt_b: str,
):
    """기존 세션의 특정 시점에서 분기하여 두 가지 접근법을 테스트"""
    from claude_agent_sdk import fork_session, query, ClaudeAgentOptions

    # A 브랜치
    fork_a = fork_session(
        session_id,
        up_to_message_id=branch_point_msg_id,
        title="A/B Test - 접근법 A",
    )
    tag_session(fork_a.session_id, "ab-test-a")
    print(f"브랜치 A: {fork_a.session_id}")

    # B 브랜치
    fork_b = fork_session(
        session_id,
        up_to_message_id=branch_point_msg_id,
        title="A/B Test - 접근법 B",
    )
    tag_session(fork_b.session_id, "ab-test-b")
    print(f"브랜치 B: {fork_b.session_id}")

    # 각 브랜치에서 다른 프롬프트로 진행
    options = ClaudeAgentOptions(
        permission_mode="bypassPermissions",
        max_turns=10,
    )

    print("\n--- 접근법 A ---")
    async for msg in query(
        prompt=prompt_a,
        options=ClaudeAgentOptions(
            **{**options.__dict__, "session_id": fork_a.session_id, "continue_conversation": True}
        ),
    ):
        if isinstance(msg, ResultMessage):
            print(f"A 완료: {msg.num_turns}턴")

    print("\n--- 접근법 B ---")
    async for msg in query(
        prompt=prompt_b,
        options=ClaudeAgentOptions(
            **{**options.__dict__, "session_id": fork_b.session_id, "continue_conversation": True}
        ),
    ):
        if isinstance(msg, ResultMessage):
            print(f"B 완료: {msg.num_turns}턴")

패턴 6: 오래된 세션 정리

from datetime import datetime, timedelta


def cleanup_old_sessions(
    directory: str,
    days: int = 30,
    dry_run: bool = True,
):
    """지정 일수보다 오래된 세션을 삭제"""
    sessions = list_sessions(directory=directory)
    cutoff = datetime.now() - timedelta(days=days)
    cutoff_ms = int(cutoff.timestamp() * 1000)

    old_sessions = [s for s in sessions if s.last_modified < cutoff_ms]

    print(f"전체 세션: {len(sessions)}개")
    print(f"{days}일 이상 된 세션: {len(old_sessions)}개")

    for s in old_sessions:
        modified = datetime.fromtimestamp(s.last_modified / 1000)
        print(f"  {s.summary[:40]:40s} | {modified:%Y-%m-%d} | {(s.file_size or 0) / 1024:.0f}KB")

    if dry_run:
        print(f"\n[dry_run] 실제로 삭제하려면 dry_run=False로 호출하세요.")
    else:
        for s in old_sessions:
            delete_session(s.session_id, directory=directory)
        print(f"\n{len(old_sessions)}개 세션 삭제 완료.")

동시 접근 안전성

SDK의 세션 변경 함수(rename_session, tag_session)는 JSONL 파일 끝에 append하는 방식입니다. 읽기 함수(list_sessions, get_session_messages)는 파일을 읽기만 합니다.

CLI가 동시에 같은 세션을 사용하고 있다면?

안전합니다. CLI의 reAppendSessionMetadata()는 파일 뒤쪽을 읽어서 메타데이터를 재적용하므로, SDK가 쓴 customTitle이나 tag도 CLI의 캐시에 자동으로 흡수됩니다.

단, delete_session()은 파일을 완전히 삭제하므로, CLI가 해당 세션을 사용 중이면 에러가 발생할 수 있습니다.


함수 전체 요약

함수용도CLI 없이 동작파일 변경
list_sessions()세션 목록 조회OX (읽기만)
get_session_info()단일 세션 메타데이터OX (읽기만)
get_session_messages()대화 내역 조회OX (읽기만)
rename_session()제목 변경OO (append)
tag_session()태그 설정/해제OO (append)
delete_session()세션 삭제OO (삭제)
fork_session()세션 분기OO (새 파일 생성)

모든 함수는 동기(sync) 함수입니다. await 없이 바로 호출할 수 있습니다. JSONL 파일을 직접 읽고 쓰기 때문에 CLI 프로세스를 실행하지 않습니다.


마치며

세션 관리 API의 핵심은 CLI 없이도 대화 데이터에 접근할 수 있다는 것입니다.

기억할 3가지:

  1. list_sessions() + get_session_messages() — 대시보드나 히스토리 뷰어의 기반
  2. fork_session(up_to_message_id=...) — 특정 시점에서 "다른 세계선"으로 분기
  3. 모두 동기 함수await 없이 바로 호출, JSONL 파일 직접 조작

전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크

profile
꿈꾸는 개발자

0개의 댓글