M3. JSONL session으로 작업 일지 남기기
이번 단계에서는 Agent의 작업 기록을 파일로 남깁니다.
M2에서 AgentMessage와 AgentEvent 타입을 정의했다면, M3에서는 그 타입을 실제 session 파일에 저장하고 다시 읽어옵니다.
AgentMessage / AgentEvent
→ JSON object
→ JSONL file
→ 다시 load
아직 AgentRuntime, SSE, Tool 실행과는 연결하지 않습니다. 이번 단계의 목표는 더 작습니다.
대화와 작업 이벤트를 기록할 수 있는가?
기록한 내용을 다시 읽을 수 있는가?
깨진 기록을 조용히 넘기지 않고 실패시킬 수 있는가?
왜 session이 필요한가
일반 챗봇은 대화가 끝나면 기억이 사라져도 큰 문제가 없습니다.
하지만 코딩 에이전트는 다릅니다.
에이전트는 파일을 읽고, 코드를 고치고, 테스트를 실행합니다. 그러면 반드시 작업 일지가 필요합니다.
언제 작업을 시작했는가
사용자가 어떤 메시지를 보냈는가
어떤 assistant 응답이 만들어졌는가
어떤 도구 호출이 시작됐는가
도구 호출은 성공했는가, 실패했는가
어디서 에러가 났는가
나중에 이 흐름을 다시 확인할 수 있는가
Spring에 비유하면 Agent session은 단순한 HttpSession보다 이벤트 로그에 더 가깝습니다.
상태를 메모리에만 들고 있는 것이 아니라, 나중에 열어보고 디버깅할 수 있는 기록으로 남깁니다.
왜 JSONL인가
JSONL은 한 줄에 JSON 객체 하나를 저장하는 형식입니다.
{"kind":"user","id":"m_1","sessionId":"s_1","parentId":null,"createdAt":"2026-06-29T00:00:00Z","text":"README를 읽어줘"}
{"kind":"assistant","id":"m_2","sessionId":"s_1","parentId":"m_1","createdAt":"2026-06-29T00:00:01Z","text":"먼저 파일을 읽겠습니다.","stopReason":"STOP"}
{"kind":"tool_result","id":"m_3","sessionId":"s_1","parentId":"m_2","createdAt":"2026-06-29T00:00:02Z","toolCallId":"tc_1","toolName":"read","resultText":"...","error":false}
장점은 단순합니다.
- 사람이 직접 열어볼 수 있다.
- append-only 저장이 쉽다.
- 한 줄씩 읽고 복원하기 좋다.
- Git diff로 형태를 확인하기 쉽다.
- DB 없이 시작할 수 있다.
초기 포트폴리오 단계에서는 PostgreSQL보다 JSONL이 더 낫습니다.
동작 원리를 눈으로 보여줄 수 있기 때문입니다.
실제 세션 파일 구조
session 하나를 디렉터리 하나로 저장합니다.
.pi-spring-ai/
sessions/
{sessionId}/
session.json
messages.jsonl
events.jsonl
session.json에는 session 메타데이터를 저장합니다.
{
"id": "s_...",
"workspaceRoot": "C:\\path\\to\\workspace",
"createdAt": "2026-06-29T00:00:00Z",
"updatedAt": "2026-06-29T00:00:00Z",
"title": null
}
messages.jsonl에는 UserMessage, AssistantMessage, ToolResultMessage를 저장합니다.
events.jsonl에는 AgentStartedEvent, AssistantDeltaEvent, ToolCallStartedEvent, ToolCallFinishedEvent, AgentErrorEvent 같은 실행 이벤트를 저장합니다.
메시지와 이벤트를 파일로 분리한 이유는 역할이 다르기 때문입니다.
메시지는 대화의 기록이고, 이벤트는 실행 과정의 기록입니다.
SessionStore 인터페이스
이번 단계의 인터페이스는 다음 모양입니다.
public interface SessionStore {
AgentSession create(Path workspaceRoot);
Optional<AgentSession> findSession(String sessionId);
void appendMessage(String sessionId, AgentMessage message);
void appendEvent(String sessionId, AgentEvent event);
List<AgentMessage> loadMessages(String sessionId);
List<AgentEvent> loadEvents(String sessionId);
}
대화 히스토리를 만들 때는 messages만 읽으면 되고, 디버깅 화면이나 SSE replay를 만들 때는 events를 읽으면 됩니다.
저장 포맷은 flat JSON으로 둔다
다음처럼 저장하지 않습니다.
{"kind":"user","payload":{"id":"m_1","text":"hello"}}
대신 실제 파일에는 concrete message/event 객체를 그대로 flat JSON으로 씁니다.
{"kind":"user","id":"m_1","sessionId":"s_1","parentId":null,"createdAt":"2026-06-29T00:00:00Z","text":"hello"}
이렇게 해두면 사람이 파일을 열었을 때 바로 읽을 수 있습니다.
payload, json 같은 내부 구현용 필드도 파일에 남지 않습니다.
읽을 때는 먼저 JsonNode로 한 줄을 읽습니다.
그 다음 kind 또는 type 값을 보고 concrete record로 복원합니다.
readTree(line)
→ kind/type 확인
→ StoredMessage 또는 StoredEvent helper 생성
→ switch
→ treeToValue(root, UserMessage.class)
StoredMessage, StoredEvent는 파일 포맷이 아닙니다. load 과정에서만 쓰는 작은 helper입니다.
ObjectMapper는 세션 저장소 전용 Bean으로 둔다
Spring Boot 4에서는 Jackson 3 계열이 기본 흐름입니다.
그런데 현재 메시지와 이벤트 타입은 com.fasterxml.jackson 기반 Jackson 2 annotation을 사용합니다.
그래서 이번 PR에서는 전역 ObjectMapper를 억지로 건드리지 않고, session 저장소 전용 mapper를 따로 둡니다.
@Bean
@Qualifier("sessionObjectMapper")
ObjectMapper sessionObjectMapper() {
return JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
}
JsonlSessionStore는 이 mapper를 주입받습니다.
처음에는 store 내부에서 mapper를 직접 만들 수도 있습니다.
하지만 Spring Bean으로 분리해두면 설정 위치가 명확해지고, Spring context에서 실제로 wiring되는지도 테스트할 수 있습니다.
이번 단계에서 하지 않은 것
M3에서는 일부러 하지 않은 것들이 있습니다.
AgentRuntime과 연결하지 않는다.
API/SSE endpoint를 만들지 않는다.
Tool 실행과 연결하지 않는다.
session branch/fork를 만들지 않는다.
DB를 붙이지 않는다.
특히 session branching은 나중에 중요해질 수 있습니다.
Git branch처럼 이전 시점에서 다른 방향으로 이어가는 기능입니다.
하지만 지금 필요한 것은 branch가 아닙니다.
이번 단계에서는 append와 load만 단단하게 만듭니다.
replay 가능한 기록을 남길 수 있으면 충분합니다.
테스트한 것
이번 PR에서 핵심으로 본 테스트는 다음입니다.
- session 생성 시 session.json, messages.jsonl, events.jsonl 생성
- message append/load 순서 보존
- event append/load 순서 보존
- flat JSONL 포맷 유지
- payload/json wrapper가 생기지 않음
- kind/type으로 concrete record 복원
- 알 수 없는 kind/type 실패
- kind/type 누락 실패
- kind/type이 문자열이 아니면 실패
- malformed JSONL 실패
- 빈 JSONL line skip
- JSONL line separator는 \n 사용
- 없는 session 조회는 Optional.empty
- 없는 session에 append/load 시 실패
- 다른 sessionId를 가진 message/event append 거부
- sessionId path traversal 차단
- 깨진 session.json은 실패
- Spring context에서 실제 SessionStore Bean round-trip 확인
마무리
M3까지 와도 아직 Agent가 똑똑해진 것은 아닙니다.
하지만 중요한 기반이 생겼습니다.
대화와 작업 과정을 기록할 수 있다.
기록할 수 있으면 다시 읽고 테스트하고 디버깅할 수 있습니다.
코딩 에이전트는 모델 호출만으로 만들어지지 않습니다.
모델이 무엇을 했는지 남기는 기록이 있어야 합니다.
M3는 그 기록을 남기는 단계입니다.
다음 단계에서는 이 기록 위에 안전한 workspace 접근을 올릴 수 있습니다.
[codex] Implement M3 JSONL session store by dd3ok · Pull Request #6 · dd3ok/pi-spring-ai
'개발 > AI' 카테고리의 다른 글
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (5) - 읽기 Tool 달아주기 (0) | 2026.06.30 |
|---|---|
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (4) - 파일 읽기 전 안전 장치 (0) | 2026.06.29 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (2) - 상태 전이를 위한 타입 정의 (0) | 2026.06.26 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (1) - chat 붙이기 (0) | 2026.06.26 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (0) - init (0) | 2026.06.26 |