티스토리 뷰

반응형

LangChain long-term memory는 어떻게 붙일까? 사용자 선호를 대화 밖에서도 기억하는 방법

LangChain에서 short-term memory는 같은 대화(thread) 안의 문맥을 기억하고, long-term memory는 여러 대화를 넘어가는 사용자 정보를 기억합니다. 공식 문서도 short-term memory를 thread-scoped memory로, long-term memory를 세션을 넘어 공유되는 user-specific 또는 application-level data로 설명합니다. 또 long-term memory는 Store를 통해 custom namespace에 저장하고, semantic search도 붙일 수 있다고 안내합니다. 즉, “방금 한 말”은 thread_id로 이어가고, “이 사용자는 다크 모드를 좋아한다” 같은 정보는 user_id 기준의 long-term store로 분리하는 게 정석에 가깝습니다. (LangChain Docs)

이 지점에서 많은 분이 헷갈립니다.
인증도 붙였고, session_id도 받고, thread_id도 연결했는데…
그래서 이제 “기억”은 다 된 거 아닌가 싶거든요.

근데 아닙니다.

같은 사용자가 오늘은 재택근무 정책을 묻고,
내일은 출장비 정산을 묻고,
다음 주에는 “나는 답변 짧게 받는 걸 좋아해”라고 말할 수 있죠.

이런 건 대화 하나에만 갇혀 있으면 안 됩니다.
그래서 오늘은 LangChain long-term memory, 정확히는 LangGraph Store를 이용해 사용자 선호나 장기 정보를 저장하는 방식을 보겠습니다. (LangChain Docs)


한 줄로 먼저 정리

LangChain 프로젝트에서 long-term memory를 붙이려면 대화 상태는 thread_id로 유지하고, 사용자 장기 정보는 user_id 기반 namespace에 Store로 저장하는 구조가 가장 자연스럽습니다. LangChain 문서는 short-term memory는 thread 단위로, long-term memory는 namespace 단위로 저장된다고 설명하고, Store는 semantic search와 content filtering까지 지원한다고 안내합니다. (LangChain Docs)


이 글에서 다루는 내용

이번 글에서는 아래를 같이 봅니다.

  • LangChain long-term memory가 정확히 뭔지
  • thread_id와 user_id를 왜 분리해야 하는지
  • profile 방식과 collection 방식 중 뭘 쓰면 좋은지
  • FastAPI 백엔드에서 long-term memory를 붙이는 가장 작은 코드
  • 나중에 InMemoryStore에서 Postgres/Redis 계열로 어떻게 커질지

LangChain long-term memory란 무엇인가?

LangChain 메모리 개요 문서는 long-term memory를 여러 대화나 세션을 넘어 유지되는 기억으로 설명합니다. short-term memory가 하나의 thread 안에서만 이어지는 반면, long-term memory는 어떤 thread에서도 꺼내 쓸 수 있고, custom namespace로 범위를 정할 수 있습니다. 공식 문서 표현 그대로 가져오면, short-term memory는 thread-scoped이고 long-term memory는 namespace-scoped라고 보면 이해가 쉽습니다. (LangChain Docs)

쉽게 말하면 이렇습니다.

  • short-term memory
    지금 대화창에서만 통하는 문맥
  • long-term memory
    다음 주, 다음 달, 다른 대화창에서도 다시 꺼낼 수 있는 사용자 정보

예를 들면:

  • “이 사용자는 답을 짧게 받는 걸 좋아한다”
  • “이 사용자는 재택근무보다 출장 정책을 자주 묻는다”
  • “이 사용자는 다크 모드를 선호한다”

이런 건 long-term memory 후보입니다. (LangChain Docs)


왜 thread_id와 user_id를 나눠야 할까

이건 정말 중요합니다.

LangGraph persistence 문서와 short-term memory 문서는 같은 대화를 이어가려면 configurable.thread_id를 넘겨야 한다고 설명합니다. 반면 long-term memory 예제는 namespace = ("memories", user_id)처럼 사용자 기준 namespace를 만들어 store에서 검색하고 저장하는 방식을 보여줍니다. 즉 공식 문서 흐름 자체가 thread와 user를 다르게 취급하고 있어요. (LangChain Docs)

실무에서는 보통 이렇게 봅니다.

  • user_id: 사람
  • thread_id: 대화창

같은 사람도 여러 대화를 열 수 있으니,
이 둘을 같은 값으로 고정하면 나중에 금방 꼬입니다.

예를 들어:

  • user_id = alice
  • thread_id = alice-chat-1
  • thread_id = alice-chat-2

이렇게 가는 편이 훨씬 자연스럽습니다.
오늘은 이 구조를 기준으로 설명할게요. (LangChain Docs)


long-term memory는 두 가지 방식으로 많이 나뉜다

LangChain 문서는 semantic memory를 다룰 때 long-term memory를 크게 profile 방식collection 방식으로 설명합니다. profile은 하나의 JSON 문서를 계속 업데이트하는 방식이고, collection은 여러 개의 memory 문서를 쌓아가는 방식입니다. 문서도 각각 장단점을 분명히 적고 있어요. profile은 구조적이지만 커질수록 업데이트가 까다롭고, collection은 recall이 좋아질 수 있지만 검색/정리 복잡성이 올라갑니다. (LangChain Docs)

1) Profile 방식

예:

{
  "response_style": "short",
  "theme_preference": "dark",
  "department": "finance"
}

장점은 깔끔합니다.
사용자 프로필처럼 보기 좋아요.

단점은 업데이트가 점점 까다로워진다는 점입니다.
필드가 많아질수록 LLM이 기존 프로필을 안전하게 갱신하는 게 어려워질 수 있습니다. (LangChain Docs)

2) Collection 방식

예:

  • user prefers short answers
  • user works in finance team
  • user likes dark mode

이런 개별 memory를 문서처럼 계속 쌓는 방식입니다.

장점은 새 정보를 추가하기 쉽고, downstream recall이 좋아질 수 있다는 점입니다.
공식 문서도 collection 접근이 더 높은 recall로 이어질 수 있다고 설명합니다. (LangChain Docs)

단점은 검색과 정리가 더 중요해진다는 점입니다.
그래서 semantic search가 자주 같이 붙습니다. (LangChain Docs)


처음엔 profile보다 collection이 더 편하다

제 경험상, 주니어가 처음 붙일 땐 collection 방식이 더 편합니다.

왜냐면 profile은 “기존 JSON을 안전하게 업데이트”하는 문제가 붙는데,
이게 생각보다 까다롭거든요.

반면 collection은:

  • 새 정보 하나 저장
  • 비슷한 질문이 오면 semantic search로 꺼내기

이 구조라서 훨씬 직관적입니다.

LangChain 문서도 collection 방식이 새 정보를 추가하기 쉽고 recall에 유리할 수 있다고 설명합니다. 그래서 오늘 예제도 collection 스타일로 갈 겁니다. (LangChain Docs)


가장 작은 long-term memory 예제

공식 문서에 나온 패턴을 최대한 따라가되,
지금 우리 시리즈 흐름에 맞게 조금 더 읽기 쉽게 정리해보겠습니다.

설치

pip install -U langchain langgraph langchain-openai

LangGraph 메모리 가이드는 InMemoryStore와 InMemorySaver를 사용한 예제를 보여주고, semantic search를 위해 init_embeddings("openai:text-embedding-3-small") 패턴을 사용합니다. (LangChain Docs)

코드

import uuid
from dataclasses import dataclass

from langchain.chat_models import init_chat_model
from langchain.embeddings import init_embeddings
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.runtime import Runtime
from langgraph.store.memory import InMemoryStore


@dataclass
class Context:
    user_id: str


model = init_chat_model("openai:gpt-4o-mini")
embeddings = init_embeddings("openai:text-embedding-3-small")

# short-term memory: thread 체크포인터
checkpointer = InMemorySaver()

# long-term memory: user namespace store
store = InMemoryStore(
    index={
        "embed": embeddings,
        "dims": 1536,
    }
)


def call_model(state: MessagesState, runtime: Runtime[Context]):
    user_id = runtime.context.user_id
    namespace = ("memories", user_id)

    # 현재 질문과 관련 있는 long-term memories 검색
    memories = runtime.store.search(
        namespace,
        query=str(state["messages"][-1].content),
        limit=3,
    )
    memory_text = "\n".join(item.value["data"] for item in memories)

    system_msg = (
        "너는 사내 업무 도우미다. "
        "사용자의 장기 기억을 참고해서 더 개인화된 답을 하되, "
        "없는 사실은 추측하지 마라.\n\n"
        f"[사용자 장기 기억]\n{memory_text}"
    )

    last_message = state["messages"][-1].content.lower()

    # 아주 단순한 hot-path memory 저장 예시
    if "짧게 답해" in last_message:
        runtime.store.put(
            namespace,
            str(uuid.uuid4()),
            {"data": "User prefers short answers"},
        )
    if "다크모드 좋아" in last_message:
        runtime.store.put(
            namespace,
            str(uuid.uuid4()),
            {"data": "User prefers dark mode"},
        )

    response = model.invoke(
        [{"role": "system", "content": system_msg}] + state["messages"]
    )
    return {"messages": response}


builder = StateGraph(MessagesState, context_schema=Context)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")

graph = builder.compile(
    checkpointer=checkpointer,
    store=store,
)

# 첫 대화: 선호 저장
config = {
    "configurable": {"thread_id": "alice-chat-1"},
    "context": {"user_id": "alice"},
}

graph.invoke(
    {"messages": [{"role": "user", "content": "앞으로는 짧게 답해줘"}]},
    config,
)

# 다른 대화창에서도 같은 user_id면 long-term memory 재사용
config2 = {
    "configurable": {"thread_id": "alice-chat-2"},
    "context": {"user_id": "alice"},
}

result = graph.invoke(
    {"messages": [{"role": "user", "content": "재택근무 정책 알려줘"}]},
    config2,
)

print(result["messages"][-1].content)

이 코드의 핵심은 세 가지입니다.

  • checkpointer=InMemorySaver()
    같은 thread 안의 short-term memory를 관리합니다. (LangChain Docs)
  • store=InMemoryStore(...)
    여러 대화를 넘어가는 long-term memory 저장소입니다. (LangChain Docs)
  • namespace = ("memories", user_id)
    사용자별로 memory를 분리하는 핵심 키입니다. 공식 문서 예제도 ("memories", user_id) 또는 (user_id, "memories")처럼 namespace를 구성합니다. (LangChain Docs)

이 코드가 실제로 하는 일

이걸 정말 쉽게 말하면 이렇습니다.

첫 번째 대화에서는
“앞으로는 짧게 답해줘”라는 말을 보고
User prefers short answers를 사용자 memory store에 저장합니다.

그 다음 두 번째 대화창에서는
thread_id는 달라도 user_id가 같기 때문에
그 long-term memory를 semantic search로 다시 찾아와서 system message에 붙입니다. (LangChain Docs)

즉:

  • alice-chat-1
    대화 기억은 여기만
  • alice-chat-2
    대화는 새로 시작하지만, alice의 장기 기억은 재사용

이게 바로 long-term memory의 진짜 역할입니다.
대화창이 바뀌어도 사용자 정보를 이어주는 거죠. (LangChain Docs)


FastAPI 백엔드에서는 어떻게 붙일까

이제 이걸 우리 FastAPI 구조에 맞춰 생각해보면 훨씬 명확해집니다.

요청에서 받는 것

  • session_id
  • 인증된 current_user

LangChain 호출에 넘기는 것

  • thread_id = session_id
  • context.user_id = current_user.username

즉 service 계층에서는 대략 이런 느낌이 됩니다.

def ask_assistant(session_id: str, question: str, current_user: User):
    config = {
        "configurable": {"thread_id": session_id},
        "context": {"user_id": current_user.username},
    }

    return graph.invoke(
        {"messages": [{"role": "user", "content": question}]},
        config,
    )

이 구조가 좋은 이유는 명확합니다.

  • short-term memory는 thread 기준
  • long-term memory는 user 기준
  • HTTP 인증과 LangChain state가 자연스럽게 연결됨

공식 문서 예제도 runtime context에서 user_id를 꺼내 namespace를 만드는 흐름을 보여줍니다. (LangChain Docs)


hot path로 저장할까, background로 저장할까

반응형

LangChain 메모리 개요 문서는 memory writing 방식으로 in the hot pathin the background를 구분합니다. hot path는 응답 과정 안에서 바로 기억을 저장하는 방식이고, background는 별도 비동기 작업으로 나중에 메모리를 생성하는 방식입니다. 공식 문서는 hot path의 장점으로 즉시 반영을, 단점으로 latency와 복잡성 증가를 설명합니다. (LangChain Docs)

hot path

장점:

  • 바로 저장됨
  • 다음 턴부터 즉시 반영 가능

단점:

  • 응답 느려질 수 있음
  • 매번 “무엇을 기억할지” 판단해야 함

background

장점:

  • 사용자 응답 latency 덜 건드림
  • 별도 정책으로 memory 정리 가능

단점:

  • 바로 반영되지 않을 수 있음
  • 아키텍처가 조금 더 복잡해짐

처음엔 hot path가 더 이해하기 쉽습니다.
오늘 예제도 그래서 hot path로 갔습니다. (LangChain Docs)


semantic search를 왜 memory에도 쓰나

이건 처음 보면 조금 신기합니다.

문서 검색(RAG)에만 semantic search를 쓰는 줄 알았는데,
memory store에도 똑같이 쓸 수 있습니다.

LangGraph add-memory 문서는 InMemoryStore(index={...})로 semantic search를 활성화하고, store.search(namespace, query=...)로 의미 기반 검색을 할 수 있다고 설명합니다. 예시로 “I love pizza”, “I am a plumber”를 저장하고 "I'm hungry" 같은 질문으로 관련 memory를 찾는 흐름까지 보여줍니다. (LangChain Docs)

이게 중요한 이유는 단순합니다.

사용자가 항상 같은 표현으로 말하지 않기 때문입니다.

예를 들어 저장된 memory가

  • User prefers short answers

인데, 사용자가 나중에

  • “간단하게 말해줘”
  • “길게 말하지 마”
  • “짧게 부탁해”

처럼 말할 수 있죠.

이럴 때 semantic search가 꽤 잘 맞습니다.
그래서 long-term memory에도 embedding이 자연스럽게 붙습니다. (LangChain Docs)


운영으로 가면 InMemoryStore를 그대로 쓰면 안 되는 이유

공식 문서는 개발 단계에서는 InMemorySaver와 InMemoryStore 예제를 많이 보여주지만, production에서는 DB-backed checkpointer와 store를 쓰라고 설명합니다. short-term memory 가이드는 Postgres checkpointer 예시를 제공하고, add-memory 가이드도 PostgresStore, RedisStore 같은 운영용 저장소 예시를 함께 보여줍니다. (LangChain Docs)

이유는 뻔합니다.

  • 서버 재시작 시 메모리 날아감
  • 멀티 인스턴스에서 공유 안 됨
  • 운영 데이터 보존 불가

그래서 운영에선 보통 이렇게 갑니다.

  • short-term memory
    PostgresSaver 같은 persistent checkpointer
  • long-term memory
    PostgresStore 또는 Redis 기반 store

즉 지금 글의 코드는 구조를 이해하는 출발점이고,
저장소만 운영용으로 바꾸면 다음 단계로 자연스럽게 갈 수 있습니다. (LangChain Docs)


자주 하는 실수

1. thread_id만 있으면 long-term memory도 해결된다고 생각한다

아닙니다. thread_id는 대화창 단위입니다.
장기 기억은 user namespace가 필요합니다. (LangChain Docs)

2. 모든 사용자 정보를 하나의 JSON profile에 몰아넣는다

초반엔 편해 보여도 커지면 갱신이 어려워질 수 있습니다.
collection 방식이 더 다루기 쉬운 경우가 많습니다. (LangChain Docs)

3. 아무 말이나 다 메모리에 저장한다

그러면 금방 노이즈가 쌓입니다.
무엇을 저장할지 정책이 꼭 필요합니다. 공식 문서도 “무엇을 언제 업데이트할지”가 핵심 질문이라고 설명합니다. (LangChain Docs)

4. memory 저장을 프롬프트로만 통제한다

가능하면 hot path 조건이나 service 레벨 정책처럼 deterministic한 조건도 같이 쓰는 게 좋습니다.

5. 운영에서 InMemoryStore 그대로 간다

이건 진짜 금방 한계를 만납니다.
운영에선 persistent store가 필요합니다. (LangChain Docs)


비교: short-term memory vs long-term memory

구분short-term memorylong-term memory

범위 같은 thread 여러 thread / 여러 세션
thread_id namespace, 보통 user_id 포함
용도 현재 대화 문맥 사용자 선호, 장기 정보
저장 방식 checkpointer store
대표 예 방금 물어본 정책 이 사용자는 짧은 답을 선호

이 비교를 머리에 넣어두면,
LangChain memory 구조가 훨씬 덜 헷갈립니다. 공식 문서도 이 둘을 아주 분명하게 나눠 설명합니다. (LangChain Docs)


FAQ

Q. LangChain에서 long-term memory는 꼭 LangGraph Store를 써야 하나요?

꼭 그래야만 하는 건 아니지만, 공식 문서 기준으로는 LangGraph Store가 가장 표준적인 접근입니다. namespace 기반 저장과 semantic search까지 같이 가져갈 수 있어서 자연스럽습니다. (LangChain Docs)

Q. session_id와 thread_id는 같은 값으로 써도 되나요?

보통은 괜찮습니다. 중요한 건 의미를 분명히 하는 겁니다.
프론트에서 보내는 session_id를 내부의 thread_id로 매핑하는 방식이 가장 단순합니다. (LangChain Docs)

Q. 사용자 선호는 profile이 좋나요, collection이 좋나요?

처음엔 collection이 더 다루기 쉽습니다. profile은 깔끔하지만, 업데이트 로직이 커질수록 까다로워질 수 있습니다. LangChain 문서도 두 방식의 장단점을 각각 설명합니다. (LangChain Docs)

Q. “짧게 답해줘” 같은 선호는 언제 저장해야 하나요?

초기에는 hot path에서 간단한 조건으로 저장해도 충분합니다. 나중에 latency가 아쉬워지면 background 처리로 넘기는 걸 고려하면 됩니다. 공식 문서도 hot path와 background를 구분해 설명합니다. (LangChain Docs)

Q. 운영에서는 어떤 저장소가 좋나요?

개발 단계에서는 InMemoryStore, 운영 단계에서는 Postgres/Redis 계열 persistent store가 더 적합합니다. 문서도 production에서는 DB-backed checkpointer와 store를 쓰라고 안내합니다. (LangChain Docs)


핵심 요약

LangChain long-term memory의 핵심은 다음 세 줄로 정리됩니다.

  • 대화 문맥은 thread_id로 관리한다
  • 사용자 장기 정보는 user_id namespace로 관리한다
  • short-term memory와 long-term memory를 섞지 않는다

그리고 실제 구현은 생각보다 단순합니다.

  • checkpointer로 thread state 유지
  • store로 user memory 저장
  • context.user_id로 namespace 분리
  • semantic search로 관련 memory 검색

이 구조가 잡히면,
이제 사내 도우미가 “대화만 이어지는 챗봇”을 넘어서
사용자를 점점 더 이해하는 시스템으로 가기 시작합니다. (LangChain Docs)


마무리

저는 long-term memory를 처음 붙였을 때
되게 거창한 기능을 만들고 있다고 생각했어요.

근데 막상 해보니까 출발은 꽤 소박하더라고요.

  • user_id 하나 나누고
  • namespace 하나 만들고
  • 기억할 만한 정보만 조금 저장하고
  • 다음 대화에서 다시 꺼내는 것

결국 중요한 건 “많이 기억하는가”보다
무엇을 어떤 키로 분리해서 저장하는가였습니다.

그게 잡히니까
short-term memory도 덜 헷갈리고,
멀티테넌시도 덜 무섭고,
사내 도우미가 좀 더 실제 서비스처럼 느껴졌어요.

그 차이가 꽤 큽니다.


다음 글 예고

다음 글에서는
LangChain long-term memory 품질은 어떻게 올릴까? 기억할 정보 추출, 중복 제거, profile vs collection 튜닝 방법
으로 이어가겠습니다.

이제부터는 “기억을 붙였다”를 넘어서,
어떤 정보를 저장해야 진짜 도움이 되는지 본격적으로 다루게 될 거예요.


출처

  • LangChain Memory overview: short-term memory는 thread-scoped, long-term memory는 namespace-scoped user/application data로 설명. (LangChain Docs)
  • LangGraph Add memory: InMemoryStore, PostgresStore, semantic search, store.put/search, context.user_id 기반 namespace 예시 제공. (LangChain Docs)
  • FastAPI Bigger Applications / Settings: 여러 파일 구조와 설정 분리 권장. (FastAPI)

LangChain, LangGraph, long-term memory, short-term memory, thread_id, user_id, AI Agent, 사내도우미, 멀티테넌시, 주니어개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
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
글 보관함
반응형