분명 5분 전에 이름을 알려줬는데 챗봇이 다시 묻는다. 모델이 멍청해서가 아니다. 상용 대형 언어 모델(LLM, 사람 글을 잔뜩 학습해 다음 문장을 만들어내는 AI)은 요청과 요청 사이에 아무것도 들고 있지 않다. 매 호출이 백지에서 시작한다. "어제 얘기한 그거요"라고 말해도, 모델에게는 그 문장 하나만 뚝 떨어져 들어간다.
그런데도 대화가 이어지는 것처럼 느껴지는 건, 앱이 매 요청마다 지금까지의 대화를 텍스트로 다시 실어 모델에게 건네기 때문이다. 사용자 눈에는 매끄러운 대화지만, 서버 뒤에서는 매번 "여태 이런 얘길 나눴고, 이번 질문은 이거다"라는 뭉치를 새로 조립한다. 챗봇의 기억은 사실 기억이 아니라, 매번 다시 들려주는 요약 브리핑에 가깝다.
그러니 설계의 질문은 하나로 좁혀진다. 그 이력을 어디에 두고, 언제 다시 꺼내는가. 답은 싱겁다. 데이터베이스(DB)에 둔다. 진짜 어려운 건 그다음, 속도를 올리려고 캐시를 붙이는 순간부터다.
이 이력 관리를 흔히 "세션"이라 부른다. 그리고 "세션은 원래 금방 사라지는 것"이라고 뭉뚱그린다. 틀렸다. 세션이 얼마나 오래 사느냐는 세션의 본질이 아니라 어디에 저장했느냐가 정한다.
실제로 Django의 세션은 기본값이 DB 백엔드다. "재시작하면 대화가 날아간다"는 건 세션이라서가 아니라, 세션을 메모리에만 뒀을 때 생기는 증상일 뿐이다. 저장 위치를 오래 남는 곳으로 옮기면 그 사고 자체가 없어진다.
가장 단순하고 가장 튼튼한 형태는 이렇다. 대화 원본을 DB에 두고, 요청이 올 때마다 최근 몇 턴(주고받은 대화 몇 번)을 조회해 프롬프트를 다시 만든다. 캐시가 없으니 캐시가 낡을 일도 없다.
def build_prompt(conn, conversation_id, new_message):
# 사용자 발언을 먼저 영구 기록한다 — 응답 생성 중 서버가 죽어도 발언은 유실되지 않는다
append_turn(conn, conversation_id, role="user", content=new_message)
# 원본에서 최근 N턴을 매 요청 조회 — 방금 넣은 발언까지 포함된다
history = fetch_recent_turns(conn, conversation_id, limit=20)
return assemble_prompt(history)
여기 두 가지 원칙이 숨어 있다. 하나, 사용자 메시지는 응답을 다 받은 뒤가 아니라 들어온 즉시 기록한다. 생성 도중 서버가 꺼져도 사용자가 한 말은 남는다. 둘, 프롬프트는 원본에서 만든다. 다른 기기에서 이어 쓰든 방금 서버가 바뀌었든, 원본을 읽으니 늘 최신이다.
대부분의 챗봇은 여기까지로 충분하다. 캐시는 조회가 정말 느려진 걸 확인한 다음에 얹어도 늦지 않다.
조회가 병목이 되어 캐시(자주 쓰는 데이터를 가까이 복사해 두는 임시 저장소)를 얹고 싶어진다. 이때 흔히 나오는, 그러나 틀린 발상이 있다. "가장 큰 턴 번호를 기억해 뒀다가, DB의 최댓값이 그대로면 캐시를 그냥 쓰자."
왜 안 되는지는 두 사람이 거의 동시에 말하는 장면을 따라가면 보인다. 턴 번호가 1씩 늘어난다고 하자.
최댓값이 11에서 꿈쩍하지 않으니, 캐시는 "바뀐 게 없네"라고 판단한다. 그리고 10번이 빠진 이력을 계속 내보낸다. 사용자는 분명 A라는 말을 먼저 했는데, 챗봇의 답에는 그 말이 통째로 증발한 것처럼 보인다. 10번 메시지가 "납기를 2주 당겨달라"였다면, 챗봇은 그 요청을 못 들은 사람처럼 군다. 데이터는 DB에 멀쩡히 있는데도.
더 고약한 건, 이게 에러로 뜨지 않는다는 점이다. 서버는 200 OK를 돌려주고, 로그도 깨끗하다. 그저 답이 조금 이상할 뿐이다. 캐시는 속도를 위한 지름길일 뿐 진실의 원본이 아니다. 지름길을 내기로 했다면, 길이 낡았을 때 닫는 규칙까지 함께 세워야 사고가 안 난다.
여기부터는 실제 해법을 코드로 짚는다. 위 문제의 결론만 가져가도 되는 독자는 다음 절로 건너뛰어도 좋다.
핵심 교체는 하나다. "가장 큰 번호" 대신 어떤 변경에서든 1씩 오르는 버전 카운터를 쓴다. 추가든 수정이든 삭제든 변경이 일어나면 버전이 오른다. 캐시에는 이력과 함께 그때의 버전을 적어 둔다. 읽을 때 DB의 현재 버전과 비교해, 다르면 다시 읽는다.
이러면 앞의 함정이 사라진다. 11번이 먼저, 10번이 나중에 확정돼도, 나중 확정이 버전을 한 번 더 올린다. 다음 읽기에서 버전이 어긋나니 캐시를 버리고 원본을 다시 읽는다. 최댓값이 못 잡던 "기존 턴 수정·삭제"도 똑같이 잡힌다.
def get_history(conn, conversation_id, cache):
cached = cache.get(conversation_id)
with conn.begin():
# 버전과 턴을 '같은 순간의 스냅샷'으로 읽기 위해 격리 수준을 올린다(아래 설명)
conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
version = conn.execute(
"SELECT version FROM conversations WHERE id = :cid",
{"cid": conversation_id},
).scalar()
if cached and cached["version"] == version:
return cached["turns"] # 버전 일치 → 무거운 턴 조회 자체를 생략
turns = conn.execute(
"SELECT role, content FROM turns "
"WHERE conversation_id = :cid ORDER BY seq",
{"cid": conversation_id},
).all()
cache.set(conversation_id, {"version": version, "turns": turns})
return turns
읽는 쪽은 값싼 버전 조회로 캐시 적중을 먼저 가린 뒤, 어긋날 때만 무거운 턴 조회를 한다.
주석의 "같은 순간의 스냅샷"이 왜 필요한지는 짚고 넘어갈 만하다. 버전을 읽는 찰나와 턴을 읽는 찰나 사이에 다른 쓰기가 끼어들면, "버전 번호와 실제 턴이 어긋난 쌍"을 캐시에 박아 넣을 수 있다. 이를 막으려고 DB는 한 트랜잭션(여러 작업을 전부 성공하거나 전부 취소하는 하나의 묶음)이 처음 본 시점의 데이터만 계속 보게 하는 기능을 제공한다. PostgreSQL에서 이 수준이 REPEATABLE READ다. 기본값 READ COMMITTED에서는 문장마다 시점이 새로 잡혀 그 틈이 생긴다. 정확한 의미는 PostgreSQL 트랜잭션 격리 문서에 있다.
버전 카운터는 "캐시가 어긋났는지"는 알려주지만, "번호를 매긴 순서와 실제 확정된 순서가 뒤집히는" 문제까지 지워 주지는 않는다. 두 확정 사이의 짧은 틈에, 어떤 읽기는 11번은 있는데 10번은 아직 없는 이력을 잠깐 본다. 캐시는 다음 읽기에서 바로잡히지만, 그 찰나의 "순간적 시간여행"까지 없애고 싶다면 번호를 매기기 전부터 대화 단위로 잠금을 건다.
def append_turn(conn, conversation_id, role, content):
with conn.begin(): # 커밋 시점에 잠금 해제
# 1) seq 배정 '이전'에 대화 행을 잠근다(FOR UPDATE) → 동시 쓰기가 한 번에 하나씩
conn.execute(
"SELECT id FROM conversations WHERE id = :cid FOR UPDATE",
{"cid": conversation_id},
)
# 2) 잠금 안에서 다음 seq 계산 → 배정 순서와 커밋 순서가 어긋나지 않는다
seq = conn.execute(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM turns WHERE conversation_id = :cid",
{"cid": conversation_id},
).scalar()
conn.execute(
"INSERT INTO turns (conversation_id, seq, role, content) "
"VALUES (:cid, :seq, :role, :content)",
{"cid": conversation_id, "seq": seq, "role": role, "content": content},
)
# 3) 모든 변경에서 버전을 올린다 → 캐시 무효화의 단일 신호
conn.execute(
"UPDATE conversations SET version = version + 1 WHERE id = :cid",
{"cid": conversation_id},
)
FOR UPDATE는 "이 행을 내가 다 쓸 때까지 다른 쓰기는 기다려"라는 잠금이다. A와 B가 같은 대화에 동시에 쓰려 하면 하나만 잠금을 얻고, 나머지는 자기 번호를 계산조차 못 한 채 기다린다. "11이 10보다 먼저 확정되는" 상황이 아예 생기지 않는다. 잠금은 대화 단위라 서로 다른 대화의 쓰기는 여전히 나란히 흐른다. 동작의 세부는 PostgreSQL 명시적 잠금 문서에 정리돼 있다.
여기까지가 기억을 "잃지 않는" 기술이다. 지금부터는 기억을 "잘 쓰는" 기술이다. 아무리 정확히 쌓아 둬도, 그 이력을 모델에게 어떻게 건네느냐에 따라 답의 질이 갈린다.
프로젝트가 몇 달 이어지면 이력이 수백 턴을 넘는다. 그걸 통째로 매번 프롬프트에 욱여넣는 건 능사가 아니다. Liu 등(2023)의 Lost in the Middle 연구는, 긴 입력에서 핵심 정보가 한가운데 있을 때 모델이 그걸 놓치기 쉽다는 U자형 경향을 보고했다. 앞과 뒤는 잘 보는데 가운데는 흐릿하다.
세 시간짜리 회의를 떠올리면 감이 온다. 첫 안건과 마지막 결론은 또렷한데, 두 시간째의 세부 논의는 잘 기억나지 않는다. 그래서 이력이 길수록 "다 넣기"가 아니라 자리 배치가 필요하다. 확정된 결정은 짧게 요약해 맨 앞에 고정하고, 최근 몇 턴은 원문 그대로 맨 뒤에 둔다. 오래된 구간은 요약으로 압축해 가운데를 짧게 지나가게 한다. 중요한 정보를 모델이 잘 보는 자리에서 만나게 하는 것이다.
한 가지 더. 같은 프로젝트라도 참여자마다 대화창을 나눠야 한다. 이건 편의가 아니라 사생활 문제다. 팀원 A가 개인적으로 남긴 우려가 B의 창에 섞이는 순간 신뢰가 깨진다. DB 설계로 옮기면, 이력을 찾는 열쇠가 "프로젝트 ID" 하나가 아니라 "프로젝트 ID + 사용자 ID"의 조합이어야 한다는 뜻이다. 일정이나 산출물 같은 공용 정보만 따로 공유한다.
온틀의 AI PM을 만들며 우리도 이 함정을 그대로 밟았다. 초기 버전은 "가장 큰 턴 번호가 그대로면 캐시 재사용" 방식이었다. 평소엔 멀쩡했다. 문제는 한 고객이 "납기를 2주 당겨달라"고 요청한 순간 터졌다. 그 메시지를 DB에 쓰는 작업과, AI PM이 바로 답을 만들며 자기 발언을 쓰는 작업이 거의 동시에 돌았다. 번호 최댓값은 꿈쩍 안 했고, 캐시는 납기 변경 턴이 빠진 이력을 다음 응답에 내보냈다. AI PM은 방금 들은 납기 변경을 못 들은 척했다. 우리는 버전 카운터로 갈아탄 뒤에야 이 증발이 멈췄다.
지금은 고객이 새 기기에서 접속해도, 서버가 재배포된 직후에도 AI PM이 "저번에 어디까지 얘기했죠?"라고 되묻지 않는다. 그 납기 변경 요청은 담당자 본인의 대화창에만 남고, 팀의 다른 담당자 화면에는 새지 않는다. 다만 변경된 납기 자체는 프로젝트 공용 정보로 올라가 모두가 같은 일정을 본다. 수백 턴이 쌓인 뒤에도 이 결정은 요약되어 프롬프트 앞자리를 지킨다.
기술만 보면 평범하다. 하지만 이게 깨지면 고객은 프로젝트를 매번 처음부터 다시 설명해야 하고, 그 피로가 쌓이면 신뢰가 흔들린다. 회의록을 꼬박꼬박 남기는 팀과 그렇지 않은 팀의 차이가 몇 달 뒤 속도로 드러나듯, 대화 이력을 다루는 방식은 서비스의 밑바닥 체력이다.
본문의 원칙은 그대로 쓰면서, 여러 서버가 같은 캐시를 공유한다는 점이 더해진다. 장점은 한 서버가 갱신한 캐시를 다른 서버도 즉시 보니, 서버마다 제각각 낡은 복사본을 들고 있는 사고가 줄어든다는 것이다. 대신 캐시가 또 하나의 외부 의존성이 되므로, 캐시가 잠깐 죽어도 DB 원본만으로 정상 동작하도록 "캐시 미스 = 원본 조회" 경로를 반드시 살려 둬야 한다. 캐시를 못 믿을 상황을 기본값으로 설계하는 셈이다.
안 된다. 잠금은 트랜잭션을 짧게 유지할 때만 안전하다. 모델 호출은 몇 초씩 걸리는 느린 작업이라, 그동안 잠금을 쥐고 있으면 같은 대화의 다른 쓰기가 전부 그 시간만큼 막힌다. 규칙은 단순하다. 잠금 구간 안에서는 DB 작업만 하고, 모델 호출 같은 느린 일은 잠금 밖에서 한다.
요청이 들어온 그 순간에 매번 요약하면 응답이 느려지므로, 보통 백그라운드 작업으로 미리 만들어 둔다. 예를 들어 턴이 일정 수를 넘길 때마다 오래된 구간을 요약해 따로 저장해 두고, 프롬프트를 조립할 때는 그 저장된 요약을 꺼내 쓰는 식이다. 요약도 결국 하나의 데이터이므로, 원본이 바뀌면 다시 만들어야 한다는 점에서 캐시와 같은 무효화 고민이 똑같이 따라붙는다.
버전 조회는 정수 한 칸을 읽는 가벼운 작업이지만, 극단적으로 몰리면 이것도 DB에 부담이 된다. 그럴 땐 버전 자체를 아주 짧은 시간(수백 밀리초 수준)만 캐시하거나, 쓰기 때 새 버전을 캐시에 직접 밀어 넣어 읽기 쪽의 DB 왕복을 줄인다. 정합성을 아주 조금 느슨하게 내주고 부하를 더는 절충이라, 서비스가 그 짧은 지연을 감당할 수 있을 때만 쓴다.