
프롬프트에서 영속성까지 (파트 2): 멀티 테넌트 에이전트 메모리 스키마 활용하기
요약
멀티 테넌트 환경에서 에이전트의 단기·장기·공유 메모리를 관리하기 위한 스키마 설계와 운영 방식을 다룹니다. 메모리 매니저를 통해 테넌트 격리, 버전 관리, 데이터 출처를 제어하며 효율적인 에이전트 루프를 구축하는 방법을 설명합니다.
핵심 포인트
- 메모리 매니저를 통한 테넌트 컨텍스트 및 버전 관리 강제
- 범위(scope) 기반의 공유 메모리 구현 방식
- 단일 DB 엔진을 활용한 테넌트 안전 검색 및 보안 강화
- RLS를 이용한 데이터 격리 및 출처 기반의 데이터 관리
멀티 테넌트 메모리 스키마 기반의 검색(Retrieval), 공유 및 에이전트 루프
핵심 요약
- 파트 2는 스키마를 에이전트 메모리를 위한 운영체제(OS)로 변환합니다. 단기 메모리(short-term memory), 내구성이 있는 장기 메모리(durable long-term memory), 공유 메모리(shared memory), 그리고 검색(retrieval)이 에이전트 루프(agent loop) 내에서 어떻게 함께 작동하는지 설명합니다.
- 메모리 매니저(Memory Manager)가 제어 지점입니다. 에이전트는 메모리 테이블에 직접 기록해서는 안 됩니다. 매니저는 테넌트 컨텍스트(tenant context), 출처(provenance), 중복 제거(deduplication), 버전 관리(versioning), 삭제, 그리고 타입화된 읽기 및 쓰기(typed reads and writes)를 강제합니다.
- 공유 메모리는 별도의 테이블이 아니라 범위(scope) 기반입니다. 메모리는
agent_id IS NULL과 같이 테넌트 내부에서 충분히 광범위하게 작성될 때 공유 상태가 되며, 이를 통해 여러 에이전트가 안전하게 읽고 업데이트할 수 있습니다. - 단일 데이터베이스 엔진이 테넌트 안전 검색을 단순화합니다. 이 글은 정책(policies), 페르소나(personas), 엔티티(entities), 요약(summaries), 워크플로(workflows), 툴박스 데이터(toolbox data), 지식 베이스 청크(knowledge base chunks), 그리고 대화 이벤트(conversation events)를 하나의 엔진에 유지함으로써 시스템 간 보안 및 삭제 리스크를 줄인다고 주장합니다.
Part 1에서는 내구성이 있는 계층(durable layer)을 설계했습니다. 여기에는 8개의 타입 지정 메모리 테이블(typed memory tables)이 포함되며, 각 테이블은 동일한 4개의 범위(scope) 컬럼(tenant_id, user_id, agent_id, thread_id)과 동일한 생명주기(lifecycle) 컬럼(version, valid_from, valid_until, deleted_at, source_event_id)을 가집니다. 테넌트 격리(Tenant isolation)는 행 수준 보안 (RLS, Row-Level Security)을 통해 데이터베이스 내에서 이루어집니다. 나머지 세 가지 범위 차원은 애플리케이션에서 제공하는 필터입니다. 모든 내구 데이터 행에 기록된 출처(Provenance) 정보 덕분에 버전 기반의 대체(versioned supersession)와 증명 가능한 잊힐 권리(right-to-forget)의 연쇄 삭제(cascade)가 가능해집니다. 아직 읽어보지 않으셨다면 거기서부터 시작하십시오. 이 포스트에서 구축할 시스템은 그 모든 것을 전제로 하기 때문입니다.
스키마 자체는 아무것도 수행하지 않습니다. 스키마는 무엇을 저장할 수 있고 어떻게 격리되는지를 정의할 뿐, 행을 검색하거나 순위를 매기지 않으며, 무엇을 유지할 가치가 있는지 결정하지도 않습니다. 이 포스트는 이를 수행하는 코드에 관한 것입니다. 우리는 내구 테이블 앞에 위치한 단기 계층(short-term layer)부터 시작하여, 공유 메모리(shared memory), 전체 스키마의 전면에 있는 메모리 매니저(Memory Manager), 하나의 엔진이 가능하게 하는 단일 검색 쿼리(retrieval query), 그리고 특정 단계에서 읽기와 쓰기를 특정 테이블에 연결하는 에이전트 루프(agent loop) 순으로 살펴볼 것입니다.
멀티 테넌트 환경에서의 단기 메모리 (Short-term memory)
단기 메모리 (STM, Short-term memory)는 장기 메모리 (LTM, Long-term memory)와 구조적으로 다릅니다. STM은 설계상 일시적(ephemeral)이며 현재 실행(run)에 한정된 범위를 가지며, 대부분은 데이터베이스 외부에서 존재합니다. 하지만 STM은 잘 정의된 접점(seams)을 통해 LTM과 상호작용하며, 멀티 테넌트 시스템에서 이러한 접점은 내구 계층과 동일한 규율이 필요합니다.
작업 메모리 (Working memory): LLM 컨텍스트 윈도우 (Context Window) + 세션 메모리 (Session Memory)
LLM 컨텍스트 윈도우 (Context Window)는 작업 메모리 (Working memory) 중 가장 눈에 보이는 부분으로, 이번 턴에 모델로 전달되는 토큰들입니다. 메모리 매니저 (Memory Manager)는 영속 계층 (Durable layer: 활성 가이드라인, 활성 페르소나, 검색된 엔티티, 검색된 요약)과 휘발성 꼬리 (Volatile tail: 이 스레드의 마지막 N개 대화 이벤트)를 결합하여 이를 구성합니다. 이 구성 과정 중 데이터베이스 수준에서 이루어지는 것은 아무것도 없습니다. 데이터베이스는 입력값을 제공하고, 매니저는 이를 모델이 실제로 보게 될 프롬프트 (Prompt)로 합성합니다.
세션 메모리 (Session memory)는 프로세스 내의 스크래치패드 (Scratchpad)입니다. 도구 호출 (Tool call) 결과, 중간 추론 상태 (Intermediate reasoning state), 에이전트가 아직 표면화하지 않기로 결정한 검색 후보군 (Retrieval candidates), 에이전트가 동일한 실행 내에서 나중에 참조하고 싶어 할 수 있는 부분적인 작업 등이 여기에 해당합니다. 단일 테넌트 (Single-tenant) 프로토타입에서는 이를 AgentSession 객체의 Python 딕셔너리 (dict)에 간단히 저장할 수 있습니다. 멀티 테넌트 (Multi-tenant) SaaS의 경우, 문제는 이를 어디에 영속화할 것인가, 그리고 과연 영속화해야 하는가입니다.
class AgentSession:
def __init__(self, tenant_id, user_id, agent_id, thread_id, run_id, memory):
# Identity
...
멀티 테넌트 SaaS에서 세션 메모리를 영속화해야 하는 이유는 크래시 복구 (Crash recovery) 때문입니다. 에이전트 프로세스가 실행 도중 종료되더라도 사용자가 처음부터 다시 시작할 필요는 없어야 합니다. 대화 메모리 (Conversation memory)가 이미 모든 것을 영속적으로 기록하고 있으므로, 크래시 발생 후 이를 통해 세션 상태 (Session state)를 가져올 수 있습니다. 효과적인 패턴은 세션 메모리를 event_type = 'session_state'로 설정하고 run_id를 키로 사용하는 conversation_memory 이벤트로 기록하는 것입니다. 크래시 이후의 읽기 작업은 해당 run_id에 대한 가장 최근의 상태 이벤트로부터 스크래치패드를 복구합니다. 별도의 특수한 보존 클래스 (Retention class)는 필요하지 않습니다. 이러한 이벤트들은 다른 모든 대화 기록과 동일한 생명주기 (Lifecycle)를 따릅니다.
이는 세션 메모리 (Session memory)를 대화 메모리 (Conversation memory)와 아키텍처적으로 일관되게 유지해 줍니다 (동일한 테이블, 스코프 컬럼 (Scope columns), RLS 정책 (RLS policy), 그리고 리텐션 스윕 (Retention sweep) 사용). 대화 메모리에 기록된 모든 것은 어차피 영구적이므로, 이것은 새로운 저장 클래스 (Storage class)를 추가하는 것이 아니라 기존의 리텐션 생명주기 (Retention lifecycle)를 사용하는 것입니다. retention_class 컬럼은 레코드가 얼마나 오래 유지되는지 (short, standard, 또는 audit 윈도우)를 제어하며, 매일 밤 수행되는 스윕 (Sweep) 작업이 기간이 만료된 파티션 (Partitions)을 삭제합니다. 'short'로 태그된 세션 상태는 리텐션 윈도우가 닫힌 후 자동으로 정리됩니다.
시맨틱 캐시 (Semantic Cache)
시맨틱 캐시 (Semantic cache)는 작업 메모리 (Working Memory)와 장기 메모리 (Long-Term Memory) 사이에 위치하며, 최근 대화 기록에 대한 벡터 인덱스 (Vector index)입니다. 이 패턴은 특정 실패 모드 (Failure mode)를 처리합니다: 사용자가 대화 초반의 내용을 언급했지만, 해당 내용이 휘발성 꼬리 윈도우 (Volatile-tail window)에서 이미 벗어났을 때, 그리고 그 맥락이 엔티티 (Entity)나 요약 메모리 (Summarization memory)로 승격될 만큼 중요하지는 않을 때 발생합니다. 순수하게 최근 턴 (Recent-turn)만을 검색하면 이를 놓치지만, 전체 대화 기록에 대한 시맨틱 검색 (Semantic search)을 수행하면 이를 찾아낼 수 있습니다.
구현 측면에서 시맨틱 캐시는 conversation_memory에 대한 파생된 프로젝션 (Derived projection)입니다. 두 가지 유효한 형태가 있습니다:
스레드별 (Per-thread, 좁은 범위). (tenant_id, thread_id)로 필터링된 conversation_memory 행들에 대한 벡터 인덱스입니다. 동일한 대화 내에서 맥락 드리프트 (Context drift)가 발생하는 실패 모드를 가진 장기 실행 스레드에 유용합니다.
사용자별 최근 기록 (Per-user-recent, 넓은 범위). (tenant_id, user_id) 및 created_at > NOW() - INTERVAL '30 days'로 필터링된 conversation_memory 행들에 대한 벡터 인덱스입니다. 스레드 간 연속성 ("지난주에 다른 작업을 할 때 이 말씀을 하셨죠")을 유지하는 데 유용합니다.
SaaS 환경에서는 사용자가 긴 스레드 케이스를 접하는 것보다 세션과 스레드를 넘나들며 다시 돌아오는 경우가 더 많기 때문에, 사용자별 최근 기록 (Per-user-recent) 형태가 더 유용합니다. 두 형태는 공존할 수 있으며, 매니저 (Manager)는 쿼리 의도 (Query intent)에 따라 하나를 선택합니다.
시맨틱 캐시 (Semantic cache)는 자체적인 테이블이 필요하지 않습니다. 이는 기존 테이블에 대한 벡터 인덱스 (Vector index)이며, 검색 결과 (Hits)를 프롬프트의 휘발성 테일 (Volatile tail)과 어떻게 융합할지 아는 검색 함수 (Retrieval function)가 추가된 형태입니다. 검색 융합 (Retrieval fusion)은 별도의 심층 분석이 필요할 만큼 중요한 주제이지만, 이 포스트에서는 스키마를 다음과 같이 간단히 정의하겠습니다: CREATE VECTOR INDEX idx_conv_semantic ON conversation_memory (embedding) … on a conversation_memory 테이블. 이 테이블은 휘발성 테일 윈도우 (Volatile-tail window)보다 오래된 이벤트들에 대해 데이터가 채워진 임베딩 (Embedding) 컬럼을 가지고 있습니다.
공유 메모리 (Shared Memory) 및 조정 (Coordination)
이 두 가지는 Oracle 블로그 분류 체계에서 최상위 카테고리에 해당하는데, 이는 유형 계층 구조(Type hierarchy)를 가로지르는 관심사이기 때문입니다. 단일 에이전트 (Single-agent) 시스템에서는 일반적인 장기 메모리 (LTM) 스코핑 (Scoping)으로 통합됩니다. 하지만 멀티 에이전트 (Multi-agent) 및 멀티 테넌트 (Multi-tenant) 시스템에서는 별도의 처리가 필요합니다.
공유 메모리 (Shared Memory)
공유 메모리는 동일한 액세스 경계 (Access boundary) 하에서 여러 에이전트가 읽고 쓰는 모든 LTM 행 (Row)을 의미합니다. 구조적 정의는 스코프 (Scope) 컬럼에서 직접 도출됩니다. 즉, 행이 단일 에이전트보다 더 넓은 스코프에 위치할 때 공유되는 것으로 간주하며, 이는 agent_id IS NULL (테넌트 내의 모든 에이전트 간 공유) 또는 agent_id = '<group_id>' (정의된 조정 그룹 간 공유)를 의미합니다.
단일 SaaS 테넌트 내에서 이를 구체화하기 위한 몇 가지 예시는 다음과 같습니다:
- 지원 에이전트(support agent)가 "Acme의 프로덕션 데이터베이스가 us-east-1에서 eu-west-1으로 이동했습니다."라는 사실을 학습합니다. 이를
(tenant_id = 'acme', user_id = NULL, agent_id = NULL)위치의 entity_memory 행으로 작성하면, Acme를 서비스하는 모든 에이전트가 이 정보를 볼 수 있게 됩니다. 지원 에이전트가 무엇을 학습하든, 해당 테넌트의 모든 에이전트가 이를 사용할 수 있습니다. 테넌트 전체에 걸친 복리 효과(Compounding advantage)를 제공합니다. - 결제 에이전트(billing agent)와 지원 에이전트(support agent)가 환불 요청 승인 여부에 대해 협력해야 합니다. 이 결정 사항은
(tenant_id = 'acme', user_id = :user_id, agent_id = NULL)위치의 summarization_memory에 저장되며, 여기서 두 에이전트 모두 이를 읽을 수 있고 어느 한쪽의 에이전트가 독점적으로 소유하지도 않습니다. - 연구 보조 에이전트(research-assistant agent)가 "경쟁사 논문을 평가하는 방법"과 같은 워크플로(workflow)를 유지하며, 이는 Acme가 생성하는 모든 연구 보조 에이전트가 사용할 수 있어야 합니다. 이 워크플로 행은
(tenant_id = 'acme', agent_id = NULL)에 위치하여 어떤 에이전트 인스턴스라도 이를 찾을 수 있습니다.
공유 메모리(Shared memory)를 위해 새로운 테이블 유형이 필요한 것은 아닙니다. 이는 쓰기 측면의 규율(write-side discipline)의 문제입니다. 즉, 후보를 LTM(Long-Term Memory)으로 승격시킬 때, 매니저(manager)는 실제로 정확한 가장 좁은 범위를 선택해야 하며, 여기서 "실제로 정확하다"는 것은 때때로 해당 내용을 작성한 에이전트보다 더 넓은 범위를 의미할 수 있습니다. 이러한 결정은 "From RAG to Memory Systems: Building Stateful AI Architecture"의 승격 게이트(promotion gate)에서 이루어져야 합니다. 기본적으로는 사용자 범위(user scope)를 따르되, 사실의 주체가 테넌트 엔티티(tenant entity) 자체이고 해당 사실이 두 개의 독립적인 소스로부터 관찰되었을 때만 테넌트 범위(tenant scope)로 승격하십시오.
공유 메모리 (shared memory)에 필요한 것은 읽기 측 조정 (read-side coordination)입니다. 두 에이전트가 동일한 대상에 대해 동시에 사실을 기록할 경우, 쓰기 작업이 겹치면 모순되는 행 (rows)이 생성될 수 있습니다. 상위 대체 패턴 (supersession pattern)이 해결 방식(버전 기반의 나중에 작성된 데이터 승리)을 처리하지만, 에이전트 스스로가 서로의 작업 위에 덮어쓰기를 하고 있을 수 있다는 점을 인지해야 합니다. 가장 간단한 패턴은 낙관적 동시성 제어 (optimistic concurrency)입니다. 매니저의 supersede_fact 메서드는 대체되는 행이 호출자가 읽기 시점에 확인했던 버전과 여전히 일치하는지 확인하며, 일치하지 않을 경우 재시도 가능한 충돌 (retry-able conflict)을 발생시킵니다.
조정 (Coordination)
이 포스트에서는 조정 메모리 (coordination memory)를 위한 스키마를 정의하지 않는데, 그 이유는 설계 영역이 여전히 매우 광범위하기 때문입니다. 이미 많은 다양한 패턴이 등장했으며 새로운 패턴들도 여전히 실험되고 있습니다. 적절한 형태는 메모리 레이어만큼이나 오케스트레이션 런타임 (orchestration runtime)에 따라 달라집니다. 명시적으로 언급할 가치가 있는 점은 조정 (coordination)이 공유 메모리 (shared memory)와는 별개의 관심사라는 것입니다. 공유 메모리는 "두 에이전트가 동일한 행을 읽는 것"입니다. 조정은 "에이전트 A가 에이전트 B에게 무언가 발생했음을 알리는 것"입니다.
메모리 매니저 (Memory Manager): 스키마로 들어가는 단 하나의 문
스키마는 계약 (contract)입니다. 메모리 매니저 (Memory Manager)는 이 스키마를 건드릴 수 있는 유일한 코드입니다. 모든 읽기와 쓰기는 매니저를 거치며, 매니저는 스키마가 강제할 수 없는 사항들을 집행합니다. 모든 작업은 테넌트 컨텍스트 (tenant context)를 포함하고, 모든 영구적 쓰기 (durable write)는 출처 (provenance)를 포함하며, 데이터는 그 유형과 일치하는 테이블에 저장되고, 상위 대체 (supersession)는 오류가 발생하기 쉬운 다단계 프로세스 대신 하나의 트랜잭션 내에서 실행됩니다.
인터페이스는 작으며, 그것이 감싸고 있는 타입 지정 테이블 (typed tables)과 정확히 동일한 형태를 가집니다. 8개의 쓰기 메서드 (write methods), 8개의 읽기 메서드 (read methods), 대체 가능한 타입 (supersedable type)당 하나의 상위 대체 (supersession) 메서드, 그리고 하나의 삭제 메서드 (delete method)로 구성됩니다. 반드시 고수해야 할 단 하나의 규칙은 스키마를 우회하는 탈출구 (escape hatch)를 만들지 않는 것입니다. 왜냐하면 일단 탈출구가 생기는 순간, 이를 발견하는 모든 팀이 그것을 사용하게 될 것이기 때문입니다.
class MemoryManager:
"""스키마로 들어가는 단 하나의 문. 테넌트 컨텍스트 (Tenant context)는 scope_ctx에서 가져오며 RLS에 의해 강제됩니다; 매니저는 출처 (provenance), 중복 제거 (dedup), 임베딩 (embedding) 등을 추가합니다.
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기