티스토리 뷰
Python으로 공부하는 OpenAI 8편 — 채팅 히스토리는 전부 저장하고, 모델에는 필요한 만큼만 보내야 합니다
octo54 2026. 3. 31. 11:57Python으로 공부하는 OpenAI 8편 — 채팅 히스토리는 전부 저장하고, 모델에는 필요한 만큼만 보내야 합니다
7편에서 previous_response_id로 대화를 이어붙이는 데까지 왔죠.
여기까지 해보면 처음엔 꽤 신기합니다. “오, 진짜 이전 맥락을 기억하네?” 싶어요.
근데 조금만 써보면 바로 다른 문제가 보입니다.
- 대화가 길어질수록 점점 무거워짐
- 비용이 신경 쓰이기 시작함
- 같은 질문인데 응답 속도가 느려지는 느낌이 듦
- 예전 대화까지 다 들고 가는 게 맞나 싶어짐
- “DB엔 다 저장하고 싶지만, 모델한테도 다 보내야 하나?”라는 고민이 생김
여기서부터가 진짜 운영 감각입니다.
OpenAI 문서도 Responses API와 함께 conversation state, compaction, counting tokens, prompt caching 같은 주제를 별도로 다루고 있습니다. 이 말은 곧, “대화를 이어간다”에서 끝나는 게 아니라 컨텍스트를 어떻게 관리할지가 실무 핵심이라는 뜻이에요. (OpenAI 플랫폼)
이번 글에서는 이걸 정리해보겠습니다.
- 전체 히스토리 저장과 모델 전달용 컨텍스트는 왜 분리해야 하는지
- 최근 N턴만 보내는 방식이 왜 자주 쓰이는지
- 오래된 대화를 요약해서 압축(compaction)하는 감각
- DB와 Redis의 역할을 어떻게 나누면 좋은지
- FastAPI에서 이 구조를 어떻게 코드로 가져가면 되는지
1. 제일 먼저 버려야 할 생각: “대화는 그냥 다 보내면 되지 않나?”
처음 만들면 다 이렇게 갑니다.
사용자 메시지 하나 올 때마다:
- DB에서 지금까지 대화 전체를 읽는다
- messages 배열로 다 만든다
- OpenAI에 통째로 보낸다
- 답을 받는다
- 다시 DB에 저장한다
동작은 합니다.
문제는… 진짜 금방 무거워져요.
왜냐하면 대화가 길어질수록 모델에 전달하는 입력도 길어지고,
입력이 길어지면 토큰 사용량과 지연이 같이 커지기 때문입니다. OpenAI 문서도 컨텍스트 관리 주제 아래에 counting tokens와 compaction을 따로 두고 있습니다. 그냥 길어지면 알아서 잘 되겠지 수준으로 운영하면 안 된다는 뜻에 가깝습니다. (OpenAI 플랫폼)
여기서 중요한 구분이 하나 생깁니다.
전체 히스토리 저장용 데이터와
이번 요청에서 모델에게 보여줄 컨텍스트는
같을 수도 있지만, 보통은 같지 않습니다.
이 감각이 진짜 중요해요.
2. 저장용 히스토리와 모델용 컨텍스트는 다릅니다
이걸 한 문장으로 정리하면 이렇습니다.
DB에는 가능한 한 많이 저장하고, 모델에는 필요한 만큼만 보낸다.
왜냐하면 둘의 목적이 다르거든요.
DB에 전체 히스토리를 저장하는 이유
- 사용자 대화 기록 보존
- 고객 문의 이력 조회
- 대화 복원
- 통계/분석
- 장애 대응
- 나중에 요약 재생성
모델에 일부만 보내는 이유
- 비용 절감
- 응답 속도 개선
- 컨텍스트 창 낭비 방지
- 불필요한 오래된 내용 제거
- 관련성 높은 맥락만 유지
OpenAI 문서가 conversation state와 context management를 분리해서 설명하는 것도 같은 이유로 볼 수 있습니다. 상태를 이어가는 것과, 어떤 맥락을 실제 요청에 실어 보낼지는 별개 문제라는 거죠. (OpenAI 플랫폼)
저는 이걸 처음 체감했을 때 좀 아깝다는 생각이 들었어요.
“열심히 다 쌓았는데 왜 다 못 보내지?”
근데 서비스는 기록 보관소가 아니라, 현재 질문에 가장 도움이 되는 문맥만 효율적으로 주는 시스템이어야 하더라고요.
3. 가장 현실적인 첫 전략: 최근 N턴만 보내기
이건 정말 많이 쓰는 방법입니다.
예를 들어:
- system / instructions는 항상 포함
- 최근 6턴 또는 10턴만 모델에 포함
- 더 오래된 대화는 DB에는 남기되 모델에는 일단 제외
이 방식이 좋은 이유는 단순합니다.
- 구현이 쉽고
- 효과가 바로 보이고
- 토큰 사용량 통제가 쉬움
- 디버깅이 쉬움
OpenAI가 context management 아래 counting tokens와 compaction을 함께 다루는 것도, 결국 대화 전체를 무조건 다 보내기보다 길이를 관리하라는 흐름으로 읽을 수 있습니다. (OpenAI 플랫폼)
예를 들어 대화 테이블이 이렇게 있다고 해볼게요.
1. user: 파이썬 데코레이터가 뭐야?
2. assistant: ...
3. user: 예제 보여줘
4. assistant: ...
5. user: 클래스 메서드에도 적용돼?
6. assistant: ...
7. user: 실무에서 어디에 많이 써?
8. assistant: ...
9. user: FastAPI 예제로 바꿔줘
모델에 보낼 때는 1~9 전체를 다 보내는 대신,
최근 몇 턴만 잘라서 이렇게 보낼 수 있습니다.
recent_messages = [
{"role": "assistant", "content": "..."},
{"role": "user", "content": "실무에서 어디에 많이 써?"},
{"role": "assistant", "content": "..."},
{"role": "user", "content": "FastAPI 예제로 바꿔줘"},
]
이 방식은 생각보다 오래 버팁니다.
처음 만드는 서비스는 여기서 시작해도 충분해요.
4. 그런데 최근 N턴만으로 안 되는 경우가 있습니다
문제는 어떤 대화는 핵심 맥락이 오래전에 나왔다는 겁니다.
예를 들어 사용자가 초반에 이런 걸 말했어요.
- “나는 주니어 백엔드 개발자야”
- “답변은 한국어로 해줘”
- “예제는 FastAPI 기준으로 설명해줘”
- “코드는 너무 길지 않게 해줘”
그런데 최근 6턴만 자르면 이 정보가 날아갈 수 있습니다.
그러면 모델은 갑자기 말투가 흔들리거나, Flask 예제를 주거나, 설명 수준이 달라질 수 있죠.
그래서 실무에서는 보통 컨텍스트를 3층으로 나눠 생각합니다.
1층: 항상 포함되는 고정 지침
- instructions
- 앱 정책
- 답변 스타일
- 안전 규칙
2층: 최근 대화 몇 턴
- 직전 문맥
- 방금 이어진 질문/답변
3층: 오래됐지만 중요한 요약 맥락
- 사용자 선호
- 이번 대화의 핵심 주제
- 이전에 결정된 전제조건
이게 바로 compaction 감각으로 이어집니다. OpenAI 문서가 context management 아래에 아예 Compaction 항목을 두는 이유가 이거예요. 오래된 걸 그냥 버리는 게 아니라, 짧게 압축해서 핵심만 남기는 전략이 필요하다는 뜻입니다. (OpenAI 플랫폼)
5. compaction은 어렵게 생각할 필요 없습니다
이 단어가 괜히 무겁게 들리는데,
처음엔 그냥 이렇게 이해하면 됩니다.
긴 대화를 짧은 요약으로 바꿔서, 나중 요청의 컨텍스트로 재사용하는 것
예를 들어 원래 대화가 이랬다고 해볼게요.
- 사용자는 Python/FastAPI 기준 설명을 선호함
- 초보자 눈높이의 설명을 원함
- 데코레이터, 의존성 주입, 라우터 분리까지 얘기함
- 다음에는 테스트 코드까지 연결하고 싶어 함
이걸 요약 메모로 바꾸면:
사용자는 주니어 Python 백엔드 개발자이며, FastAPI 기준의 쉬운 한국어 설명을 선호한다. 현재 주제는 데코레이터와 FastAPI 구조이며, 다음 단계로 테스트 코드 연결에 관심이 있다.
이 한 줄이, 이전 수십 개 메시지를 전부 다시 보내는 것보다 훨씬 효율적일 수 있습니다.
6. 추천 구조: “원본 로그 + 압축 요약 + 최근 턴”
제가 실제로 제일 추천하는 건 이 구조입니다.
A. 원본 로그 테이블
모든 사용자/assistant 메시지를 저장
이건 복구와 분석용입니다.
B. 세션 요약 테이블
대화가 길어질 때 주기적으로 압축한 요약 저장
이건 모델 컨텍스트용입니다.
C. 최근 메시지 버퍼
가장 최근 N턴만 빠르게 가져오기
이건 즉시 응답 품질용입니다.
이 셋을 조합하면 모델 입력은 대략 이렇게 됩니다.
- instructions
- 세션 요약 1개
- 최근 N턴
- 현재 사용자 질문
이 구조는 진짜 밸런스가 좋습니다.
모든 걸 잃지 않으면서도, 매 요청이 너무 비대해지지 않아요.
7. FastAPI에서 이걸 어떻게 코드로 가져가나
이번 글은 학습용이라 DB 대신 in-memory 예제를 섞어 설명하겠지만, 구조는 실무형으로 보겠습니다.
프로젝트 구조 예시:
app/
├── main.py
├── core/
│ └── config.py
├── schemas/
│ └── chat.py
├── services/
│ ├── chat_history_service.py
│ ├── context_builder_service.py
│ └── openai_chat_service.py
└── repositories/
├── message_repository.py
└── session_summary_repository.py
역할은 이렇게 나누면 좋습니다.
message_repository.py
- 전체 메시지 저장/조회
- 최근 N턴 조회
session_summary_repository.py
- 세션 요약 저장/조회
chat_history_service.py
- 메시지 추가
- 턴 수 증가 시 compaction 필요 여부 판단
context_builder_service.py
- instructions
- 세션 요약
- 최근 N턴
- 현재 질문
이 4개를 합쳐서 모델 입력 구성
openai_chat_service.py
- 실제 OpenAI 호출
- 스트리밍 또는 일반 응답 처리
이렇게 나누면 “기록 저장”과 “모델에 보낼 입력 조립”이 분리돼서 정말 편합니다.
8. 먼저 Pydantic 모델부터 정리해봅시다
app/schemas/chat.py
from pydantic import BaseModel, Field
from typing import Literal
class ChatMessage(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(min_length=1, max_length=4000)
class ChatTurnRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=100)
message: str = Field(min_length=1, max_length=2000)
class SessionSummary(BaseModel):
session_id: str
summary: str = Field(min_length=1, max_length=3000)
여기선 원본 메시지와 세션 요약을 분리해서 둡니다.
9. 최근 N턴만 잘라오는 서비스
app/services/chat_history_service.py
from collections import defaultdict
from app.schemas.chat import ChatMessage
class InMemoryChatHistoryService:
def __init__(self) -> None:
self._messages: dict[str, list[ChatMessage]] = defaultdict(list)
def add_user_message(self, session_id: str, content: str) -> None:
self._messages[session_id].append(ChatMessage(role="user", content=content))
def add_assistant_message(self, session_id: str, content: str) -> None:
self._messages[session_id].append(ChatMessage(role="assistant", content=content))
def get_recent_messages(self, session_id: str, limit: int = 8) -> list[ChatMessage]:
return self._messages[session_id][-limit:]
def get_all_messages(self, session_id: str) -> list[ChatMessage]:
return list(self._messages[session_id])
이건 단순합니다.
지금은 메모리지만, 나중엔 그대로 DB repository로 바꾸기 쉬운 인터페이스예요.
10. 세션 요약 저장소도 하나 둡니다
app/services/session_summary_service.py
class InMemorySessionSummaryService:
def __init__(self) -> None:
self._summaries: dict[str, str] = {}
def get_summary(self, session_id: str) -> str | None:
return self._summaries.get(session_id)
def save_summary(self, session_id: str, summary: str) -> None:
self._summaries[session_id] = summary
이제 대화가 길어질수록 원본 메시지 전체 대신,
여기 저장된 summary를 같이 사용하게 됩니다.
11. 진짜 핵심: 모델 입력을 조립하는 Context Builder
여기가 이번 글의 핵심입니다.
app/services/context_builder_service.py
from app.schemas.chat import ChatMessage
class ContextBuilderService:
def build_messages(
self,
current_user_message: str,
recent_messages: list[ChatMessage],
session_summary: str | None = None,
) -> list[dict]:
messages: list[dict] = []
if session_summary:
messages.append({
"role": "system",
"content": (
"다음은 이전 대화의 요약입니다. "
"필요한 맥락으로만 참고하세요.\n"
f"{session_summary}"
)
})
for msg in recent_messages:
messages.append({
"role": msg.role,
"content": msg.content,
})
messages.append({
"role": "user",
"content": current_user_message,
})
return messages
이 서비스가 좋은 이유는 명확합니다.
- 히스토리 저장 방식이 바뀌어도
- 모델 입력 조립 규칙을 한 곳에서 관리할 수 있고
- “최근 8턴”, “요약 포함”, “현재 질문 추가” 같은 규칙을 여기서만 바꾸면 됩니다
이게 나중에 진짜 편해요.
12. compaction은 언제 돌릴까
이건 정답 하나가 있는 건 아니지만,
초기 서비스에선 아래 정도로 시작하면 무난합니다.
방법 1. 턴 수 기준
예: 메시지가 20개를 넘으면 요약 생성
방법 2. 문자 수 기준
예: 최근 제외 전체 본문 길이가 10,000자를 넘으면 요약 생성
방법 3. 토큰 수 기준
예: 모델 입력 예상 토큰이 특정 기준을 넘으면 요약 생성
OpenAI 문서가 context management 아래에 counting tokens를 따로 두는 걸 보면, 결국 운영 단계에선 토큰 길이를 직접 의식해야 한다는 방향이 분명합니다. (OpenAI 플랫폼)
처음엔 너무 정밀하게 안 가도 됩니다.
솔직히 주니어 단계에서는 “턴 수 20 넘으면 요약” 정도만 해도 꽤 쓸 만해요.
13. 요약은 누가 만드나
이건 보통 두 가지 방식이 있습니다.
방식 A. 메인 모델이 직접 요약
같은 OpenAI 모델에 “지금까지 대화를 짧게 요약해줘”라고 요청
방식 B. 더 작은 모델로 요약 전담
요약은 가벼운 모델이 맡고, 본 대화 응답은 더 좋은 모델이 맡음
처음엔 A로 가도 충분합니다.
나중에 비용이 커지면 B를 검토하면 돼요.
예를 들어 요약 생성 함수는 이렇게 갈 수 있습니다.
from openai import OpenAI
class ConversationCompactionService:
def __init__(self, client: OpenAI, model: str) -> None:
self.client = client
self.model = model
def summarize_messages(self, messages: list[dict]) -> str:
response = self.client.responses.create(
model=self.model,
instructions=(
"당신은 대화 요약기입니다. "
"중복을 줄이고, 다음 대화에 필요한 핵심 사실과 사용자 선호만 남기세요."
),
input=messages + [
{
"role": "user",
"content": "위 대화를 다음 요청에 재사용할 수 있게 짧고 핵심적으로 요약해줘."
}
],
)
return response.output_text.strip()
여기서 핵심은
“예전 대화를 예쁘게 요약”이 아니라,
다음 답변 품질에 필요한 정보만 남기는 압축이라는 점입니다.
14. 프롬프트 캐싱도 같이 알아둘 필요가 있습니다
OpenAI 문서 구조를 보면 context management 아래에 Prompt caching이 따로 있습니다. 즉, 반복되는 프롬프트 prefix를 캐시해 비용과 지연을 줄이는 흐름이 공식적으로 중요하게 다뤄집니다. (OpenAI 플랫폼)
이걸 실무 감각으로 풀면 이렇습니다.
- 항상 같은 instructions
- 항상 같은 시스템 정책
- 같은 형식의 고정 prefix
이런 것들은 매 요청마다 반복되기 쉽습니다.
이럴 때 prompt caching이 도움이 될 수 있어요.
그래서 실무에서는 아래 전략이 잘 맞습니다.
- 고정 instructions는 최대한 안정적으로 유지
- 자주 바뀌는 최근 대화는 뒤쪽에 붙임
- 오래된 히스토리는 compact summary로 짧게 유지
즉,
캐시되기 좋은 고정 prefix + 짧아진 가변 컨텍스트
이 조합이 운영에 유리해집니다.
15. DB와 Redis는 어떻게 역할을 나누면 좋을까
이 부분도 많이 헷갈립니다.
저는 보통 이렇게 봅니다.
DB
- 원본 대화 로그 영구 저장
- 세션 요약 영구 저장
- 감사/분석/복원용
Redis
- 최근 N턴 캐시
- 현재 세션 상태
- 빠른 조회용 임시 데이터
- 실시간 채팅 서버에서 자주 읽는 데이터
즉,
- DB는 진실의 원본(source of truth)
- Redis는 빠른 현재 상태 캐시
이렇게 나누면 구조가 깔끔합니다.
예를 들어 흐름은 이렇습니다.
- 새 메시지 오면 DB 저장
- 동시에 Redis 최근 메시지 리스트 갱신
- 모델 호출 시 Redis에서 최근 N턴 조회
- 세션 summary는 DB 또는 Redis에서 조회
- 일정 조건 넘으면 summary 재생성 후 저장
이렇게 하면 성능이 꽤 안정됩니다.
16. 주니어가 여기서 자주 하는 실수
실수 1. 원본 로그와 모델 입력을 같은 데이터로 취급한다
제일 흔합니다.
저장용과 추론용은 분리해야 합니다.
실수 2. 최근 N턴만 자르면 끝이라고 생각한다
짧은 대화는 괜찮지만, 오래된 핵심 전제가 날아갈 수 있습니다.
실수 3. summary를 너무 장황하게 만든다
요약인데 또 길면 compaction 의미가 약해집니다.
실수 4. summary를 갱신하지 않는다
한 번 만든 요약이 영원히 맞는 건 아닙니다.
대화 주제가 바뀌면 다시 압축해야 해요.
실수 5. 토큰 길이를 전혀 신경 안 쓴다
OpenAI 문서가 아예 counting tokens를 context management 주제로 분리해 둔 이유가 있습니다. 운영에선 길이를 의식해야 합니다. (OpenAI 플랫폼)
17. 지금 단계에서 추천하는 실전형 흐름
처음 서비스 만들 때는 이 정도가 가장 좋습니다.
- 모든 대화는 DB에 저장
- 최근 8턴은 Redis나 빠른 저장소에서 관리
- 세션당 summary 1개 유지
- 모델 입력은
- instructions
- session summary
- recent 8 turns
- current user message
로 구성
- 20턴 넘거나 토큰 길이 커지면 summary 재생성
이렇게만 해도 진짜 많이 좋아집니다.
괜히 처음부터 거대한 memory system 만들려고 하지 않아도 돼요.
18. 오늘 글의 핵심 요약
이번 글에서 꼭 가져가야 할 건 이것입니다.
채팅 히스토리는 전부 저장하되, 모델에는 전체를 다 보내지 말고 “요약 + 최근 턴 + 현재 질문” 구조로 압축해서 보내는 게 실무적으로 훨씬 낫다.
정리하면:
- 전체 로그는 DB에 저장
- 모델용 컨텍스트는 별도로 조립
- 최근 N턴 전략으로 1차 최적화
- 오래된 대화는 compaction summary로 압축
- context management에서는 counting tokens와 prompt caching도 함께 봐야 함 (OpenAI 플랫폼)
이 감각이 잡히면, 이제부터 채팅 서비스가 훨씬 “운영 가능한 구조”로 바뀝니다.
다음 편 예고
다음 글에서는 여기서 더 실무적으로 들어가겠습니다.
Python + FastAPI에서 RAG를 붙여, 대화 히스토리와 문서 검색을 함께 사용하는 방법
이 주제로,
- 채팅 기억과 문서 검색은 어떻게 다른지
- 히스토리 기반 답변과 검색 기반 답변을 언제 섞어야 하는지
- FAQ/사내문서/블로그 문서를 붙이는 흐름
- FastAPI 서비스 구조 확장
까지 이어가보겠습니다.
출처
- OpenAI API Reference — Responses API는 이전 응답을 입력으로 활용하는 stateful interaction을 지원. (OpenAI 플랫폼)
- OpenAI API docs navigation snippets — Run and scale 아래에 Conversation state, Context management, Compaction, Counting tokens, Prompt caching 항목이 별도로 존재. (OpenAI 플랫폼)
Python, OpenAI, FastAPI, 채팅 히스토리, Context Management, Compaction, Counting Tokens, Prompt Caching, Responses API, AI 채팅 서버, Redis, 대화 요약, Python 백엔드, 주니어 개발자, 운영형 AI 서비스
'study > Python으로 시작하는 OpenAI 개발 입문' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 개발블로그
- SEO최적화
- seo 최적화 10개
- Prisma
- nextJS
- flax
- JAX
- rag
- Express
- Next.js
- 백엔드개발
- Redis
- Docker
- fastapi
- JWT
- LangChain
- kotlin
- REACT
- Python
- ai철학
- DevOps
- node.js
- PostgreSQL
- llm
- CI/CD
- 생성형AI
- 쿠버네티스
- NestJS
- 딥러닝
- 웹개발
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |

