TypeScript AI 에이전트가 세션을 넘어 대화 맥락을 유지하는 방법 — Mastra 메모리 계층 설계
AI 에이전트를 만들다 보면 어느 순간 벽에 부딪힙니다. 지난 대화에서 사용자가 분명히 자기 이름을 말했는데, 새 세션을 열면 에이전트가 전혀 기억을 못 하죠. 이걸 해결하려고 처음엔 "그냥 RAG(Retrieval-Augmented Generation: 질문마다 벡터 검색으로 관련 문서를 끌어와 컨텍스트에 포함하는 방식) 붙이면 되는 거 아닌가?" 싶어서 시도해봤는데, 매 턴마다 벡터 검색 결과가 조금씩 달라지면서 에이전트 응답이 들쭉날쭉해지는 문제가 생겼습니다. 결국 직접 DB를 연결하고, 컨텍스트를 쑤셔 넣고, 프롬프트를 조립하는 코드를 짜다 보면 어느새 비즈니스 로직보다 메모리 관리 코드가 더 많아집니다.
Mastra는 이 문제를 프레임워크 수준에서 해결하는 TypeScript 네이티브 에이전트 프레임워크입니다. LangChain Memory가 Python 생태계 중심인 것과 달리, @mastra/memory 패키지 하나로 단기 메시지 히스토리부터 구조화된 사용자 상태 관리, 의미 기반 장기 검색, 그리고 2026년 초 공개된 압축 기반 Observational Memory까지 계층화된 전략을 제공합니다. 이 글에서는 Mastra의 네 가지 메모리 유형이 어떻게 동작하는지, 어떤 상황에 어떤 전략을 선택하면 좋은지를 실제 코드와 함께 살펴봅니다.
핵심 개념
Mastra 메모리의 2계층 스코핑 구조
Mastra 메모리를 이해하려면 먼저 resource와 thread 두 축을 짚어야 합니다.
resource: 사용자 또는 엔티티의 식별자. 동일 사용자의 모든 대화에 걸쳐 공유되는 메모리를 담습니다.thread: 개별 대화 세션의 ID. 같은 사용자라도 서로 다른 스레드는 독립적으로 관리됩니다.
await agent.generate('이전에 말한 내용 기억나?', {
memory: { resource: 'user-001', thread: 'session-abc' },
})고객 지원 챗봇이라면 resource를 고객 ID, thread를 티켓 ID로 쓰는 식입니다. "이 고객이 이전에 어떤 문의를 했는가"(resource 레벨)와 "이번 티켓의 대화 흐름"(thread 레벨)을 깔끔하게 분리해서 관리할 수 있습니다.
용어 정의
resource는 "누가"에 해당하는 식별자,thread는 "어느 대화"에 해당하는 식별자입니다. 두 값이 같으면 항상 같은 메모리 맥락에 연결됩니다.
네 가지 메모리 유형
1. Message History — 단기 기억
가장 기본적인 형태로, 최근 N개의 메시지를 컨텍스트 창에 그대로 포함시켜 LLM이 직전 대화를 참조하게 합니다. 기본값은 최근 10개이며 lastMessages로 조정할 수 있습니다.
new Memory({
options: {
lastMessages: 20,
},
})참고
lastMessages를 너무 높게 설정하면 토큰 비용이 선형으로 늘어납니다. 짧은 세션 내에서는 10~20 정도면 충분하고, 대화가 길어지는 경우라면 아래에서 소개하는 다른 유형을 함께 사용하는 것을 권장합니다.
단순하지만 짧은 세션 내에서는 충분히 강력합니다. 다만 대화가 길어지면 토큰을 순식간에 잡아먹으니 그 이상은 다른 전략이 필요합니다.
2. Working Memory — 에이전트의 포스트잇
"활성 스크래치패드"라고 생각하면 이해하기 쉽습니다. 사용자 이름, 선호도, 진행 중인 작업처럼 세션이 바뀌어도 유지돼야 하는 정보를 저장합니다. Zod 스키마를 통해 타입 검증도 됩니다.
import { z } from 'zod'
const memory = new Memory({
workingMemory: {
enabled: true,
schema: z.object({
userName: z.string().optional(),
preferredLanguage: z.string().optional(),
ongoingTask: z.string().optional(),
}),
},
})동작 방식이 처음엔 좀 낯선데, 익숙해지면 당연한 구조입니다. 에이전트가 대화 중에 Working Memory를 업데이트할 필요가 있다고 판단하면, 응답에 XML 형식의 업데이트 블록을 포함시킵니다. Mastra 런타임이 이걸 파싱해서 스토리지에 저장하고, 다음 대화부터는 현재 상태를 시스템 프롬프트에 자동으로 주입해줍니다. 즉 별도의 쓰기 API를 직접 호출하는 게 아니라, LLM의 응답 생성 과정에서 자연스럽게 업데이트가 일어납니다.
코드 생성 에이전트라면 "이 사람은 함수형 스타일을 선호하고, 현재 결제 모듈 작업 중"이라는 정보를 Working Memory에 담아두면, 다음 세션에서도 맥락 없이 처음부터 설명하지 않아도 됩니다.
3. Semantic Recall — 의미 기반 장기 기억
키워드 매칭이 아니라 의미적 유사성으로 과거 메시지를 검색합니다. "예전에 결제 오류 얘기했을 때"처럼 정확한 단어를 기억 못 해도 관련 대화를 끌어올 수 있는 게 핵심입니다.
new Memory({
options: {
semanticRecall: {
topK: 5, // 유사도 상위 5개 메시지 검색
messageRange: 2, // 검색된 메시지 전후 2개씩 포함 (맥락 보존)
scope: 'resource', // 동일 사용자의 모든 스레드에서 검색
},
},
})용어 정의
messageRange는 검색된 메시지만 가져오는 게 아니라 그 주변 맥락도 함께 가져옵니다. 단편적인 문장보다 대화의 흐름을 보존하는 데 유용합니다.
내부적으로 벡터 DB와 임베딩을 활용합니다. 기본 임베딩 모델은 OpenAI text-embedding-3 시리즈이며, 스토리지 설정에서 교체할 수 있습니다. PostgreSQL을 스토리지로 쓴다면 pgvector 확장 설치가 필요하고, LibSQL(Turso)을 쓴다면 별도 설정 없이 바로 사용할 수 있습니다.
4. Observational Memory — 압축 기반 장기 기억
솔직히 이게 가장 흥미로운 부분입니다. 2026년 2월에 공개된 신기능으로, Observer 에이전트가 백그라운드에서 오래된 대화를 자동으로 고밀도 구조화 노트로 압축합니다.
컨텍스트 창을 두 영역으로 나눕니다.
| 영역 | 내용 |
|---|---|
| 관찰 블록 | Observer 에이전트가 압축한 이전 대화의 핵심 정보 |
| 현재 세션 원본 메시지 | 압축되지 않은 최근 대화 |
미관찰 메시지가 30,000 토큰(설정 가능)에 도달하면 Observer 에이전트가 자동으로 압축을 실행합니다. LongMemEval 벤치마크에서 gpt-4.1-mini 기준 **94.87%**를 기록했습니다.
벤치마크 읽는 법 LongMemEval은 장기 대화에서 특정 사실을 올바르게 회상하는 정확도를 측정하는 벤치마크입니다. 비교 대상인 RAG 방식(GPT-4o 기준 80.05%)과는 사용 모델 규모가 다르다는 점에 주의할 필요가 있습니다. 더 작은 모델(gpt-4.1-mini)로 더 큰 모델(GPT-4o)의 RAG 방식을 앞섰다는 건 흥미롭지만, 메모리 아키텍처의 효과만을 순수하게 분리하기는 어렵습니다. 방향성은 읽을 수 있지만, 프로덕션 도입 전에는 자체 도메인에서 검증하는 것을 권장합니다.
RAG가 매 턴마다 벡터 검색을 수행해서 컨텍스트가 계속 바뀌는 것과 달리, Observational Memory는 구조가 안정적으로 유지됩니다. 이 덕분에 프롬프트 캐싱 — AI API에서 동일한 프롬프트 앞부분이 반복될 때 비용을 할인해주는 기능 — 과 궁합이 잘 맞아서 토큰 비용을 추가로 절감할 수 있습니다.
실전 적용
예시 1: 기본 메모리 설정 — 개발 환경 세팅부터
처음 시작할 때 권장하는 스택은 LibSQL입니다. 별도 DB 없이 인메모리로 바로 테스트해볼 수 있어서 진입 장벽이 낮습니다.
pnpm add @mastra/memory @mastra/libsql @ai-sdk/openaiimport { Mastra } from '@mastra/core'
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/memory'
import { LibSQLStore } from '@mastra/libsql'
import { openai } from '@ai-sdk/openai'
export const mastra = new Mastra({
storage: new LibSQLStore({
url: process.env.DATABASE_URL ?? ':memory:', // 개발 중엔 인메모리로 충분합니다
}),
})
export const assistantAgent = new Agent({
name: 'assistant',
instructions: '사용자의 정보를 기억하고 맥락에 맞게 응답하는 어시스턴트입니다.',
model: openai('gpt-4o-mini'), // Mastra는 Vercel AI SDK 어댑터로 모델을 지정합니다
memory: new Memory({
options: {
lastMessages: 20,
semanticRecall: {
topK: 5,
messageRange: 2,
scope: 'resource',
},
},
}),
})
// 첫 번째 메시지 — 사용자 정보를 전달합니다
const res1 = await assistantAgent.generate(
'내 이름은 김철수고, TypeScript 기반 풀스택 개발자야.',
{ memory: { resource: 'user-001', thread: 'session-abc' } }
)
console.log(res1.text)
// → "안녕하세요, 김철수님! TypeScript 풀스택 개발자로 활동하고 계시는군요..."
// 같은 resource + thread로 호출하면 이전 맥락을 그대로 참조합니다
const res2 = await assistantAgent.generate(
'내가 어떤 개발자였지?',
{ memory: { resource: 'user-001', thread: 'session-abc' } }
)
console.log(res2.text)
// → "TypeScript 기반 풀스택 개발자라고 하셨습니다."| 코드 포인트 | 설명 |
|---|---|
':memory:' |
개발·테스트용 인메모리 SQLite. 서버 재시작 시 데이터가 사라집니다. |
openai('gpt-4o-mini') |
Mastra는 Vercel AI SDK 어댑터 방식으로 모델을 지정합니다. @ai-sdk/openai 패키지가 필요합니다. |
resource: 'user-001' |
사용자 식별자. 로그인 시스템이 있다면 실제 user ID를 씁니다. |
thread: 'session-abc' |
대화 세션 ID. 새 채팅을 열 때마다 새 thread ID를 생성하면 됩니다. |
예시 2: 고객 지원 봇 — Working Memory로 고객 정보 유지하기
실무에서 자주 맞닥뜨리는 상황인데, 제가 이 패턴을 처음 쓸 때 실수했던 게 resource와 thread를 같은 값으로 넣은 거였습니다. 고객 ID와 티켓 ID를 분리하면 "이 고객이 이전에 어떤 문의를 했는가"(resource 레벨)와 "이번 티켓의 대화"(thread 레벨)를 깔끔하게 나눌 수 있습니다.
import { z } from 'zod'
import { openai } from '@ai-sdk/openai'
const customerMemory = new Memory({
workingMemory: {
enabled: true,
schema: z.object({
customerName: z.string().optional(),
subscriptionPlan: z.string().optional(),
previousIssues: z.array(z.string()).optional(),
preferredContactTime: z.string().optional(),
}),
},
options: {
lastMessages: 15,
semanticRecall: {
topK: 3,
messageRange: 1,
scope: 'resource', // 이 고객의 모든 이전 티켓에서 검색
},
},
})
export const supportAgent = new Agent({
name: 'support',
instructions: `고객 지원 에이전트입니다.
Working Memory에서 고객 정보를 확인하고,
이전 문의 이력을 참조하여 중복 안내를 피해주세요.`,
model: openai('gpt-4o'),
memory: customerMemory,
})
async function handleNewTicket(customerId: string, ticketId: string, message: string) {
return await supportAgent.generate(message, {
memory: {
resource: customerId, // 고객 단위 공유 메모리
thread: ticketId, // 티켓 단위 독립 대화
},
})
}
// 고객이 첫 메시지를 보냅니다
const res1 = await handleNewTicket(
'customer-001',
'ticket-2026-001',
'안녕하세요, 저는 김철수이고 Pro 플랜을 사용 중이에요. 결제 오류가 났는데요.'
)
// → 에이전트가 응답 생성 시 Working Memory에 customerName, subscriptionPlan을 자동 저장합니다
// 다른 티켓을 열어도 Working Memory에서 고객 정보를 읽어 활용합니다
const res2 = await handleNewTicket(
'customer-001',
'ticket-2026-002', // 새 티켓 ID
'또 문제가 생겼어요.'
)
// → "김철수님, Pro 플랜 관련 문의이신가요? 이전에 결제 오류 건도 있으셨는데..."이 구조에서 Working Memory에 저장된 고객 정보(subscriptionPlan, previousIssues 등)는 티켓이 바뀌어도 유지되고, Semantic Recall은 scope: 'resource'이므로 이전 티켓들의 대화 내용도 검색 대상이 됩니다.
예시 3: 장기 운영 에이전트 — Observational Memory 활성화
의료 진료 기록이나 장기 프로젝트처럼 대화가 매우 길어지는 경우에 적합합니다. 기존 generate() 호출 방식과 사용법이 완전히 동일하고, 압축은 백그라운드에서 자동으로 처리됩니다.
import { Memory } from '@mastra/memory'
import { openai } from '@ai-sdk/openai'
const longTermMemory = new Memory({
observationalMemory: {
enabled: true,
compressionThreshold: 30000, // 30,000 토큰 도달 시 자동 압축
},
options: {
lastMessages: 10,
},
})
export const medicalAgent = new Agent({
name: 'medical-assistant',
instructions: `환자의 진료 이력을 관리하는 어시스턴트입니다.
압축된 진료 기록을 참조하여 현재 증상과의 연관성을 분석합니다.`,
model: openai('gpt-4o'),
memory: longTermMemory,
})
// 호출 방식은 일반 에이전트와 동일합니다
const res = await medicalAgent.generate(
'오늘 두통이 있는데, 지난달에 처방받은 약이 뭐였죠?',
{ memory: { resource: 'patient-001', thread: 'visit-2026-05-21' } }
)
// Observer 에이전트가 이전 진료 기록을 압축해뒀다면,
// 수만 토큰 분량의 기록도 관찰 블록에서 효율적으로 참조됩니다주의 Observational Memory는 민감 정보를 장기 아티팩트로 집중 저장하는 구조입니다. 의료·금융 등 규제 환경에서는 데이터 유출 시 파급 범위가 RAG 방식보다 넓을 수 있습니다. 암호화 및 접근 제어 정책을 함께 설계하는 것을 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 구조화된 메모리 계층 | 단기(Message History) → 작업(Working Memory) → 장기(Semantic Recall / Observational) 계층이 명확히 분리되어 목적에 맞는 전략 선택 가능 |
| 비용 효율 | Observational Memory는 텍스트 5~40x 압축 + 프롬프트 캐싱 호환으로 토큰 비용 최대 10x 절감 가능 |
| 벡터 DB 없는 장기 기억 | Observational Memory는 벡터 DB 없이도 장기 맥락 유지. 인프라 복잡도 감소 |
| TypeScript 네이티브 | Zod 스키마 검증, 타입 안전 설정, Python 서버 관리 불필요 |
| 유연한 스토리지 선택 | LibSQL(개발용 포함), PostgreSQL, MongoDB, Upstash 중 환경에 맞게 선택 가능 |
단점 및 주의사항
개인적으로 가장 신경 쓰이는 항목은 보안 리스크 집중입니다. Observational Memory가 장기 아티팩트로 모든 대화를 압축·보관한다는 건, 한 군데 뚫리면 그 사용자의 수개월치 대화 히스토리가 통째로 노출된다는 뜻이기도 합니다. RAG 방식이 쿼리별로 리스크를 분산하는 구조인 것과 대비됩니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 보안 리스크 집중 | Observational Memory는 민감 정보를 장기 아티팩트로 집중 저장 → 유출 시 파급 범위 넓음 | 저장소 암호화 + 엄격한 접근 제어 설계 필요 |
| Observer 에이전트 비용 | 압축 시 백그라운드 LLM 호출 발생 → 빈번한 압축 시 추가 비용 | compressionThreshold를 충분히 높게 설정 |
| 압축 손실 가능성 | 5~40x 압축 과정에서 세부 정보가 유실될 수 있음 | 원본 보존이 필요한 규제 환경에서는 Semantic Recall 활용 권장 |
| 벤치마크 출처 | 94.87% 수치는 Mastra 자체 측정 결과, 제3자 독립 검증 부족 | 프로덕션 도입 전 자체 도메인에서 검증 필요 |
| PostgreSQL pgvector | Semantic Recall을 PostgreSQL에서 사용 시 pgvector 확장 별도 설치 필요 | CREATE EXTENSION vector; 마이그레이션 사전 준비 |
| 프레임워크 종속 | Mastra 생태계에 깊이 통합된 설계 → 다른 프레임워크로 마이그레이션 비용 높음 | 도입 전 장기 유지보수 계획 검토 권장 |
용어 보충
pgvector는 PostgreSQL에서 벡터 연산을 지원하는 확장 모듈입니다. Semantic Recall이 의미 유사도를 계산할 때 내부적으로 이 벡터 인덱스를 활용합니다.
장단점을 정리하고 나면 결국 메모리 전략 선택은 순수한 기술 결정이 아니라는 걸 알 수 있습니다. 데이터 보안 요건, 비용 구조, 팀의 인프라 운용 역량까지 함께 고려해야 합니다.
실무에서 가장 흔한 실수
resource와thread를 동일하게 설정하는 경우 — 모든 대화가 하나의 스레드로 합쳐지면서 서로 다른 맥락이 뒤섞입니다. 사용자 ID는resource에, 대화 세션 ID는thread에 별도로 넣어야 합니다.- 개발 환경의
:memory:스토리지를 프로덕션에 그대로 쓰는 경우 — 서버 재시작 시 모든 메모리가 사라집니다. 프로덕션에서는 반드시 LibSQL 파일 경로 또는 PostgreSQL로 변경할 필요가 있습니다. - Semantic Recall만 믿고 Working Memory를 생략하는 경우 — 사용자 이름, 선호도처럼 항상 참조해야 하는 정보는 벡터 검색으로 매번 끌어오는 것보다 Working Memory에 구조화해서 저장하는 쪽이 훨씬 안정적이고 비용도 적게 듭니다.
마치며
메모리 설계는 "나중에 바꾸지 뭐" 하기 어려운 아키텍처 결정입니다. resource와 thread 계층을 처음부터 제대로 잡지 않으면 사용자 데이터가 뒤섞이거나 의도치 않게 다른 사용자의 맥락이 흘러드는 문제가 생깁니다. 메모리 계층 설계를 에이전트 설계의 일부로 처음부터 고려하는 것이, 나중에 데이터가 쌓인 후 리팩토링하는 것보다 훨씬 적은 비용이 듭니다. 지금 당장 계층 구조를 잡아두는 것을 권장합니다.
처음 시도해보신다면 아래 순서로 접근해볼 수 있습니다.
pnpm add @mastra/memory @mastra/libsql @ai-sdk/openai로 의존성을 추가한 뒤,url: ':memory:'인메모리 스토리지로resource+thread조합을 테스트해볼 수 있습니다. 세션이 바뀌어도 맥락이 유지되는 걸 직접 확인하는 것부터 시작해볼 수 있습니다.- Working Memory에 Zod 스키마를 정의해서 사용자 선호도나 진행 상황 같은 구조화된 정보를 저장해보시면 좋습니다. Message History만 쓸 때와 얼마나 다른지 바로 체감됩니다.
- 대화가 충분히 쌓인 후 Semantic Recall을 활성화해서, 키워드가 다른 표현으로 같은 주제를 물어봤을 때 과거 맥락이 잘 검색되는지 확인해볼 수 있습니다. 어느
topK와messageRange값이 본인 도메인에 맞는지 감이 잡힐 겁니다. - 대화가 30,000 토큰 이상 쌓이는 시나리오를 시뮬레이션해서 Observational Memory의 압축 결과를 직접 확인해볼 수 있습니다. 어떤 정보가 관찰 블록에 남고 어떤 정보가 사라지는지 보면, 도메인에 맞는
compressionThreshold기준이 잡힙니다.
참고 자료
- Agent memory | Mastra 공식 문서
- Memory overview | Mastra 공식 문서
- Observational Memory | Mastra 공식 문서
- Working memory | Mastra 공식 문서
- Semantic recall | Mastra 공식 문서
- Storage | Mastra 공식 문서
- Observational Memory Research | Mastra
- Using Mastra's Agent Memory API | Mastra Blog
- 'Observational memory' cuts AI agent costs 10x | VentureBeat
- How Mastra's Observational Memory Beats RAG | Techbuddies Studio
- Mastra AI: The Complete Guide to the TypeScript Agent Framework (2026)
- Mastra in 2026: What It Is, When to Use It | DEV Community
- Memory System Architecture | DeepWiki
- State of AI Agent Memory 2026 | mem0
- @mastra/memory | npm
- GitHub - mastra-ai/mastra