Claude 컨텍스트 윈도우 메모리 관리 5단계: 장기 대화 토큰 낭비 없애는 실전 설계

목차

# 이 코드가 뭘 하는지 한번 보자
conversation_history = []
for turn in range(200):
    user_msg = get_user_input()
    conversation_history.append({"role": "user", "content": user_msg})
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        messages=conversation_history  # 이게 계속 커진다
    )
    conversation_history.append({"role": "assistant", "content": response.content[0].text})
    # 200턴째... 토큰이 얼마일까?

이 코드의 문제가 뭔지 바로 보이면, 이미 Claude 컨텍스트 윈도우 메모리 관리로 한번은 삽질해본 거다. conversation_history를 그대로 쌓으면 50턴쯤에서 200K 토큰을 넘기기 시작한다. API가 400 에러를 뱉고, 요금은 요금대로 나간다.

Claude 컨텍스트 윈도우 메모리 관리 문제를 처음 만난 건 챗봇 프로젝트에서였다. 사용자가 30분 넘게 대화하면 응답이 느려지더니, 어느 순간 prompt is too long 에러가 터졌다. 그때부터 메모리 시스템을 직접 설계하기 시작했다. 단순해 보이는 문제인데, 파고들수록 깊더라.

Claude 컨텍스트 윈도우의 실제 한계

Claude 모델별로 컨텍스트 윈도우 크기가 다르다. 2025년 기준으로 정리하면 이렇다.

모델 컨텍스트 윈도우 최대 출력 토큰 실사용 가능 입력
Claude 3.5 Sonnet 200K 8,192 ~191K
Claude 3.5 Haiku 200K 8,192 ~191K
Claude Sonnet 4 200K 16,384 ~183K

숫자만 보면 200K 토큰이 엄청 많아 보인다. 근데 실제로 써보면 얘기가 다르다. 시스템 프롬프트에 2~3K 토큰, 출력에 4~8K 토큰을 빼면 실제로 대화 히스토리에 쓸 수 있는 건 180K 정도다.

토큰이 실제로 얼마나 빨리 차는가

한국어는 영어보다 토큰 효율이 나쁘다. 같은 내용을 한국어로 쓰면 영어 대비 1.5~2배 토큰을 먹는다. 한국어 대화 한 턴이 평균 300~500토큰이라고 치면, 사용자+어시스턴트 합쳐서 턴당 1,000토큰 정도다.

import anthropic

client = anthropic.Anthropic()

# 토큰 수를 직접 세보자
def count_tokens(messages, system=""):
    response = client.messages.count_tokens(
        model="claude-sonnet-4-20250514",
        system=system,
        messages=messages
    )
    return response.input_tokens

# 실험: 빈 대화부터 시작해서 토큰 증가량 확인
messages = []
for i in range(10):
    messages.append({"role": "user", "content": f"이전 대화를 참고해서 {i+1}번째 질문에 답해줘. 파이썬 비동기 처리에서 주의할 점이 뭐야?"})
    messages.append({"role": "assistant", "content": f"파이썬 비동기 처리에서 주의할 점은... (긴 응답 {i+1})"})

token_count = count_tokens(messages)
print(f"10턴 후 토큰: {token_count}")

직접 세보면 10턴에 3,000~5,000토큰 정도 나온다. 별거 아닌 것 같지만, 코드 리뷰 같은 대화에서는 한 턴에 2,000토큰 넘게 들어간다. 50턴이면 100K를 훌쩍 넘는다.

200K가 무한이 아니다
컨텍스트 윈도우를 꽉 채우면 응답 품질이 떨어진다. Anthropic 문서에서도 전체 윈도우의 70~80% 이하로 유지하길 권장한다. 160K 이상 채우면 초반 대화 내용을 잘 기억 못 하는 현상이 생긴다.
## 첫 번째 시도: 단순 슬라이딩 윈도우 (그리고 실패)

가장 먼저 떠오른 건 슬라이딩 윈도우였다. 최근 N개 메시지만 유지하고 오래된 건 버리는 거다.

class SlidingWindowMemory:
    def __init__(self, max_turns=30):
        self.max_turns = max_turns
        self.messages = []

    def add(self, role, content):
        self.messages.append({"role": role, "content": content})
        # 최근 max_turns * 2개만 유지 (user + assistant 쌍)
        if len(self.messages) > self.max_turns * 2:
            self.messages = self.messages[-(self.max_turns * 2):]

    def get_messages(self):
        return self.messages

간단하고 빠르다. 근데 치명적인 문제가 있다. 20턴 전에 사용자가 "내 이름은 김철수야, 백엔드 개발자야"라고 했는데, 슬라이딩 윈도우가 그걸 날려버리면? "아까 내가 뭐 한다고 했지?"라는 질문에 답을 못 한다.

실제로 이 방식으로 배포했다가 사용자 피드백이 안 좋았다. "봇이 기억력이 없다", "계속 같은 말 반복한다". 당연하다. 대화 앞부분을 통째로 잘라내니까.

왜 단순 슬라이딩으로는 부족한가

사람의 대화에는 두 종류 정보가 있다. 휘발성 정보(방금 한 질문의 맥락)와 지속성 정보(사용자 이름, 프로젝트 맥락, 이전 결정사항). 슬라이딩 윈도우는 둘을 구분 못 한다. 오래됐다고 다 버리면 지속성 정보가 날아간다.

Claude 컨텍스트 윈도우 메모리 관리의 핵심: 요약 압축 시스템

삽질 끝에 정착한 방식이 요약 기반 메모리다. 핵심 아이디어는 간단하다: 오래된 대화를 Claude한테 요약시키고, 그 요약을 시스템 프롬프트에 넣는 거다.

import anthropic
from typing import Optional

class SummarizationMemory:
    def __init__(self, client: anthropic.Anthropic, max_tokens: int = 50000):
        self.client = client
        self.max_tokens = max_tokens  # 이 이상이면 요약 트리거
        self.messages = []
        self.summary: Optional[str] = None

    def add(self, role: str, content: str):
        self.messages.append({"role": role, "content": content})

    def _estimate_tokens(self) -> int:
        """대략적 토큰 추정. 정확하진 않지만 API 호출 줄이려고."""
        total_chars = sum(len(m["content"]) for m in self.messages)
        return total_chars // 2  # 한국어 기준 대략 2자당 1토큰

    async def maybe_compress(self):
        if self._estimate_tokens() < self.max_tokens:
            return

        # 앞쪽 절반을 요약 대상으로
        split_point = len(self.messages) // 2
        to_summarize = self.messages[:split_point]
        to_keep = self.messages[split_point:]

        summary_prompt = self._build_summary_prompt(to_summarize)

        response = self.client.messages.create(
            model="claude-haiku-3-5-20241022",  # 요약은 저렴한 모델로
            max_tokens=2048,
            messages=[{"role": "user", "content": summary_prompt}]
        )

        new_summary = response.content[0].text

        # 기존 요약이 있으면 합치기
        if self.summary:
            self.summary = f"{self.summary}\n\n[추가 요약]\n{new_summary}"
        else:
            self.summary = new_summary

        self.messages = to_keep

    def _build_summary_prompt(self, messages: list) -> str:
        conversation = "\n".join(
            f"{'사용자' if m['role'] == 'user' else '어시스턴트'}: {m['content']}"
            for m in messages
        )
        return f"""아래 대화를 요약해줘. 다음 규칙을 지켜:
1. 사용자가 언급한 개인 정보(이름, 직업, 프로젝트명 등) 반드시 유지
2. 주요 결정사항과 합의된 내용 유지
3. 기술적 맥락(사용 중인 기술 스택, 에러 상황 등) 유지
4. 일상적인 인사, 중복 질문은 생략
5. 500자 이내로

대화:
{conversation}"""

    def get_system_prompt(self, base_prompt: str) -> str:
        if self.summary:
            return f"""{base_prompt}

[이전 대화 요약]
{self.summary}"""
        return base_prompt

    def get_messages(self) -> list:
        return self.messages

이 방식의 포인트가 몇 가지 있다.

요약에는 저렴한 모델을 쓴다
요약 작업에 Claude Sonnet을 쓰면 비용이 2배로 뛴다. Haiku로 요약하면 비용이 1/10 수준이고, 요약 품질도 충분하다. 요약에 필요한 건 이해력이지 창의성이 아니다.
요약 프롬프트에서 “개인 정보 유지”를 명시적으로 지정한 게 중요하다. 이걸 안 넣으면 요약 모델이 사용자 이름 같은 걸 “한 사용자가”로 뭉개버린다. 처음에 이거 몰라서 한참 헤맸다.

토큰 카운팅과 압축 타이밍

요약을 언제 트리거하느냐도 중요한 설계 포인트다. 너무 자주 하면 비용이 늘고, 너무 늦으면 컨텍스트가 넘친다.

Anthropic Token Counting API 활용

위 코드에서 _estimate_tokens()를 대략적으로 구현했는데, 정확한 카운팅이 필요하면 Anthropic의 Token Counting API를 쓰면 된다.

def get_exact_token_count(self) -> int:
    """정확한 토큰 수. API 호출이 발생하므로 매 턴마다 쓰진 않는다."""
    if not self.messages:
        return 0

    response = self.client.messages.count_tokens(
        model="claude-sonnet-4-20250514",
        system=self.get_system_prompt(""),
        messages=self.messages
    )
    return response.input_tokens

다만 이것도 매 턴마다 호출하면 API 비용이 늘어난다. 내가 쓰는 전략은 이렇다:

  • 평소에는 문자 수 기반 추정 (총 문자 수 / 2)
  • 추정값이 임계치의 80%를 넘으면 그때 정확한 카운팅
  • 정확한 값이 임계치를 넘으면 요약 트리거
async def should_compress(self) -> bool:
    estimate = self._estimate_tokens()

    # 임계치의 80% 이하면 패스
    if estimate < self.max_tokens * 0.8:
        return False

    # 80% 넘으면 정확히 세본다
    exact = self.get_exact_token_count()
    return exact >= self.max_tokens

솔직히 문자 수 기반 추정은 정확도가 70% 정도밖에 안 된다. 코드가 많은 대화는 토큰이 적게 나오고, 한국어 텍스트가 많으면 많이 나온다. 근데 정확한 타이밍보다 비용 절감이 더 중요해서 이렇게 쓰고 있다.

계층적 메모리 아키텍처 설계

실전에서는 요약만으로 부족하다. 대화가 수백 턴 넘어가면 요약의 요약이 필요해지고, 그러다 보면 초기 맥락이 뭉개진다. 그래서 메모리를 계층으로 나눈다.

graph TD
    A[사용자 메시지] --> B[작업 메모리<br/>최근 10~20턴]
    B --> C{토큰 임계치<br/>초과?}
    C -->|No| D[그대로 유지]
    C -->|Yes| E[요약 엔진<br/>Claude Haiku]
    E --> F[단기 요약<br/>시스템 프롬프트에 삽입]
    E --> G[엔티티 추출<br/>사용자 정보, 결정사항]
    G --> H[장기 메모리<br/>벡터 DB 또는 KV 저장소]
    H --> I[검색으로 필요할 때 꺼내기]

작업 메모리 (Working Memory)

최근 대화. 슬라이딩 윈도우로 관리한다. 여기가 메인이고, 대부분의 응답은 여기만으로 충분하다.

단기 요약 (Short-term Summary)

작업 메모리에서 밀려난 대화의 요약. 시스템 프롬프트에 들어간다. 보통 1,000~2,000토큰 수준으로 유지한다.

장기 메모리 (Long-term Memory)

여기가 좀 까다롭다. 대화에서 추출한 핵심 엔티티(사용자 이름, 선호 기술 스택, 프로젝트명 등)를 별도로 저장한다. 벡터 DB를 쓸 수도 있고, 간단하게 JSON 파일로 할 수도 있다.

import json
from datetime import datetime

class EntityMemory:
    def __init__(self, storage_path: str = "memory.json"):
        self.storage_path = storage_path
        self.entities = self._load()

    def _load(self) -> dict:
        try:
            with open(self.storage_path, "r") as f:
                return json.load(f)
        except FileNotFoundError:
            return {"user_info": {}, "decisions": [], "context": {}}

    def save(self):
        with open(self.storage_path, "w") as f:
            json.dump(self.entities, f, ensure_ascii=False, indent=2)

    def update_user_info(self, key: str, value: str):
        self.entities["user_info"][key] = {
            "value": value,
            "updated_at": datetime.now().isoformat()
        }
        self.save()

    def add_decision(self, decision: str):
        self.entities["decisions"].append({
            "content": decision,
            "created_at": datetime.now().isoformat()
        })
        # 최근 20개만 유지
        self.entities["decisions"] = self.entities["decisions"][-20:]
        self.save()

    def to_prompt(self) -> str:
        parts = []
        if self.entities["user_info"]:
            info = ", ".join(
                f"{k}: {v['value']}"
                for k, v in self.entities["user_info"].items()
            )
            parts.append(f"[사용자 정보] {info}")

        if self.entities["decisions"]:
            recent = self.entities["decisions"][-5:]
            decisions = "\n".join(f"- {d['content']}" for d in recent)
            parts.append(f"[최근 결정사항]\n{decisions}")

        return "\n\n".join(parts)

개인적으로 이 구조가 제일 깔끔하다. 벡터 DB까지 갈 필요 없는 프로젝트가 대부분이고, JSON 파일 하나로 충분한 경우가 많다.

엔티티 추출 자동화
매번 수동으로 엔티티를 넣을 수는 없다. Claude한테 대화를 주고 “여기서 기억할 만한 정보를 JSON으로 뽑아줘”라고 하면 된다. 이것도 Haiku로 충분하다.
## Claude 컨텍스트 윈도우 메모리 관리와 Prompt Caching

Anthropic의 Prompt Caching 기능을 메모리 시스템과 같이 쓰면 비용을 크게 줄일 수 있다. 사실 이걸 모르고 한동안 비용을 많이 썼다.

Prompt Caching이 뭐냐면, 시스템 프롬프트나 대화 앞부분이 여러 요청에서 동일하면 캐싱해서 토큰 비용을 90% 줄여주는 기능이다.

from anthropic import Anthropic

client = Anthropic()

# 캐싱을 활용한 메모리 시스템
def create_cached_request(system_prompt: str, summary: str, recent_messages: list):
    system_blocks = [
        {
            "type": "text",
            "text": system_prompt,
            "cache_control": {"type": "ephemeral"}  # 이 부분이 캐싱됨
        }
    ]

    # 요약도 캐싱 대상. 요약은 자주 안 바뀌니까.
    if summary:
        system_blocks.append({
            "type": "text",
            "text": f"\n[이전 대화 요약]\n{summary}",
            "cache_control": {"type": "ephemeral"}
        })

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        system=system_blocks,
        messages=recent_messages
    )
    return response

캐싱의 효과가 꽤 크다. 시스템 프롬프트 + 요약이 3,000토큰이라고 하면, 캐시 히트 시 이 부분의 비용이 1/10로 줄어든다. 장기 대화에서 턴마다 절약되니까 누적하면 상당하다.

캐싱과 메모리 압축의 타이밍 문제

근데 여기서 주의할 게 있다. 요약을 업데이트하면 캐시가 무효화된다. 그래서 요약 갱신 주기를 너무 짧게 잡으면 캐싱 효과가 없어진다.

내 경험상 최적 지점은 이렇다:

  • 시스템 프롬프트: 거의 안 바뀜 → 캐싱 효과 극대
  • 대화 요약: 20~30턴마다 갱신 → 적당한 캐싱 효과
  • 엔티티 메모리: 변경 시에만 갱신 → 캐싱에 영향 적음
캐싱 최소 토큰 요건
Prompt Caching은 캐싱할 블록이 최소 1,024토큰(Claude Sonnet 기준) 이상이어야 작동한다. 시스템 프롬프트가 짧으면 캐싱 대상이 안 될 수 있다. 시스템 프롬프트에 충분한 지시사항을 넣는 게 오히려 비용 절감에 도움이 된다.
## RAG 연동으로 장기 메모리 확장하기

대화가 수천 턴을 넘어가거나, 여러 세션에 걸쳐 맥락을 유지해야 하면 요약만으로는 한계가 온다. 이때 RAG(Retrieval-Augmented Generation)를 붙인다.

![메모리 아키텍처 다이어그램]({{image:memory-architecture|Isometric 3D illustration of a layered memory architecture system with three tiers: working memory, summary cache, and vector database. Modern flat design, blue and teal gradient, dark background}})

전체 대화를 벡터 DB에 저장하고, 현재 질문과 관련된 과거 대화만 검색해서 컨텍스트에 넣는 방식이다.

# chromadb를 쓴 예시. pip install chromadb
import chromadb

class VectorMemory:
    def __init__(self, collection_name: str = "conversations"):
        self.chroma = chromadb.PersistentClient(path="./chroma_db")
        self.collection = self.chroma.get_or_create_collection(
            name=collection_name,
            metadata={"hf:space": "default"}
        )
        self.turn_id = 0

    def store_turn(self, user_msg: str, assistant_msg: str, metadata: dict = None):
        """대화 턴을 벡터 DB에 저장"""
        self.turn_id += 1
        combined = f"사용자: {user_msg}\n어시스턴트: {assistant_msg}"

        self.collection.add(
            documents=[combined],
            ids=[f"turn_{self.turn_id}"],
            metadatas=[metadata or {"turn": self.turn_id}]
        )

    def search_relevant(self, query: str, n_results: int = 5) -> list[str]:
        """현재 질문과 관련된 과거 대화를 검색"""
        results = self.collection.query(
            query_texts=[query],
            n_results=n_results
        )
        return results["documents"][0] if results["documents"] else []

이걸 메인 메모리 시스템에 통합하면 이렇게 된다:

class HybridMemory:
    def __init__(self, client: anthropic.Anthropic):
        self.client = client
        self.sliding = SlidingWindowMemory(max_turns=15)
        self.summarizer = SummarizationMemory(client, max_tokens=40000)
        self.entity = EntityMemory()
        self.vector = VectorMemory()

    def build_context(self, current_query: str, base_system: str) -> tuple[str, list]:
        # 1. 벡터 검색으로 관련 과거 대화 찾기
        relevant_history = self.vector.search_relevant(current_query, n_results=3)

        # 2. 시스템 프롬프트 조합
        system_parts = [base_system]

        entity_prompt = self.entity.to_prompt()
        if entity_prompt:
            system_parts.append(entity_prompt)

        if self.summarizer.summary:
            system_parts.append(f"[대화 요약]\n{self.summarizer.summary}")

        if relevant_history:
            history_text = "\n---\n".join(relevant_history)
            system_parts.append(f"[관련 과거 대화]\n{history_text}")

        system_prompt = "\n\n".join(system_parts)

        # 3. 최근 메시지는 슬라이딩 윈도우에서
        messages = self.sliding.get_messages()

        return system_prompt, messages

솔직히 이건 좀 불편하다. ChromaDB 설치하고 관리하는 것도 일이고, 검색 품질이 항상 좋은 것도 아니다. 사용자가 "아까 그거"라고 하면 벡터 검색이 "아까 그거"를 제대로 못 찾는 경우가 있다. 임베딩의 한계다.

그래도 수백 턴 이상의 장기 대화에서는 요약+벡터 검색 조합이 가장 나은 방법이다. 다른 대안을 못 찾았다.

실전 트러블슈팅: 자주 만나는 문제들

Claude 컨텍스트 윈도우 메모리 관리를 실제로 구현하면 예상 못 한 문제가 꽤 나온다.

요약이 핵심을 놓치는 경우

제일 짜증났던 건 요약 모델이 기술적 디테일을 빠뜨리는 거였다. 사용자가 "PostgreSQL 15에서 jsonb_path_query 쓰고 있어"라고 했는데, 요약에서 "데이터베이스를 사용 중"으로 뭉개버린다.

해결법: 요약 프롬프트에 도메인 특화 지시를 넣는다.

TECH_SUMMARY_PROMPT = """아래 대화를 요약해줘.

반드시 유지할 정보:
- 구체적 기술명과 버전 (예: PostgreSQL 15, React 18)
- 에러 메시지 원문
- 사용자가 선택한 방법/도구
- 코드 스니펫의 핵심 로직 (전체 복사 불필요, 핵심만)
- 파일 경로, 환경 설정값

생략해도 되는 정보:
- 인사말, 감사 표현
- 이미 해결된 문제의 상세 과정 (결론만 유지)
- 중복된 설명

500자 이내로 요약:
{conversation}"""

이렇게 하니까 요약 품질이 확 올랐다.

messages 배열의 role 순서 문제

Claude API는 messages 배열에서 userassistant가 번갈아 와야 한다. 요약 후 메시지를 재조합할 때 이 순서가 깨지면 400 에러가 난다.

# 이렇게 되면 에러
messages = [
    {"role": "user", "content": "질문1"},
    {"role": "user", "content": "질문2"},  # user가 연속으로 오면 안 됨
    {"role": "assistant", "content": "답변"}
]

요약 후 메시지를 잘라낼 때 항상 user로 시작하는지 확인해야 한다.

def sanitize_messages(messages: list) -> list:
    """messages 배열의 role 순서를 보정"""
    if not messages:
        return messages

    # user로 시작해야 함
    while messages and messages[0]["role"] != "user":
        messages = messages[1:]

    # 연속 role 제거
    sanitized = [messages[0]]
    for msg in messages[1:]:
        if msg["role"] != sanitized[-1]["role"]:
            sanitized.append(msg)

    return sanitized

이것 때문에 2시간 날렸다. 에러 메시지가 messages: roles must alternate between "user" and "assistant"인데, 요약 직후에만 발생해서 원인 찾기가 어려웠다.

API 응답에서의 stop_reason 확인
컨텍스트가 거의 찬 상태에서 요청을 보내면, Claude가 응답을 중간에 끊는 경우가 있다. `stop_reason`이 `end_turn`이 아니라 `max_tokens`이면 출력이 잘린 거다. 메모리 압축 로직에서 이 경우를 처리해야 한다.
## 컨텍스트 윈도우 메모리 관리 전략별 성능 비교

![전략 비교 차트]({{image:strategy-comparison|Clean data visualization chart comparing three memory management strategies: sliding window, summarization, and hybrid RAG. Bar chart style, minimal design, white background with blue accent colors}})

지금까지 다룬 전략들을 정리하면 이렇다.

전략 구현 난이도 토큰 효율 맥락 보존 비용 적합한 경우
슬라이딩 윈도우 쉬움 낮음 최근만 높음 짧은 대화, 프로토타입
요약 압축 중간 높음 중간 중간 일반적인 챗봇
엔티티 추출 중간 높음 좋음 중간 개인화 서비스
하이브리드(요약+RAG) 어려움 매우 높음 매우 좋음 낮음 장기 대화, 복잡한 태스크

비용 열에서 슬라이딩 윈도우가 "높음"인 이유가 의아할 수 있다. 요약 없이 최근 메시지를 그대로 보내면, 매 요청마다 보내는 토큰 수가 계속 높은 상태로 유지되기 때문이다. 요약을 쓰면 전체 토큰 수 자체가 줄어서 장기적으로는 더 싸다.

개인적으로 대부분의 프로젝트에서 "요약 압축 + 엔티티 추출" 조합이면 충분하다. RAG까지 붙이는 건 대화가 수백 턴 이상 가는 경우에만 의미 있다.

프로덕션 적용 시 고려할 것들

![프로덕션 아키텍처]({{image:production-architecture|System architecture diagram showing a production chatbot with memory management layer, API gateway, cache layer, and vector database. Clean technical illustration, dark theme with neon accent lines}})

실제 서비스에 메모리 시스템을 넣을 때 빠뜨리기 쉬운 것들이 있다.

동시성과 세션 격리

여러 사용자가 동시에 대화하면 메모리가 섞이면 안 된다. 당연한 얘기인데, 싱글턴으로 메모리를 구현하면 이 문제가 생�다.

from collections import defaultdict

class SessionManager:
    def __init__(self, client: anthropic.Anthropic):
        self.client = client
        self.sessions: dict[str, HybridMemory] = {}

    def get_or_create(self, session_id: str) -> HybridMemory:
        if session_id not in self.sessions:
            self.sessions[session_id] = HybridMemory(self.client)
        return self.sessions[session_id]

    def cleanup_old_sessions(self, max_idle_minutes: int = 30):
        """오래된 세션 정리. 메모리 누수 방지."""
        # 실제로는 마지막 활동 시간을 추적해서 정리
        pass

비용 모니터링

Claude 컨텍스트 윈도우 메모리 관리에서 놓치기 쉬운 게 비용이다. 요약 API 호출 비용, 메인 대화 비용, 벡터 DB 운영 비용을 합치면 턴당 비용이 생각보다 높아질 수 있다.

class CostTracker:
    def __init__(self):
        self.total_input_tokens = 0
        self.total_output_tokens = 0
        self.summary_calls = 0

    def track(self, response):
        self.total_input_tokens += response.usage.input_tokens
        self.total_output_tokens += response.usage.output_tokens

    def estimate_cost(self, model: str = "claude-sonnet-4-20250514") -> float:
        """대략적 비용 추정 (USD)"""
        # 2025년 기준 Sonnet 가격
        input_cost = self.total_input_tokens * 3 / 1_000_000
        output_cost = self.total_output_tokens * 15 / 1_000_000
        return input_cost + output_cost

비용 추적을 처음부터 넣어야 한다. 나중에 넣으려면 귀찮아서 안 하게 된다. 운영 중에 "이번 달 Claude 비용이 왜 이렇게 나왔지?" 하고 나서야 찾게 되는데, 그때는 이미 늦다.

에러 핸들링

요약 API 호출이 실패하면 어떻게 할 건지도 정해야 한다. 요약 실패 시 그냥 슬라이딩 윈도우로 폴백하는 게 가장 안전하다.

async def safe_compress(self):
    try:
        await self.summarizer.maybe_compress()
    except anthropic.APIError as e:
        # 요약 실패 시 슬라이딩 윈도우로 폴백
        if len(self.sliding.messages) > 40:
            self.sliding.messages = self.sliding.messages[-30:]
        print(f"요약 실패, 폴백: {e}")
테스트 환경에서의 주의점
메모리 시스템 테스트할 때 실제 Claude API를 매번 호출하면 비용이 나간다. 요약 로직은 mock 응답으로 단위 테스트하고, 통합 테스트만 실제 API로 돌리는 게 낫다. `unittest.mock.patch`로 `client.messages.create`를 모킹하면 된다.
## 다음 단계: MCP와 외부 메모리 연동

메모리 시스템이 어느 정도 잡혔으면 다음은 MCP(Model Context Protocol)와의 연동이다. MCP 서버로 외부 데이터베이스나 파일 시스템을 직접 연결하면, 컨텍스트 윈도우 안에 모든 걸 담을 필요가 없어진다. 필요한 정보를 그때그때 가져오는 구조로 바꿀 수 있다.

그리고 Anthropic의 Agent SDK를 쓰면 멀티 에이전트 구조에서 메모리를 공유하는 것도 가능하다. 에이전트 A가 수집한 맥락을 에이전트 B가 참조하는 식인데, 이건 아직 안정화가 안 된 영역이라 프로덕션에서 쓰려면 좀 기다려야 할 것 같다.

프롬프트 엔지니어링 쪽에서도 할 게 남아있다. 시스템 프롬프트에 메모리를 어떤 포맷으로 넣느냐에 따라 Claude의 활용 능력이 달라진다. XML 태그로 구조화해서 넣는 방식이 체감상 가장 안정적인데, 이것도 별도로 정리할 만한 주제다.

Claude 컨텍스트 윈도우 메모리 관리, 처음엔 단순한 문제인 줄 알았다. 대화 길어지면 자르면 되지, 싶었는데 실제로는 뭘 자르고 뭘 남길지가 핵심이다. 요약 + 엔티티 추출 + 적절한 캐싱, 이 세 가지를 조합하면 200K 토큰으로도 꽤 긴 대화를 커버하게 된다.

관련 글