들어가며
지난 비용 자가 관리 실전에서 5월 트렌드 다섯 가지의 첫 번째 안전망을 깔았습니다. 오늘은 그다음, 영구 메모리 레이어 차례입니다. 5월 트렌드 정리에서 "무엇을 잊을지 설계가 핵심"이라고 짚었던 그 이야기죠.
영구 메모리는 두 얼굴입니다. 잘 쓰면 "내 컨텍스트를 기억하는 똑똑한 동료"가 되고, 잘못 쓰면 잘못된 기억의 영구화·PII 누적·테넌트 간 정보 유출이라는 세 가지 사고의 종합세트가 됩니다. 그래서 이 글은 "메모리를 어떻게 추가할까"보다 "메모리를 어떻게 안전하게 운영할까"에 무게를 싣습니다.
오늘 다룰 세 가지 핵심:
- PII 자동 만료: 카테고리별 TTL + GDPR 삭제권 즉시 대응
- 잘못된 기억 검증: LLM-as-Judge로 메모리 품질 주기 점검
- 멀티테넌트 격리: 사용자 A의 기억이 사용자 B에게 절대 노출되지 않게
구현은 Mem0 + Spring Boot 4 + PostgreSQL(pgvector) 조합으로 합니다. 벤더 종속 없이 자가 호스팅 가능한 스택입니다.
1. 왜 영구 메모리가 필요한가 - 컨텍스트 윈도우의 한계
Claude Opus 4.7은 1M 토큰 컨텍스트를 지원합니다. "그러면 다 넣으면 되지 않나" 싶지만, 그게 답이 아닌 이유 세 가지.
| 문제 | 설명 |
|---|---|
| 비용 | 1M 토큰을 매 호출마다 로드 = Opus 기준 $15/호출. 하루 1000회면 $15,000 |
| 지연시간 | 긴 컨텍스트는 첫 토큰 출력까지 5~30초. 사용자 경험 파괴 |
| 주의 분산 | 모델은 "중간" 정보에 약함 (lost-in-the-middle). 100k 토큰을 넘으면 정확도 떨어짐 |
해결: 외부 메모리 + 검색 기반 회상
모든 기억을 컨텍스트에 항상 들고 있는 게 아니라, 필요할 때만 의미 검색으로 꺼내 컨텍스트에 주입하는 구조. 이게 영구 메모리 레이어의 본질입니다.
요청 → [의미 검색: "이 요청과 관련된 기억 5개"]
│
▼
관련 기억만 컨텍스트에 주입
│
▼
LLM 호출 (작은 컨텍스트)
│
▼
응답 + 새 기억 저장
2. Mem0 - 무엇이 다른가
벡터 DB만으로도 비슷한 걸 만들 수 있습니다. Mem0가 한 단계 더 나아간 점은 벡터 + 그래프 + LLM 추출의 3중 구조입니다.
3중 구조의 역할
| 레이어 | 역할 |
|---|---|
| LLM 추출기 | 대화에서 "기억할 만한 사실"만 자동 추출 (잡음 제거) |
| 벡터 저장 | 의미 기반 검색용 임베딩 인덱스 |
| 그래프 저장 | 엔티티 간 관계 (예: 사용자 ↔ 선호 ↔ 제품) |
왜 자가 호스팅인가
Mem0는 SaaS 버전과 OSS 버전이 모두 있습니다. 사내 메모리는 보통 다음 이유로 OSS + 자가 호스팅 권장:
- 고객 PII가 외부 SaaS에 누적되면 GDPR 책임 소재 복잡
- 검색 지연시간 통제 (사내 네트워크 RTT)
- 요금 예측 가능성 (호출 수 폭증 시)
3. 데이터 모델 설계 - "무엇을 어떻게 저장할 것인가"
코드 짜기 전에 데이터 모델부터 명확히 해야 사고를 막을 수 있습니다.
메모리 카테고리
public enum MemoryCategory {
USER_PREFERENCE, // 사용자 선호 (예: 다크모드 좋아함)
DOMAIN_FACT, // 도메인 사실 (예: 회사 정책 X)
TASK_HISTORY, // 작업 이력 (예: 어제 PR #42 머지됨)
PII, // 개인 식별 정보 (이름, 이메일, 전화)
SECRET, // 절대 LLM에 흘리면 안 되는 비밀 (토큰 등)
EPHEMERAL // 단기 컨텍스트 (1세션 내)
}
카테고리별 정책 (TTL·검증 주기·삭제 우선순위)
| 카테고리 | TTL | 검증 주기 | GDPR 삭제 시 |
|---|---|---|---|
| USER_PREFERENCE | 180일 | 30일마다 | 즉시 |
| DOMAIN_FACT | 무기한 | 14일마다 | 해당 사용자 한정 키만 삭제 |
| TASK_HISTORY | 90일 | 해당 X (감사 로그) | 익명화 후 보존 |
| PII | 30일 | 7일마다 | 즉시 + 백업 14일 후 자동 폭발 |
| SECRET | 저장 금지 | 해당 X | 해당 X |
| EPHEMERAL | 1세션 | 해당 X | 해당 X |
이 표 자체가 운영 정책입니다. 카테고리만 정하면 자동 만료·검증·삭제가 모두 결정됩니다.
저장 스키마
CREATE TABLE memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(64) NOT NULL, -- 격리 키 #1
user_id VARCHAR(64) NOT NULL, -- 격리 키 #2
category VARCHAR(32) NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- pgvector
metadata JSONB,
confidence REAL DEFAULT 1.0, -- 검증 점수 (0~1)
last_verified TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- 카테고리별 TTL 기준
soft_deleted BOOLEAN DEFAULT FALSE
);
-- 격리 + 검색용 인덱스
CREATE INDEX idx_memory_tenant_user ON memory(tenant_id, user_id) WHERE NOT soft_deleted;
CREATE INDEX idx_memory_expires ON memory(expires_at) WHERE NOT soft_deleted;
CREATE INDEX idx_memory_embedding ON memory
USING hnsw (embedding vector_cosine_ops) WHERE NOT soft_deleted;
핵심 포인트: tenant_id, user_id, soft_deleted 세 컬럼이 보안의 전부입니다. 이게 모든 쿼리의 WHERE에 항상 들어가야 합니다.
4. 멀티테넌트 격리 - 가장 사고가 잦은 영역
메모리 사고 중 가장 치명적인 게 "고객 A의 기억이 고객 B에게 노출"입니다. 한 번이라도 터지면 신뢰 회복 불가능. 코드 레벨에서 우회 불가능하게 막아야 합니다.
패턴 1: Repository 단에서 강제 필터링
@Repository
public interface MemoryRepository extends JpaRepository<MemoryEntity, UUID> {
// ⚠️ 절대 tenantId/userId 없는 메서드 만들지 말 것
@Query(value = """
SELECT * FROM memory
WHERE tenant_id = :tenantId
AND user_id = :userId
AND soft_deleted = FALSE
AND expires_at > NOW()
ORDER BY embedding <=> CAST(:queryEmbedding AS vector)
LIMIT :topK
""", nativeQuery = true)
List<MemoryEntity> searchByEmbedding(
@Param("tenantId") String tenantId,
@Param("userId") String userId,
@Param("queryEmbedding") String queryEmbedding,
@Param("topK") int topK
);
}
패턴 2: PostgreSQL Row Level Security (방어 2중화)
ALTER TABLE memory ENABLE ROW LEVEL SECURITY;
CREATE POLICY memory_tenant_isolation ON memory
USING (tenant_id = current_setting('app.tenant_id', TRUE));
CREATE POLICY memory_user_isolation ON memory
USING (user_id = current_setting('app.user_id', TRUE));
// 매 요청마다 세션 변수 설정
@Component
public class TenantContextFilter extends OncePerRequestFilter {
private final JdbcTemplate jdbc;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String tenant = req.getHeader("X-Tenant-Id");
String user = req.getHeader("X-User-Id");
if (tenant == null || user == null) {
res.setStatus(401);
return;
}
try {
jdbc.execute("SET LOCAL app.tenant_id = '" + sanitize(tenant) + "'");
jdbc.execute("SET LOCAL app.user_id = '" + sanitize(user) + "'");
chain.doFilter(req, res);
} finally {
jdbc.execute("RESET app.tenant_id");
jdbc.execute("RESET app.user_id");
}
}
private String sanitize(String s) {
if (!s.matches("^[a-zA-Z0-9_-]{1,64}$")) {
throw new IllegalArgumentException("Invalid tenant/user id");
}
return s;
}
}
왜 2중화인가
애플리케이션 코드만 믿으면 한 번의 쿼리 작성 실수로 전체 노출. RLS는 실수로 WHERE 빠뜨려도 DB가 막아줍니다. 두 레이어 모두 깔려 있어야 운영 자신감이 생깁니다.
5. 메모리 추가 - LLM 추출과 카테고리 자동 분류
대화 한 줄을 통째로 저장하면 잡음이 쌓입니다. "기억할 가치 있는 사실"만 추출하는 게 핵심.
@Service
@RequiredArgsConstructor
public class MemoryExtractor {
private final AnthropicChatClient haikuClient;
public List<ExtractedMemory> extract(String userMessage, String agentResponse) {
String prompt = """
아래 대화에서 "향후 같은 사용자와의 대화에 도움 될 사실"만 추출하라.
잡담·인사·중복은 제외. JSON 배열로 출력.
각 항목 형식:
{
"content": "한 문장 사실",
"category": "USER_PREFERENCE | DOMAIN_FACT | TASK_HISTORY | PII",
"confidence": 0.0~1.0
}
카테고리 가이드:
- USER_PREFERENCE: 사용자가 표현한 선호/싫어함
- DOMAIN_FACT: 회사·도메인 사실 (조직, 정책, 시스템)
- TASK_HISTORY: 완료된 작업 이력 (PR 머지, 배포 등)
- PII: 이름·이메일·전화·주소 같은 개인정보
대화:
user: %s
agent: %s
JSON only. 추출 가치 없으면 빈 배열 [].
""".formatted(userMessage, agentResponse);
String json = haikuClient.prompt(prompt)
.options(AnthropicChatOptions.builder().maxTokens(2000).build())
.call().content();
return parseJson(json); // 안전한 파싱 + 검증
}
}
추출 시 주의점
- SECRET 카테고리는 LLM이 분류하지 못함: API 토큰·비밀번호 같은 건 정규식으로 사전 필터링 후 저장 자체를 거부
- confidence 0.6 미만은 저장 보류: 추출기가 자신 없는 건 노이즈일 가능성 높음
- 중복 추출 방지: 저장 전에 같은 user_id의 최근 50개와 코사인 유사도 0.9 이상이면 스킵
@Service
@RequiredArgsConstructor
public class MemoryService {
private final MemoryRepository repo;
private final EmbeddingClient embeddings;
private final SecretDetector secretDetector;
private final MemoryProperties props;
public Optional<UUID> add(String tenantId, String userId,
ExtractedMemory ext) {
// 1) SECRET 사전 차단
if (secretDetector.containsSecret(ext.content())) {
log.warn("Refused to store SECRET-like content for user={}", userId);
return Optional.empty();
}
// 2) confidence 게이트
if (ext.confidence() < 0.6f) {
return Optional.empty();
}
// 3) 임베딩 + 중복 검사
float[] emb = embeddings.embed(ext.content());
boolean dup = repo.existsSimilar(tenantId, userId, emb, 0.9f);
if (dup) return Optional.empty();
// 4) 카테고리별 TTL 적용
Duration ttl = props.ttlFor(ext.category());
Instant expiresAt = Instant.now().plus(ttl);
MemoryEntity m = MemoryEntity.builder()
.tenantId(tenantId)
.userId(userId)
.category(ext.category())
.content(ext.content())
.embedding(emb)
.confidence(ext.confidence())
.expiresAt(expiresAt)
.build();
return Optional.of(repo.save(m).getId());
}
}
6. PII 검출과 자동 만료
PII는 가장 까다로운 영역입니다. "무심코 저장"이 가장 큰 사고 패턴.
SecretDetector + PiiDetector
@Component
public class SecretDetector {
private static final List<Pattern> SECRET_PATTERNS = List.of(
Pattern.compile("sk-[a-zA-Z0-9]{32,}"), // OpenAI / Anthropic-like
Pattern.compile("ghp_[a-zA-Z0-9]{36}"), // GitHub PAT
Pattern.compile("AKIA[A-Z0-9]{16}"), // AWS Access Key
Pattern.compile("-----BEGIN [A-Z ]+PRIVATE KEY-----"), // Private keys
Pattern.compile("eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+") // JWT
);
public boolean containsSecret(String text) {
return SECRET_PATTERNS.stream().anyMatch(p -> p.matcher(text).find());
}
}
@Component
public class PiiDetector {
private static final Pattern EMAIL = Pattern.compile(
"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
private static final Pattern PHONE_KR = Pattern.compile(
"01[0-9]-?\\d{3,4}-?\\d{4}");
private static final Pattern RRN = Pattern.compile(
"\\d{6}-?[1-4]\\d{6}"); // 주민번호
public PiiResult detect(String text) {
Set<String> types = new HashSet<>();
if (EMAIL.matcher(text).find()) types.add("EMAIL");
if (PHONE_KR.matcher(text).find()) types.add("PHONE");
if (RRN.matcher(text).find()) types.add("RRN");
return new PiiResult(!types.isEmpty(), types);
}
public record PiiResult(boolean found, Set<String> types) {}
}
자동 만료 배치
@Component
@RequiredArgsConstructor
public class MemoryExpiryJob {
private final MemoryRepository repo;
@Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") // 매일 새벽 4시
public void purgeExpired() {
// 1) 만료 시점 도달한 항목 soft delete
int marked = repo.softDeleteExpired(Instant.now());
log.info("Soft deleted {} expired memories", marked);
// 2) 14일 이상 soft deleted 인 항목은 hard delete (백업도 함께)
Instant hardCut = Instant.now().minus(Duration.ofDays(14));
int purged = repo.hardDeleteSoftDeletedBefore(hardCut);
log.info("Hard deleted {} memories (soft deleted > 14d)", purged);
}
}
GDPR 즉시 삭제 엔드포인트
@RestController
@RequestMapping("/api/v1/memory/gdpr")
@RequiredArgsConstructor
public class GdprController {
private final MemoryRepository repo;
private final AuditLogger audit;
@DeleteMapping("/user/{userId}")
@PreAuthorize("hasRole('DPO') or #userId == authentication.principal.userId")
public ResponseEntity<DeletionReceipt> deleteAllForUser(
@RequestHeader("X-Tenant-Id") String tenantId,
@PathVariable String userId,
Principal principal) {
// 1) 즉시 hard delete (PII)
int piiPurged = repo.hardDeleteByCategory(tenantId, userId, "PII");
// 2) 나머지는 soft delete (감사 추적용 14일 보존)
int otherSoftDeleted = repo.softDeleteByUserExceptPii(tenantId, userId);
// 3) 감사 로그
audit.log("GDPR_DELETE", Map.of(
"tenantId", tenantId,
"userId", userId,
"requestedBy", principal.getName(),
"piiPurged", piiPurged,
"otherSoftDeleted", otherSoftDeleted
));
return ResponseEntity.ok(new DeletionReceipt(
UUID.randomUUID().toString(),
Instant.now(),
piiPurged + otherSoftDeleted
));
}
public record DeletionReceipt(String receiptId, Instant deletedAt, int totalDeleted) {}
}
GDPR 7조 "잊혀질 권리"의 시한은 "부당 지연 없이". 일반적으로 30일 이내가 안전 기준이지만, 자동화된 즉시 처리가 가장 무난합니다.
7. 잘못된 기억 검증 - LLM-as-Judge 적용
한 번 잘못 학습된 사실이 반복 인용되며 굳어지는 게 영구 메모리 최대 함정입니다. LLM-as-Judge로 주기 검증.
검증 시나리오 - 모순 탐지
@Service
@RequiredArgsConstructor
public class MemoryVerifier {
private final MemoryRepository repo;
private final AnthropicChatClient sonnetClient;
@Scheduled(cron = "0 0 5 * * *", zone = "Asia/Seoul") // 매일 새벽 5시
public void verifyBatch() {
// 검증 주기 도달한 항목 일부만 (전체는 비용 폭증)
List<MemoryEntity> due = repo.findDueForVerification(
Instant.now(), 200); // 한 번에 최대 200개
for (MemoryEntity m : due) {
verify(m);
}
}
private void verify(MemoryEntity m) {
// 같은 user의 다른 기억과 모순 여부 검사
List<MemoryEntity> related = repo.searchByEmbedding(
m.getTenantId(), m.getUserId(),
toVectorString(m.getEmbedding()), 5);
String otherMemories = related.stream()
.filter(r -> !r.getId().equals(m.getId()))
.map(r -> "- " + r.getContent())
.collect(Collectors.joining("\n"));
String prompt = """
아래 "검증 대상 기억"이 "기존 기억들"과 모순되는지 판정하라.
출력은 JSON: {"verdict": "CONSISTENT|CONTRADICTS|UNCERTAIN",
"reason": "한 문장", "new_confidence": 0.0~1.0}
검증 대상:
%s
기존 기억들:
%s
""".formatted(m.getContent(), otherMemories);
VerificationResult result = parseVerification(
sonnetClient.prompt(prompt).call().content());
switch (result.verdict()) {
case "CONSISTENT" -> {
m.setConfidence(Math.max(m.getConfidence(), result.newConfidence()));
m.setLastVerified(Instant.now());
}
case "CONTRADICTS" -> {
// 새 기억이 더 최근이면 옛것을 폐기, 아니면 새것 신뢰도 하향
m.setConfidence(result.newConfidence() * 0.5f);
if (m.getConfidence() < 0.3f) {
m.setSoftDeleted(true);
}
}
case "UNCERTAIN" -> {
m.setConfidence(m.getConfidence() * 0.9f);
m.setLastVerified(Instant.now());
}
}
repo.save(m);
}
}
검증 빈도 제어
- 전체 메모리를 매일 검증하면 비용 폭증. 카테고리별 검증 주기 차등 (PII 7일, USER_PREFERENCE 30일 등)
- 한 배치당 200개 상한. 검증 큐가 길어지면 늘리거나 우선순위 정책 도입
- 검증은 Sonnet 사용 (Haiku는 모순 탐지 정확도 떨어짐, Opus는 비용 과잉)
8. 회상 - 검색 + 컨텍스트 주입
기억을 잘 저장해도, 잘 꺼내 쓰지 못하면 의미 없습니다. 검색의 핵심은 "노이즈 vs 신호".
@Service
@RequiredArgsConstructor
public class MemoryRecall {
private final MemoryRepository repo;
private final EmbeddingClient embeddings;
public String buildContextSection(String tenantId, String userId, String query) {
float[] queryEmb = embeddings.embed(query);
List<MemoryEntity> hits = repo.searchByEmbedding(
tenantId, userId, toVectorString(queryEmb), 10);
// 후처리: confidence 가중치 + 카테고리별 우선
List<MemoryEntity> ranked = hits.stream()
.sorted(Comparator
.comparingDouble((MemoryEntity m) ->
m.getCategory().equals("PII") ? -1 : 0) // PII는 절대 컨텍스트 주입 X
.thenComparingDouble(m -> -m.getConfidence()))
.filter(m -> !m.getCategory().equals("PII")) // 안전장치 #2
.filter(m -> m.getConfidence() >= 0.5f)
.limit(5)
.toList();
if (ranked.isEmpty()) return "";
return "\n[관련 컨텍스트]\n" + ranked.stream()
.map(m -> "- (" + m.getCategory() + ", conf="
+ String.format("%.2f", m.getConfidence()) + ") " + m.getContent())
.collect(Collectors.joining("\n"));
}
}
왜 PII는 컨텍스트에 주입하지 않는가
PII가 컨텍스트에 들어가면 LLM 응답에 그대로 출력되거나, 프롬프트 인젝션으로 외부 유출될 수 있습니다. 메모리 저장은 하되 "호출 시 자동 주입"은 막는 게 표준 패턴.
PII가 꼭 필요한 케이스(예: "제 이메일이 뭔지 알아?")는 별도 명시적 API로 분리. 일반 프롬프트 흐름과 섞이지 않게.
9. 멀티테넌트 부하 분리
한 테넌트가 메모리 폭주로 다른 테넌트의 검색 지연을 일으키면 곤란합니다. PostgreSQL 단일 인스턴스에서도 다음 패턴으로 분리 가능.
| 전략 | 장점 | 단점 |
|---|---|---|
| 테이블 1개 + tenant_id 컬럼 | 단순, 운영 쉬움 | 대형 테넌트가 인덱스 효율 저하시킴 |
| 스키마 분리 (테넌트당 1 스키마) | 인덱스 격리 | 스키마 수 폭증 시 메타데이터 부담 |
| DB 분리 (테넌트당 1 DB) | 완전 격리 | 운영 비용·연결 풀 부담 |
1만 테넌트 미만은 "테이블 1개 + tenant_id"로 충분. 그 이상은 hash 기반 샤딩 또는 테넌트 등급별 분리(엔터프라이즈는 전용 DB) 검토.
10. 관측 - 메모리 시스템 핵심 메트릭
메모리는 "조용히 망가지는" 시스템입니다. 메트릭 없이는 모릅니다.
| 메트릭 | 의미 | 알림 기준 |
|---|---|---|
| memory.add.rate | 초당 추가 건수 | 평소의 3배 초과 |
| memory.recall.latency.p95 | 회상 지연시간 | 200ms 초과 |
| memory.confidence.avg | 전체 평균 신뢰도 | 0.7 미만 |
| memory.contradicts.count | 모순 탐지 빈도 | 주당 100건 초과 |
| memory.pii.detected.count | PII 검출 후 차단/저장 | 비정상 패턴 시 |
| memory.gdpr.deletion.count | GDPR 삭제 처리 | SLA 추적용 |
대시보드 핵심 패널
- 테넌트별 메모리 수 - 폭주 테넌트 조기 발견
- 카테고리 분포 - PII 비율이 높으면 정책 위반 의심
- 만료 임박 항목 수 - 사용자에게 "기억이 잊혀질 예정" 안내 가능
- 검증 큐 적체 - 검증 배치가 못 따라가면 늘려야 함
11. 도입 단계별 점검표
단번에 모든 기능을 켜지 말 것. 단계별 안전 도입.
| 단계 | 활성화 | 점검 |
|---|---|---|
| 1주차 | EPHEMERAL만 (1세션 내 메모리) | 검색 지연·정확도 베이스라인 |
| 2~3주차 | USER_PREFERENCE + DOMAIN_FACT 추가 | 중복 추출 비율, 사용자 만족도 |
| 4주차 | TASK_HISTORY 추가, LLM 추출 본격화 | 비용 변화, 추출 정확도 |
| 5주차 | 검증 배치 가동 | 모순 탐지 빈도, 신뢰도 분포 |
| 6주차 | PII 카테고리 + GDPR 엔드포인트 | 법무 검토 후 가동 |
| 7주차+ | 멀티테넌트 본격 운영 | RLS 침투 테스트, 부하 분리 |
12. 안티패턴
| 안티패턴 | 왜 문제 |
|---|---|
| 모든 대화를 통째로 저장 | 잡음·비용 폭증, 회상 정확도 저하 |
| tenant_id를 application 코드에서만 필터링 | SQL 한 줄 실수로 전체 노출 |
| PII를 컨텍스트에 항상 주입 | LLM 응답에 그대로 노출, 인젝션 시 외부 유출 |
| confidence 무시 | 잘못된 기억의 영구화 |
| 검증 배치 없음 | 오류 누적, 어느 순간 신뢰 불가 |
| SECRET 검증 없이 저장 | API 키·토큰이 메모리에 영구 보존 |
| GDPR 삭제를 수동 처리 | SLA 위반 위험, 백업본 누락 |
마치며
영구 메모리 레이어 도입의 핵심을 정리합니다.
- "무엇을 잊을지"가 "무엇을 기억할지"보다 먼저. 카테고리별 TTL·검증 주기·삭제 정책이 첫 번째 설계 산출물. 이 표 없이 코드부터 시작하면 6개월 후에 PII가 영구 누적된 데이터베이스를 마주합니다.
- 멀티테넌트 격리는 2중화 필수. 애플리케이션 필터 + PostgreSQL RLS. 둘 중 하나만 깔아두면 어느 한쪽의 실수로 전체 노출. 한 번 터지면 신뢰 회복 불가능한 사고이므로 도입 전 침투 테스트도 필수.
- PII는 저장은 OK, 자동 주입은 NO. 컨텍스트에 자동 들어가면 응답으로 새거나 인젝션으로 유출. 명시적 API로만 노출하는 패턴이 표준.
- 잘못된 기억 검증을 자동화. LLM-as-Judge로 주기 검증, confidence 점수로 점진적 폐기. 검증 없는 메모리는 잘못된 답을 자신 있게 반복하는 시스템이 됩니다.
- SECRET은 저장 금지. 정규식 기반 사전 차단이 가장 확실. 한 번 들어간 SECRET을 나중에 찾아 지우는 건 사실상 불가능에 가깝습니다.
비용 자가 관리(전 글)와 영구 메모리(이번 글)가 두 안전망으로 깔리면, 이제 5월 트렌드의 나머지 셋(장기 자율 실행·A2A·Computer Use)을 도입할 토대가 마련됩니다. 다음 글에서는 세 번째 안전망인 장기 자율 실행 에이전트의 checkpoint·재개·실패 복구 패턴을 다룹니다. 12시간짜리 작업이 8시간 지점에서 깨졌을 때 처음부터 다시 시작하지 않게 만드는 실전 코드입니다.