최신 트렌드

AI 에이전트 영구 메모리 레이어 실전 - Mem0 + Spring Boot로 PII 자동 만료·잘못된 기억 검증·멀티테넌트 격리 구현하기

백엔드 개발자 김승원 2026. 5. 5. 20:34

들어가며

지난 비용 자가 관리 실전에서 5월 트렌드 다섯 가지의 첫 번째 안전망을 깔았습니다. 오늘은 그다음, 영구 메모리 레이어 차례입니다. 5월 트렌드 정리에서 "무엇을 잊을지 설계가 핵심"이라고 짚었던 그 이야기죠.

영구 메모리는 두 얼굴입니다. 잘 쓰면 "내 컨텍스트를 기억하는 똑똑한 동료"가 되고, 잘못 쓰면 잘못된 기억의 영구화·PII 누적·테넌트 간 정보 유출이라는 세 가지 사고의 종합세트가 됩니다. 그래서 이 글은 "메모리를 어떻게 추가할까"보다 "메모리를 어떻게 안전하게 운영할까"에 무게를 싣습니다.

오늘 다룰 세 가지 핵심:

  1. PII 자동 만료: 카테고리별 TTL + GDPR 삭제권 즉시 대응
  2. 잘못된 기억 검증: LLM-as-Judge로 메모리 품질 주기 점검
  3. 멀티테넌트 격리: 사용자 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시간 지점에서 깨졌을 때 처음부터 다시 시작하지 않게 만드는 실전 코드입니다.