
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()를 실행하지 않아도 동작합니다.
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 기준 내림차순으로 정렬됩니다.
매개변수:
| 매개변수 | 타입 | 기본값 | 설명 |
|---|---|---|---|
directory | str \| None | None | 프로젝트 경로. 생략하면 전체 프로젝트 |
limit | int \| None | None | 최대 반환 수 |
offset | int | 0 | 건너뛸 수 |
include_worktrees | bool | True | git worktree 세션 포함 여부 |
내부 동작:
.jsonl 파일의 앞 64KB와 뒤 64KB만 읽습니다 (전체 파싱 없이 빠르게)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)
특정 세션 하나의 메타데이터만 빠르게 조회합니다. 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을 반환합니다.
세션의 전체 대화 메시지를 시간순으로 반환합니다.
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_id | str | (필수) | 세션 UUID |
directory | str \| None | None | 프로젝트 경로 |
limit | int \| None | None | 최대 메시지 수 |
offset | int | 0 | 건너뛸 메시지 수 |
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": {...}},
]
}
내부 동작:
parentUuid 체인을 따라가서 대화 순서를 재구성isCompactSummary)는 포함 (VS Code IDE와 동일한 동작)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 — 세션 파일이 없음from claude_agent_sdk import tag_session
# 태그 설정
tag_session("550e8400-...", "experiment")
# 태그 해제
tag_session("550e8400-...", None)
태그는 세션을 분류하는 용도입니다. list_sessions()로 조회한 뒤 tag 필드로 필터링할 수 있습니다.
동작 원리: rename_session()과 같은 방식으로 JSONL 파일 끝에 추가합니다. Unicode 위험 문자(zero-width, 방향 제어 등)는 자동으로 제거됩니다.
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"]
세션의 대화를 복제하여 새로운 세션을 만듭니다. 원본은 변경되지 않습니다.
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 파일로 저장
사용 시나리오:
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}")
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})")
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}개")
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)
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}턴")
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() | 세션 목록 조회 | O | X (읽기만) |
get_session_info() | 단일 세션 메타데이터 | O | X (읽기만) |
get_session_messages() | 대화 내역 조회 | O | X (읽기만) |
rename_session() | 제목 변경 | O | O (append) |
tag_session() | 태그 설정/해제 | O | O (append) |
delete_session() | 세션 삭제 | O | O (삭제) |
fork_session() | 세션 분기 | O | O (새 파일 생성) |
모든 함수는 동기(sync) 함수입니다. await 없이 바로 호출할 수 있습니다. JSONL 파일을 직접 읽고 쓰기 때문에 CLI 프로세스를 실행하지 않습니다.
세션 관리 API의 핵심은 CLI 없이도 대화 데이터에 접근할 수 있다는 것입니다.
기억할 3가지:
list_sessions() + get_session_messages() — 대시보드나 히스토리 뷰어의 기반fork_session(up_to_message_id=...) — 특정 시점에서 "다른 세계선"으로 분기await 없이 바로 호출, JSONL 파일 직접 조작전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크