Mastra Working Memory + Zod 스키마로 에이전트 사용자 상태를 구조적으로 추적하기
AI 에이전트를 처음 만들어보면 공통적으로 겪는 좌절이 있습니다. 분명 지난 대화에서 "저 서울에 살아요"라고 했는데, 다음 요청에서 에이전트가 마치 처음 보는 사람처럼 대답할 때입니다. 전체 대화 이력을 컨텍스트에 매번 다 넣으면 토큰 비용이 급격히 올라가고, 그렇다고 안 넣으면 에이전트는 기억을 잃습니다. 저도 이 문제를 해결하려고 직접 상태 관리 레이어를 붙여봤다가 꽤 고생했는데, Mastra의 Working Memory가 이 문제를 꽤 깔끔하게 풀어줍니다.
이 글에서는 Mastra Working Memory의 schema 옵션에 Zod를 연결해 에이전트가 사용자 상태를 구조적으로 추적하게 만드는 방법을 다룹니다. 단순한 API 소개가 아니라, 스키마 설계 시 어떤 선택이 어떤 결과를 낳는지, 실무에서 자주 맞닥뜨리는 상황을 중심으로 이야기해보려 합니다. 이 글을 읽고 나면 에이전트가 "저번에 말씀하신 마감이 이번 주 금요일이었죠?"처럼 자연스러운 연속성을 만드는 구조를 직접 설계할 수 있게 됩니다.
TypeScript에 익숙하고 AI 에이전트를 실제 서비스에 붙여보려는 개발자라면 바로 활용할 수 있는 내용으로 채웠습니다. Mastra를 처음 접한다면 핵심 개념 섹션부터, 이미 기본 사용법을 알고 있다면 실전 적용 섹션부터 봐도 됩니다.
핵심 개념
Working Memory가 대화 이력과 다른 이유
Mastra는 TypeScript-first로 설계된 AI 에이전트 프레임워크로, Gatsby 창업자가 참여해 만든 프로젝트입니다. LangChain의 Python 중심 생태계와 달리 메모리 계층이 프레임워크 수준에서 통합되어 있다는 게 특징입니다.
Mastra의 메모리 시스템은 크게 네 계층으로 나뉩니다.
| 계층 | 역할 | 특징 |
|---|---|---|
| Conversation History | 최근 N개 메시지 보관 | 컨텍스트 윈도우 직접 주입 |
| Semantic Recall | 임베딩 기반 과거 메시지 검색 | 벡터 DB 연동 필요 (이 글에서 다루지 않음) |
| Working Memory | 구조화된 사용자 상태 | 스키마 정의, JSON 병합 |
| Observational Memory | 대화 압축·요약 저장 | Observer 에이전트가 처리 (이 글에서 다루지 않음) |
Working Memory는 "지금 이 사용자에 대해 에이전트가 알아야 할 핵심 상태"를 저장하는 레이어입니다. 대화 이력과 다른 점은, 메시지 전체를 저장하는 게 아니라 에이전트가 명시적으로 "이건 기억해둬야 해"라고 판단한 구조화된 데이터만 보관한다는 겁니다.
Working Memory: 에이전트가 대화 턴 사이에 유지해야 할 핵심 상태를 JSON 형태로 저장하는 메커니즘. 매 요청마다 시스템 프롬프트에 자동으로 주입되어 에이전트가 사용자 컨텍스트를 즉시 참조할 수 있습니다.
Schema 모드 vs Template 모드 — 이 차이가 핵심입니다
Working Memory를 설정하는 방법은 두 가지인데, 이 선택이 에이전트의 메모리 업데이트 방식 전체를 바꿉니다.
| 모드 | 설정 방법 | 업데이트 시맨틱 | 적합한 상황 |
|---|---|---|---|
| Schema 기반 | schema: z.object({...}) |
Merge — 변경 필드만 제공, 나머지 보존 | 점진적으로 쌓이는 사용자 프로필 |
| Template 기반 | template: "..." 문자열 |
Replace — 매번 전체 내용 새로 제공 | 단순 텍스트 노트 수준의 메모리 |
솔직히 처음엔 "그냥 template 써도 되는 거 아닌가?" 싶었는데, 실제로 써보면 차이가 확 납니다. Template 모드에서는 에이전트가 메모리를 업데이트할 때 기존 내용을 전부 포함해서 반환해야 합니다. 깜빡하면 이전 데이터가 통째로 사라집니다. Schema 모드에서는 { goals: [...새항목] }만 반환해도 userContext 같은 다른 필드는 자동으로 살아남습니다.
Zod 스키마를 주입하면 무슨 일이 벌어지나
import { Memory } from "@mastra/memory";
import { z } from "zod";
const memory = new Memory({
options: {
workingMemory: {
enabled: true,
schema: z.object({
name: z.string().optional(),
location: z.string().optional(),
preferences: z.object({
communicationStyle: z.enum(["formal", "casual"]).optional(),
projectGoal: z.string().optional(),
}).optional(),
activeDeadlines: z.array(z.object({
label: z.string(),
date: z.string(),
})).optional(),
lastTopicDiscussed: z.string().optional(),
}),
},
},
});schema 필드에 Zod 객체를 넣으면 Mastra가 내부적으로 이 스키마를 JSON Schema로 변환합니다. 그리고 에이전트 시스템 프롬프트에 아래와 같은 형태로 주입됩니다.
# Working Memory (Current State)
{
"name": null,
"location": null,
"preferences": null,
"activeDeadlines": [],
"lastTopicDiscussed": null
}
대화에서 사용자 정보를 파악하면 위 JSON 구조에 맞춰 업데이트하세요.
변경이 필요한 필드만 포함해도 됩니다 — 나머지는 자동으로 보존됩니다.에이전트는 대화 중 사용자 정보를 파악하면 해당 구조에 맞는 JSON을 생성해 working memory를 업데이트하고, 다음 요청부터는 그 상태를 시스템 프롬프트에서 바로 참조합니다.
TypeScript 입장에서 보면, z.infer<typeof schema> 타입이 자동으로 생성되어 메모리 접근 시 타입 안전성을 보장받을 수 있습니다.
const userProfileSchema = z.object({
name: z.string().optional(),
location: z.string().optional(),
});
type UserProfile = z.infer<typeof userProfileSchema>;
// → { name?: string | undefined; location?: string | undefined }
// 이 타입으로 메모리 조회 결과를 안전하게 다룰 수 있습니다Zod: TypeScript-first 스키마 선언 및 유효성 검사 라이브러리. 런타임 유효성 검사와 TypeScript 타입 추론을 동시에 제공합니다. Mastra는 Zod v3/v4 모두 지원하며, JSON Schema 형식 스키마도
schema필드에 직접 사용할 수 있습니다.
실전 적용
예시 1: 사용자 프로필 기반 개인화 에이전트
가장 전형적인 패턴입니다. 사용자의 기본 정보와 현재 세션 컨텍스트, 진행 중인 목표를 함께 추적합니다.
import { Agent } from "@mastra/core";
import { Memory } from "@mastra/memory";
import { z } from "zod";
const userProfileSchema = z.object({
profile: z.object({
name: z.string().optional(),
timezone: z.string().optional(),
preferredLanguage: z.string().optional(),
}),
currentSession: z.object({
topic: z.string().optional(),
openQuestions: z.array(z.string()).default([]),
}),
goals: z.array(z.object({
description: z.string(),
deadline: z.string().optional(),
status: z.enum(["active", "completed", "paused"]),
})).default([]),
});
type UserProfile = z.infer<typeof userProfileSchema>;
const agent = new Agent({
name: "PersonalAssistant",
instructions: `당신은 사용자의 개인 어시스턴트입니다.
대화에서 아래 정보를 파악하면 즉시 working memory를 업데이트하세요:
- 사용자 이름, 시간대, 선호 언어 → profile 필드
- 현재 대화 주제나 미해결 질문 → currentSession 필드
- 사용자가 언급한 목표나 마감 → goals 배열에 추가
goals에 새 항목을 추가할 때는 기존 항목을 포함한 전체 배열을 반환하세요.
working memory에 이미 있는 정보는 사용자에게 다시 묻지 말고 자연스럽게 활용하세요.`,
memory: new Memory({
options: {
workingMemory: {
enabled: true,
schema: userProfileSchema,
scope: "resource", // 동일 사용자의 모든 스레드에서 상태 공유
},
},
}),
});
const response = await agent.generate("안녕하세요, 저는 김민수입니다.", {
resourceId: "user-kim-minsu",
threadId: crypto.randomUUID(),
});| 포인트 | 설명 |
|---|---|
scope: "resource" |
동일 resourceId를 가진 모든 스레드가 같은 working memory를 참조 |
.default([]) |
배열 필드에 기본값을 설정해 첫 업데이트 전에도 타입 안전하게 접근 가능 |
optional() |
에이전트가 아직 파악 못한 필드는 undefined로 두고 점진적으로 채워나감 |
instructions |
어떤 정보를 언제 어떻게 기억할지 명시하지 않으면 LLM이 자율적으로 업데이트하지 않을 수 있음 |
이 패턴의 장점은 에이전트가 "김민수님, 저번에 말씀하신 마감이 이번 주 금요일이었죠?"처럼 자연스러운 연속성을 만들 수 있다는 겁니다. scope: "resource" 덕분에 고객 지원 스레드에서 알게 된 정보를 추천 스레드에서도 그대로 활용할 수 있습니다.
예시 2: 태스크 관리 에이전트 — Merge 시맨틱 제대로 활용하기
(이후 예시에서는 import 구문을 생략합니다 — 예시 1과 동일합니다.)
const taskSchema = z.object({
tasks: z.array(z.object({
id: z.string(),
title: z.string(),
priority: z.enum(["high", "medium", "low"]),
done: z.boolean().default(false),
createdAt: z.string(),
})).default([]),
userContext: z.object({
focusArea: z.string().optional(),
workingHours: z.string().optional(),
preferredNotification: z.enum(["email", "slack", "none"]).optional(),
}).optional(),
});
const taskAgent = new Agent({
name: "TaskManager",
instructions: `태스크를 추가하거나 상태를 변경할 때는 해당 필드만 업데이트하세요.
tasks 배열에 새 항목을 추가할 때는 기존 항목을 포함한 전체 배열을 반환하세요.
userContext는 명시적으로 변경 요청이 없는 한 업데이트하지 마세요.`,
memory: new Memory({
options: {
workingMemory: {
enabled: true,
schema: taskSchema,
},
},
}),
});여기서 실무적으로 중요한 부분이 있습니다. Merge 시맨틱이라고 해도 tasks 같은 배열 필드는 에이전트가 기존 배열을 포함한 전체를 반환해야 올바르게 업데이트됩니다. 단순히 새 항목 하나만 반환하면 기존 배열을 덮어씁니다. 이걸 instructions에 명시적으로 안내해두는 게 좋습니다.
const result = await taskAgent.generate(
"내일 오후 3시까지 API 문서 작성해야 해. 우선순위 높음으로 추가해줘.",
{
resourceId: "user-dev",
threadId: crypto.randomUUID(),
}
);
// 에이전트가 내부적으로 업데이트하는 구조 (예시)
// {
// tasks: [
// ...기존태스크,
// {
// id: crypto.randomUUID(),
// title: "API 문서 작성",
// priority: "high",
// done: false,
// createdAt: "2026-05-21"
// }
// ]
// // userContext는 건드리지 않음 → 기존 값 자동 보존
// }예시 3: 멀티에이전트 메모리 공유
고객 지원 봇과 제품 추천 봇이 별개로 운영되더라도, 동일한 Memory 설정과 동일한 resourceId를 사용하면 스토리지에서 같은 레코드를 공유할 수 있습니다.
const sharedMemory = new Memory({
options: {
workingMemory: {
enabled: true,
schema: z.object({
customerProfile: z.object({
name: z.string().optional(),
purchaseHistory: z.array(z.string()).default([]),
preferences: z.array(z.string()).default([]),
}),
currentIssue: z.object({
description: z.string().optional(),
status: z.enum(["open", "resolved", "escalated"]).optional(),
}).optional(),
}),
},
},
});
const supportAgent = new Agent({ name: "SupportBot", memory: sharedMemory });
const recommendAgent = new Agent({ name: "RecommendBot", memory: sharedMemory });
// 동일 resourceId → 스토리지에서 동일한 working memory 레코드를 읽고 씀
await supportAgent.generate("제품에 문제가 있어요.", {
resourceId: "user-123",
threadId: crypto.randomUUID(),
});
await recommendAgent.generate("다른 제품 추천해주세요.", {
resourceId: "user-123", // SupportBot이 기록한 customerProfile을 그대로 참조
threadId: crypto.randomUUID(),
});여기서 중요한 점은, 두 에이전트가 sharedMemory 인스턴스를 코드 레벨에서 공유하는 것뿐 아니라, resourceId: "user-123"이 동일하기 때문에 스토리지에서 같은 레코드를 읽고 쓴다는 겁니다. 즉, 설령 두 에이전트가 다른 프로세스에서 실행되더라도 같은 스토리지를 바라보고 같은 resourceId를 사용하면 동일한 working memory를 공유하게 됩니다.
지원 봇이 대화에서 파악한 구매 이력과 선호도를 추천 봇이 별도 조회 없이 바로 활용할 수 있습니다. 마이크로서비스처럼 역할이 분리된 에이전트 시스템을 만들 때 꽤 유용한 패턴입니다.
장단점 분석
실제로 서비스에 붙여보면서 체감한 항목들로만 추렸습니다.
장점
| 항목 | 내용 |
|---|---|
| 타입 안전성 | Zod 스키마와 TypeScript 타입 추론이 연동되어 컴파일 타임에 메모리 구조 오류를 잡아냄 |
| Merge 시맨틱 | 부분 업데이트만으로 전체 상태 보존 — 실수로 데이터가 지워질 위험 감소 |
| 교차 스레드 공유 | scope: 'resource'로 동일 사용자의 모든 대화에 걸쳐 상태 공유 가능 |
| 멀티에이전트 호환 | 여러 전문 에이전트가 하나의 working memory를 공유하는 분산 아키텍처 지원 |
| Mutex 보호 | 동일 사용자에게 병렬 요청이 들어왔을 때 working memory 업데이트가 충돌 없이 순차 처리되도록 내부 잠금 처리 |
| 스토리지 유연성 | LibSQL(로컬), PostgreSQL(운영), Upstash(서버리스) 등 환경에 맞게 교체 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 스토리지 의존성 | 세션 간 데이터 유지를 위해 외부 스토리지가 반드시 필요 | 개발 단계엔 LibSQL, 운영엔 PostgreSQL로 단계적 전환 |
| Upstash 비용 | 대화량이 많으면 pay-as-you-go 모델이 예상외 비용 발생 | 사용량 알림 설정, 필요 시 Redis 자체 호스팅으로 전환 |
| 장기 대화 메모리 부패 | 대화가 매우 길어지면 working memory만으로는 부족 | Observational Memory 병행 사용 권장 |
| 스키마 진화 관리 | 운영 중 Zod 스키마 구조 변경 시 기존 저장 데이터와 호환성 문제 발생 가능 | 필드 삭제보다 optional() 추가로 하위 호환성 유지 |
| Mastra Cloud 제한 | Mastra Cloud Store 사용 시 에이전트 수준 스토리지 설정 불가 | Mastra 인스턴스 레벨에서만 스토리지 설정 |
| 멀티에이전트 중복 지시 | 여러 에이전트에 동일 메모리 수집 지시를 반복해야 하는 설계 복잡성 | 공유 instructions 템플릿 함수로 추출해 재사용 |
LibSQL: SQLite의 오픈소스 포크로, 로컬 파일 기반 DB입니다. 별도 DB 서버 없이 바로 사용할 수 있어 개발 단계 프로토타이핑에 적합합니다.
@mastra/store-libsql패키지로 연동됩니다.
실무에서 가장 흔한 실수
1. 배열 필드 Merge 오해
Schema 기반이라도 tasks 같은 배열은 에이전트가 기존 항목을 포함한 전체 배열을 반환해야 합니다. "변경 필드만" 반환이라는 개념이 배열 내부 항목 단위까지 적용되지는 않습니다. 새 항목 하나만 반환하면 기존 배열이 통째로 덮어쓰여집니다. 저도 이걸 처음에 몰라서 태스크가 계속 하나씩만 남는 버그를 한참 뒤에야 발견했습니다. instructions에 명시적으로 안내해두는 것이 좋습니다.
2. 스키마를 운영 중에 함부로 변경
이미 저장된 데이터가 있는 상태에서 Zod 스키마의 필드명을 바꾸거나 타입을 변경하면 파싱 오류가 발생합니다. 필드를 제거해야 한다면 먼저 optional()로 전환해 기존 데이터를 마이그레이션한 뒤 제거하는 순서를 거치는 것이 안전합니다.
3. resourceId 없이 호출
resourceId를 생략하면 scope: "resource" 설정이 무의미해집니다. 에이전트 호출 시 항상 사용자를 식별하는 resourceId를 명시적으로 전달하는 패턴을 팀 내 관례로 정해두는 것이 좋습니다. 래퍼 함수에 resourceId 검증을 넣어두면 누락을 원천 차단할 수 있습니다.
마치며
Working Memory에 Zod 스키마를 연결하고 나면, 에이전트를 보는 관점이 조금 달라집니다. 상태를 "어떻게 저장할까"가 아니라 "이 사용자에 대해 에이전트가 무엇을 알아야 하는가"를 먼저 설계하게 되거든요. 스키마가 에이전트의 기억 구조를 정의하고, TypeScript가 그 구조를 컴파일 타임에 보장하고, Merge 시맨틱이 데이터 손실 위험을 줄여줍니다. 기억하는 척이 아니라, 실제로 추적 가능한 방식으로요.
지금 바로 시작할 수 있는 3단계:
-
패키지 설치 및 기본 에이전트 구성 —
pnpm add @mastra/core @mastra/memory zod로 설치한 뒤, 위에서 소개한userProfileSchema예시를 그대로 복사해 로컬 LibSQL 스토리지와 함께 연결해볼 수 있습니다. 별도 DB 서버 없이도 바로 동작합니다. -
Mastra Playground에서 Working Memory 실시간 확인 — Playground UI에서 에이전트가 현재 어떤 값을 메모리에 갖고 있는지 직접 조회하고 편집할 수 있습니다. 스키마 설계 초기에 에이전트가 의도대로 필드를 채우는지 빠르게 검증해보는 데 활용해볼 수 있습니다.
-
scope: "resource"와resourceId조합 테스트 — 두 개의 별도 스레드에서 동일resourceId로 에이전트를 호출해, 첫 번째 스레드에서 알게 된 사용자 정보가 두 번째 스레드에서도 정상적으로 참조되는지 확인해볼 수 있습니다. 이 동작이 확인되면 멀티에이전트 아키텍처로 확장할 준비가 된 겁니다.
참고 자료
시작점으로 보기 좋은 자료:
- Working Memory | Mastra 공식 문서
- Example: Working Memory with Schema | Mastra
- Memory Overview | Mastra 공식 문서
더 깊이 보고 싶다면:
- Observational Memory: 95% on LongMemEval | Mastra Research
- Announcing Observational Memory | Mastra Blog
- Agent Memory System | DeepWiki (mastra-ai/mastra)
- Memory and Storage Architecture | DeepWiki
- Multi-agent systems | Mastra Concepts
- Mastra agents with memory | Trigger.dev
- Observational Memory cuts AI agent costs 10x | VentureBeat
- GitHub — mastra-ai/mastra