
멀티테넌시(Multi-Tenancy)는 에이전트 플랫폼의 진정한 문제다
요약
에이전트 플랫폼 구축 시 데모 수준을 넘어 실제 서비스로 전환할 때 직면하는 멀티테넌시(Multi-Tenancy) 문제의 중요성을 다룹니다. 데이터, 도구, 메모리, 비용 등 모든 리소스가 테넌트별로 엄격히 격리되어야 함을 강조합니다.
핵심 포인트
- 데모와 실제 플랫폼의 차이는 테넌트 간 데이터 격리 여부에 있음
- 모든 데이터베이스 쿼리, 캐시, 도구 호출은 테넌트 소속을 증명해야 함
- 에이전트 설계 시 모델 성능보다 테넌트 경계 설정이 우선되어야 함
- 타입이 지정된 요청 컨텍스트와 스코프가 지정된 액세스 메커니즘 필요
개요
Hashnode에서 읽기
에이전트 플랫폼을 구축하며 제가 목격한 것은, 대부분의 에이전트 데모가 작동하는 이유는 테넌트(tenant)가 단 하나뿐이기 때문이라는 점입니다. 단 한 명의 사용자, 하나의 메모리 저장소(memory store), 하나의 도구 세트(tool set), 하나의 트레이스(trace), 하나의 노트북(notebook), 그리고 하나의 해피 패스(happy path)가 존재합니다. 분리해야 할 것이 아무것도 없습니다.
그러다 팀들이 데모를 플랫폼으로 전환하면, 프롬프트(prompt)는 더 이상 어려운 부분이 아니게 됩니다. 대신 지루하지만 중요한 질문이 등장합니다: 모든 데이터베이스 쿼리(database query), 캐시 키(cache key), 스트림(stream), 도구 호출(tool call), 트레이스(trace), 그리고 메모리 조회(memory lookup)가 그것이 어느 테넌트에 속하는지를 증명할 수 있는가? 만약 이 중 단 하나라도 증명하지 못한다면, 데이터 유출(leak) 사고가 발생하기를 기다리고 있는 셈입니다.
저는 에이전트 플랫폼에 관한 대화들이 잘못된 지점에서 시작되는 것을 계속 보고 있습니다. 대화는 _모델 선택(model choice), 오케스트레이션 스타일(orchestration style), 또는 메모리 품질(memory quality)_로 시작되며, 한 테넌트의 데이터, 도구, 비용, 그리고 트레이스가 다른 테넌트와 격리되어 있는지에 대한 질문은 나중에야 논의됩니다.
그 경계(boundary)는 마지막에 덧붙이는 강화 작업(hardening task)이 아닙니다. 그것은 플랫폼의 형태 그 자체이며, 그렇지 않은 척한다면 나중에 대가를 치르게 될 것입니다.
따라서 제가 실제 에이전트 플랫폼에서 찾고자 하는 메커니즘은 다음과 같습니다: 그래프(graph)로 전달되는 타입이 지정된 요청 컨텍스트(typed request context), 모든 경계에서의 스코프가 지정된 액세스(scoped access), 그리고 테넌트 유출(tenant leak)이 사고로 이어지기 전에 이를 잡아낼 수 있도록 테스트를 지루할 정도로 철저히 수행하는 것입니다.
데모 버전은 문제를 숨긴다
단일 사용자 에이전트는 메모리에 상태(state)를 유지하면서도 여전히 인상적으로 보일 수 있습니다. 테넌트 필터(tenant filter) 없이 검색 도구를 호출하고, 단순한 thread_id 아래에 채팅 기록을 저장합니다. 아무도 보고 있지 않기 때문에 가공되지 않은 프롬프트가 포함된 트레이스를 기록합니다.
하지만 이건 괜찮아 보이죠, 그렇지 않나요?
네, 하지만 데모에서만 그렇습니다. 플랫폼에서는 아닙니다.
변하는 것은 정답의 단위(unit of correctness)입니다. 에이전트는 더 이상 단순히 "다음에 무엇을 해야 할까?"에 답하는 것이 아닙니다. 에이전트는 수행하는 모든 단계에서 경계(boundary)를 함께 운반해야 합니다. 그 경계를 놓친다면, 모델은 여전히 완벽하게 좋은 답변을 내놓을 수 있지만, 당신은 여전히 실패한 것입니다. 왜냐하면 그 답변이 잘못된 테넌트에게 전달되었기 때문입니다.
만약 이전에 멀티테넌트 (multi-tenant) 플랫폼에서 작업해 본 경험이 있다면, 이는 다음을 의미합니다: 데이터, 도구 (tools), 메모리 (memory), 비용 (cost), 트레이스 (traces) 또는 이벤트 (events)에 접근하는 모든 작업은 모델이 즉흥적으로 행동하기 전에 반드시 테넌트(tenant) 단위로 범위가 지정(scoped)되어야 합니다.
에이전트 플랫폼을 위한 멀티테넌트 설계 규칙
이것은 에이전트 전용 아이디어가 아닙니다. 이는 에이전트 런타임 (agent runtime)에 적용되는 일반적인 백엔드 보안입니다. 우리는 이미 알고 있는 멀티테넌트 및 권한 부여 (authorization) 가이드라인을 에이전트 플랫폼에서 정보가 유출될 수 있는 지점인 메모리, 도구, 검색 (retrieval), 스트림 (streams), 그리고 트레이스 (traces)로 변환하여 적용합니다.
| 개념 | 멀티테넌트 플랫폼에서 | 에이전트 플랫폼에서 |
|---|---|---|
| 테넌트 격리 (Tenant isolation) (source) | 테넌트 컨텍스트 (tenant context), 교차 테넌트 접근 (cross-tenant access), 캐시/세션 격리 (cache/session isolation), 데이터베이스 격리 (database isolation), 파일 격리 (file isolation), 로깅 (logging) 및 감사 (audit) 모두 명시적인 제어가 필요합니다. | 에이전트 메모리, 도구, 스트림 및 트레이스 또한 테넌트 격리가 필요한 접점입니다. |
| ... |
에이전트 관점에서도 같은 이야기를 하고 있지만, 정확히 짚고 넘어가겠습니다. Anthropic이 멀티테넌시 표준을 발표하고 있는 것은 아닙니다. 저는 Anthropic의 도구 설계 및 가드레일 (guardrail) 가이드를 가져와 이를 테넌시 (tenancy) 문제에 적용하고 있는 것입니다.
여기서 관통하는 원칙은 강제 적용 (enforcement)을 당신이 가진 가장 구조적인 계층으로 밀어붙이는 것입니다. 프롬프트 (prompt)는 가장 취약한(softest) 계층입니다. 범위가 지정된 저장소 (scoped repository), 정책 검사 (policy check), 또는 테넌트를 인식하는 도구 래퍼 (tenant-aware tool wrapper)는 우회하기가 더 어렵습니다.
테넌트 컨텍스트가 이동해야 하는 경로
제가 본 첫 번째이자 가장 흔한 실수는 tenant_id를 HTTP 계층의 문제로 취급하는 것입니다. 그것은 거기서 시작되지만, 거기에 머물러 있어서는 안 됩니다. 에이전트 플랫폼에서 에이전트 그래프 (agent graph)는 두 번째 실행 환경이고, 도구는 세 번째 환경이며, 컨텍스트는 이들 사이의 모든 홉 (hop)을 거치는 동안 살아남아야 합니다.
이 다이어그램을 세 가지 요소가 함께 움직이는 것으로 읽으십시오. 이 중 어느 것도 단독으로 움직이지 않습니다.
- 컨텍스트 (Context): identity, tenant, role, locale, 그리고 trace metadata가 API 계층에서 그래프(graph)와 도구(tools)로 내려갑니다.
- 경계 (Boundary): 모든 홉(hop)은 범위를 놓치거나, 잘못된 곳에서 범위를 추론하거나, 호출자가 조용히 이를 재정의(override)할 수 있는 기회입니다.
- 불변성 (Invariant): 테넌트 컨텍스트(tenant context)가 이미 해결(resolved)되기 전까지는 그 무엇도 스토리지(storage), 검색(retrieval), 캐시(cache), 스트림(stream), 또는 트레이스 쓰기(trace write)에 접근할 수 없습니다.
다이어그램을 잠시 보십시오. 중요한 경로는 Agent service, Graph state, Node execution, 그리고 Tool wrapper를 통과하는 RequestContext입니다. 이것이 테넌트 경계(tenant boundary)입니다. 스토리지(Storage), 벡터 검색(vector search), 캐시(cache), 스트림 이벤트(stream events), 그리고 트레이스 속성(trace attributes)은 해당 경계가 이미 존재한 후에만 요청을 확인해야 합니다. 저는 단 하나의 프롬프트(prompt)를 작성하기 전에, 이 경로를 가장 먼저 설계할 것입니다.
내가 구축할 메커니즘
저는 _하지 말아야 할 것_부터 시작하겠습니다. tenant_id, user_id, role, locale, 그리고 trace_id를 다섯 개의 느슨한 파라미터(parameters)로 만들어 모든 함수에 전달하지 않을 것입니다. 그렇게 하면 격리(isolation)가 단순한 복사-붙여넣기 작업이 되어버리며, 복사-붙여넣기는 바로 우리가 하나를 누락하게 되는 지점입니다.
대신, 저는 단일한 명시적 요청 컨텍스트(request context) 객체를 사용하고, 모든 경계가 이를 수락하거나 아니면 폐쇄적으로 실패(fail closed)하도록 만들 것입니다. 제3의 선택지는 없습니다.
타입이 지정된 컨텍스트(Typed Context)로 시작하기
이 코드 스니펫(code snippet)은 예시용입니다. 제가 의도하는 형태를 보여줍니다.
from dataclasses import dataclass
from typing import Literal
...
지루하게 느껴질 수 있다는 것을 알지만, 그 지루한 작은 require_tenant 함수가 핵심입니다. 만약 백그라운드 작업(background job), 도구(tool), 또는 그래프 노드(graph node)가 테넌트 컨텍스트 없이 실행을 시도하면, 플랫폼은 그 즉시 중단됩니다. **범위 누락(Missing scope)**은 모델이 더 열심히 노력한다고 해서 회복할 수 있는(can recover) 문제가 아닙니다. 그것은 **플랫폼의 버그(bug in the platform)**이며, 버그답게 실패해야 합니다.
컨텍스트를 그래프 경계(Graph Boundary)에 배치하기
에이전트 플랫폼을 구축하고 있다면, 플로우(flows)나 그래프(graphs)를 갖게 될 것입니다. 유용한 규칙 하나를 말씀드리자면, 그래프는 사용자 프롬프트(user prompt)가 아니라 **런타임 메타데이터(runtime metadata) 또는 상태(state)**로부터 컨텍스트(context)를 가져와야 합니다.
모델에게 테넌트(tenant)를 대신 기억해 달라고 요청해서는 안 됩니다. 모델은 자신이 볼 수 있도록 허용된 데이터에 대해 추론(reasoning)할 수 있지만, 해당 데이터의 소유자가 누구인지 결정하는 결정권자가 되어서는 안 됩니다.
다음의 예시 코드 스니펫(snippet)을 확인해 보세요.
async def run_agent(message: str, ctx: RequestContext) -> AgentResult:
# 테넌트 컨텍스트를 사용자 프롬프트 외부에 유지합니다. 모델은 허용된
# 데이터를 사용할 수 있지만, 소유권을 강제해서는 안 됩니다.
...
관측성(observability) 컨텍스트를 다룰 때는 주의해야 합니다.
OpenTelemetry의 배기지(baggage)는 계정 또는 사용자 식별자를 하위 서비스(downstream)로 전달할 수 있지만, 공식 문서에서는 배기지가 서비스 경계(service boundaries)를 넘나들 수 있으므로 자격 증명(credentials), API 키 또는 개인정보(PII)를 포함해서는 안 된다고 경고합니다. 저는 테넌트 ID(tenant ID)를 불투명(opaque)하게 유지하고, 외부 서비스를 호출하기 전에 전파(propagation) 데이터를 정제(scrub)할 것입니다.
따라서 배기지(baggage), 트레이스 속성(trace attributes), 사용자 헤더(user headers) 또는 모델이 볼 수 있는 상태(model-visible state)가 권한 부여(authorization)의 근원이 되도록 해서는 안 됩니다. 자체적인 인증(auth) 및 멤버십 조회(membership lookup)를 통해 요청 컨텍스트(request context)를 처음부터 다시 재구축하기 전까지는, 들어오는 모든 전파 헤더(propagation header)를 신뢰할 수 없는 것으로 취급하십시오. W3C Baggage는 무결성 보호(integrity protection) 기능도 없으므로, 무언가의 증거라기보다는 상관관계 데이터(correlation data)의 전달체에 불과합니다.
제가 계속해서 강조하는 구분은 간단합니다. **권한 부여 컨텍스트(Authorization context)**는 신뢰할 수 있는 인증 클레임(auth claims)과 멤버십 조회로부터 옵니다. **관측성 컨텍스트(Observability context)**는 결정이 이미 내려진 후 요청을 디버깅(debug)하는 데 도움을 주기 위해 존재합니다. 운영자가 고장 난 플로우를 찾을 수 있도록 트레이스(trace)에 안전하고 불투명한 테넌트 태그(tenant tag)를 부착하는 것은 괜찮습니다. 하지만 동일한 트레이스 태그가 도구(tool)가 읽을 수 있는 문서를 결정하게 만드는 것은 안 되며, 이 두 가지 사이의 간극에서 엔지니어들이 피해를 입게 됩니다.
전파 범위(propagation scope)도 주의 깊게 살펴봐야 합니다. 내부 서비스 간 호출(service-to-service calls)은 진정으로 테넌트 안전한 상관관계 메타데이터(tenant-safe correlation metadata)를 필요로 할 수 있습니다. 반면, 제3자 모델 호출(third-party model call) 시에는 귀하의 테넌트 식별자(tenant identifier)가 배이지(baggage)에 실려 함께 전달될 필요가 거의 없습니다. 만약 외부로 상관관계 정보가 전달되어야 한다면, 고객이나 워크스페이스(workspace)로 역추적할 수 없는 요청 ID(request ID)를 사용하십시오.
스토리지 API를 구조적으로 테넌트 범위 지정(Tenant-Scoped)하기
이 지점이 바로 구조적 계층(structural layer)이 제 역할을 다해야 하는 곳입니다.
쿼리 API는 안전한 경로를 짧게 만들고, 안전하지 않은 경로를 번거롭게 만들어야 합니다.
사람들은 보통 저항이 가장 적은 경로를 따르므로, 저항이 가장 적은 경로를 올바른 경로로 만드십시오.
class MemoryStore:
async def list_facts(self, ctx: RequestContext, subject_id: str) -> list[Fact]:
# 쿼리를 생성하기 전에 호출자가 테넌트 인지 API(tenant-aware API)를 통하도록 강제합니다.
...
멀티테넌트(multi-tenant) 플랫폼에서 list_facts(subject_id)와 같은 API는 선호하지 않습니다. 이러한 API는 모든 호출자에게 경계(boundary)를 기억하도록 요구합니다. 동일한 규칙이 너무 많은 호출 지점(call sites)에서 반복되어야 할 때, 바로 이 지점에서 실패가 발생합니다.
더 나은 방법은 **범위가 지정된 경로(scoped path)**를 유일한 정상 경로로 만드는 것입니다. RequestContext, 테넌트 범위가 지정된 리포지토리(tenant-scoped repository), 또는 테넌트 범위가 지정된 DB 세션(DB session)을 스토리지 계층에 전달하고, 마이그레이션(migrations), 복구 작업(repair jobs), 그리고 검토된 관리 도구(admin tooling)만이 접근할 수 있는 작은 내부 API 뒤로 범위가 지정되지 않은 원시 쿼리(raw unscoped query)를 숨기십시오.
제가 지적하는 API를 보여주는 다음 예시 스니펫을 확인하십시오:
class TenantMemoryStore:
def __init__(self, db: Database, tenant_id: str):
# 테넌트는 범위가 지정된 리포지토리가 생성될 때 한 번 캡처됩니다.
...
제가 의미하는 바를 보여주는 다음 흐름을 확인하십시오:
여기서 얻는 이득이 작다고 말할 수도 있습니다. 저도 동의합니다. 하지만 이것은 중요합니다. 이 구조는 그래프 노드(graph node)가 이미 스코프(scoped)가 지정된 저장소(repository)를 전달받도록 보장합니다. 하루 종일 주제별로 사실(facts)을 요청하더라도, 그 요청의 어떤 형태도 테넌트 간 읽기(cross-tenant read)로 변질되지 않습니다.
이는 단순히 보안 측면의 승리만이 아닙니다. 테스트를 더 깔끔하게 만들어 주기도 합니다. 테넌트 간 테스트(cross-tenant test) 시, 모든 호출자가 수동으로 tenant_id를 붙이는 것을 기억했는지 일일이 확인하며 뒤지는 대신, 그래프가 호출하는 것과 동일한 공개 저장소 메서드(public repository method)를 호출할 수 있습니다.
모델이 보기 전에 정책(Policy)으로 도구(Tools)를 감싸기
우리가 주의 깊게 다뤄야 할 또 다른 계층은 모델에게 주어지는 도구(tools)입니다.
도구는 에이전트 플랫폼이 의도했든 아니든 조용히 권한 부여 시스템(authorization system)으로 변모하는 지점입니다. 모델은 동작(action)을 요청합니다. 플랫폼은 해당 동작이 이 테넌트와 이 역할(role)에 대해 실제로 존재하는지 결정합니다.
저는 이를 코드베이스 곳곳에 누군가 생각날 때마다 뿌려놓은 역할 확인(role check) 방식이 아니라, 속성 기반 액세스 제어 (ABAC) 결정으로 모델링하겠습니다. 정책 입력값에는 주체(subject), 테넌트(tenant), 도구 또는 동작(tool or action), 대상 리소스(target resource), 그리고 환경(environment)이 필요합니다.
귀하의 스택에 맞는 것이 무엇이든 OPA, Amazon Verified Permissions (AVP) 뒤의 Cedar, OpenFGA, 또는 **단순 인프로세스 정책 모듈 (plain in-process policy module)**을 사용하여 구현하십시오. 이러한 선택지 중 그 어떤 것도 결정 지점(decision point) 자체를 선택 사항으로 만들지는 않습니다.
다음 예시를 확인해 보십시오:
class ToolGate:
def __init__(self, policy: PolicyEngine, registry: ToolRegistry):
self.policy = policy
...
이것이 이 글 전체의 요지와 가장 깔끔하게 일치하는 부분입니다. 프롬프트(prompt)는 당신이 원하는 동작을 설명할 수 있도록 허용됩니다. 도구 게이트(tool gate)는 그것을 강제하는 역할을 합니다. 그리고 두 가지가 충돌할 때(결국 충돌하게 될 것입니다), 코드가 승리합니다.
엔지니어들이 잊는 것
어떤 버그들은 where tenant_id = ...가 누락된 DB 쿼리처럼 명백합니다. 그런 것은 적어도 코드 리뷰에서 눈에 보입니다.
실제로 프로덕션 환경에서 유출되는 버그들은 조용한 것들입니다. 이들은 아무도 "데이터 레이어 (data layer)"라고 생각하지 않는 에이전트 주변의 사이드 채널 (side channels)에 숨어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기