JSONL을 활용한 AI 대화 맥락 보존 및 필터링

약 18분 읽기

세션이 끝나면 맥락이 사라지는 문제

AI와의 대화를 통해 다양한 아이디어를 얻거나 문제를 해결하려는 사람들에게는 대화의 맥락을 보존하는 것이 필수적이다. 그러나 여러 세션에 걸쳐 대화를 진행하다 보면, 각 세션이 끝날 때마다 대화의 맥락이 사라져 버리는 문제가 발생한다. 매번 새로운 세션을 시작할 때 과거에 논의했던 내용을 다시 떠올려야 하는 상황은 불편함을 초래한다. 이를 방지하기 위해, 대화의 기록을 체계적으로 보존할 필요가 있었다.

처음에는 대화 내용을 그대로 저장하는 방법을 시도했다. 하지만 이렇게 하면 쓸모없는 대화까지 모두 저장되어 필요 없는 정보가 쌓이는 경우가 많았다. 예를 들어, “안녕하세요”, “맞습니다” 같은 간단한 응답들은 맥락을 이해하는 데 큰 도움을 주지 못한다. 그래서 이런 무의미한 대화를 필터링하는 기능을 추가하게 되었다. 대화의 질을 높이고 실제로 유용한 정보만 저장하기 위해 필터링 로직을 여러 번 수정했다.

또한, 대화의 내용을 주제별로 분류하여 저장하면 나중에 특정 주제를 다시 탐색할 때 용이하다는 점을 깨달았다. 하지만 이렇게 주제별로 나누는 작업은 처음에는 수작업으로 진행했기에 많은 시간이 소요되었다. 이를 개선하기 위해 키워드를 통해 자동으로 주제를 분류하는 시스템을 구현했다. 이 과정에서 키워드를 어떻게 설정할지, 중복되는 주제는 어떻게 처리할지를 고려해야 했으며, 여러 시행착오를 거쳐 점차 개선해 나갔다.

결국, 세션이 끝나면 맥락이 사라지는 문제를 해결하기 위해서는 대화 내용을 체계적으로 저장하고 분류하는 것뿐만 아니라, 필요 없는 정보를 걸러내는 작업도 필수적이라는 결론에 도달했다. 이렇게 해서 다음 세션에서 과거의 대화 내용을 쉽게 참고함으로써 더 효과적이고 일관된 대화를 이어갈 수 있게 되었다.

대화 로그는 JSONL로 남는다

대화 로그를 저장하는 형식으로 JSONL을 선택한 이유는 그 구조의 단순함과 유연성 때문이다. JSONL은 한 줄에 하나의 JSON 객체를 기록하는 형태로, 각 대화 이벤트를 명확하게 구분할 수 있어 파싱이 용이하다. 하지만 처음에는 JSONL의 이런 특성을 제대로 이해하지 못해 많은 부분에서 문제가 발생했다.

Claude Code와의 대화는 주로 여러 줄의 JSON 객체로 저장되었는데, 이때 각 객체의 ‘type’ 필드는 ‘user’와 ‘assistant’로 나뉜다. 문제는 ‘content’ 필드에 있었다. 이 필드는 문자열로 직접 저장되는 경우도 있고, 리스트 형태의 JSON 객체로 저장되는 경우도 있었는데, 이 특성을 간과하고 처음에는 단순 문자열만 처리하도록 구현했다. 그래서 전체 로그의 절반은 제대로 저장되지 못하고 빈 노트로 남겨졌다.

이를 해결하기 위해 ‘content’ 필드의 형식에 따라 다르게 처리하도록 코드를 수정했다. 문자열일 경우에는 그대로 저장하고, 리스트일 경우에는 각 리스트 요소를 순회하며 ‘text’ 값을 추출해 연결하는 방식으로 변경했다. 이렇게 하여 모든 대화 내용을 빠짐없이 저장할 수 있었다. 그 결과, 대화의 맥락을 놓치지 않게 되었고, 과거 대화를 참고할 때의 불편함도 크게 줄어들었다.

JSONL 포맷 덕분에 복잡한 대화 내용을 빠르게 저장하고 처리하는 기반을 마련할 수 있었다. 또한, 각 대화의 시작과 끝을 명확히 하여, 필요에 따라 특정 대화만을 손쉽게 추출할 수 있는 가능성도 열렸다. 이런 방식으로 대화 로그를 체계적으로 관리하게 되면서, 대화의 흐름을 원활하게 유지할 수 있었다.

절반이 빈 노트로 저장됐던 이유

처음에 대화 내용을 저장하는 과정에서 절반 이상의 대화가 빈 노트로 기록되는 문제가 발생했다. 이 문제는 Claude Code의 대화 로그 형식에서 비롯됐다. 각 대화 이벤트가 JSONL 포맷으로 저장되는데, 이때 content라는 필드가 문자열일 때도 있고 리스트일 때도 있었다. 이런 차이를 제대로 처리하지 못한 채 코드를 작성했기 때문에 일부 대화의 내용이 제대로 저장되지 않았다. 프로젝트 초기에는 JSONL 형식과 텍스트 형식의 차이를 간과했고, 이로 인해 빈 노트가 생기는 것이다.

빈 노트 문제를 해결하기 위해서는 다양한 형식의 데이터를 모두 수용할 수 있는 파싱 코드가 필요했다. JSONL로 저장된 로그의 각 이벤트를 검사하여 content가 문자열인지, 리스트인지에 따라 분기 처리를 해야 했다. 그렇지 않으면 모든 대화 내용이 저장되지 않고 일부만 손실되는 상황이 이어질 수밖에 없었다. 이를 해결하기 위해 다음과 같은 접근 방식을 취했다.

def extract_claude(path):
    msgs = []
    for line in path.read_text(errors='ignore').splitlines():
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue                      # 깨진 줄은 그냥 넘긴다
        t = d.get('type', '')
        if t not in ('user', 'assistant'):
            continue

        c = d.get('message', {}).get('content', '')
        # 여기가 함정이었다. 문자열로 올 때도 있고
        # [{"type":"text","text":"..."}] 리스트로 올 때도 있다
        if isinstance(c, list):
            text = next((x['text'] for x in c
                         if isinstance(x, dict) and x.get('type') == 'text'), '')
        else:
            text = c

        if text and not is_noise_text(text):
            msgs.append((t, text))
    return msgs

NOISE = {'ls', 'y', 'yes', 'ok', '계속', '응', 'ㅇㅇ', 'cd', 'pwd'}

def is_noise_text(text):
    s = text.strip().lower()
    return len(s) < 3 or s in NOISE

JSONL 파싱 — content가 문자열일 때와 리스트일 때가 섞여 있다

코드 작성 시 주의한 부분은 content의 형식에 따라 적절한 파싱 방법을 선택하는 것이었다. 문자열로 되어 있을 때는 그대로 저장하고, 리스트 형태일 때는 각 요소를 순회하며 필요한 데이터를 추출했다. 이 과정을 통해 모든 대화 유형을 커버할 수 있었고, 더 이상 빈 노트가 저장되는 문제는 발생하지 않았다. 결국 이렇게 수정된 코드를 통해 대화 내용이 완전하게 저장되면서 노트의 활용도가 크게 높아졌다.

Work와 Personal을 자동으로 가르기

업무와 개인 대화를 자동으로 분류하는 기능은 처음부터 명확한 필요성이 있었다. 하지만 이를 구현하는 과정에서 몇 가지 문제가 발생했다. 가장 큰 문제는 대화의 주제가 명확하지 않을 때였다. 주제 불문하고 대화가 저장되는 것은 좋지만, 적절한 vault로 분류되지 않으면 노트의 체계가 흐트러지기 때문이다. 그래서 키워드 기반의 분류 시스템을 고안했다. 이 시스템은 대화 내용에서 특정 키워드를 찾아 TOPICS 테이블과 비교하고, 적합한 vault로 자동 분류하는 방식이다.

또한, 여러 주제가 동시에 등장할 때 어떤 기준으로 분류할 것인지도 고민할 필요가 있었다. 여러 토픽이 걸리는 경우, Work 관련 키워드가 더 많으면 Work 노트로, 그렇지 않으면 Personal 노트로 분류하도록 했다. 그러나 이 방식도 처음부터 효과적이진 않았다. 가령, Work와 Personal 키워드가 비슷한 빈도로 사용되면 잘못된 분류가 이뤄질 수 있었다. 이를 해결하기 위해 추가적으로 대화가 발생한 작업 디렉터리 경로도 참고하여 최종적으로 판단했다.

# (토픽명, 어느 vault로, 이 키워드가 본문에 있으면 매칭)
TOPICS = [
    ("RPA",      "Work",     ["rpa", "uipath", "자동화", "로봇", "월간보고"]),
    ("N8N",      "Work",     ["n8n", "워크플로우", "webhook"]),
    ("블로그",    "Personal", ["블로그", "워드프레스", "애드센스", "gsc"]),
    ("개인서버",  "Personal", ["monsil", "도커", "docker", "nginx", "ssl"]),
    ("CBT학습",   "Personal", ["cbt", "정처기", "정보처리기사", "기출"]),
    ("청약",      "Personal", ["청약", "주택드림", "특공", "분양"]),
]
TOPIC_MAP = {t[0]: t[1] for t in TOPICS}

def detect_topics(text):
    t = text.lower()
    return [(name, vault) for name, vault, kws in TOPICS
            if any(k in t for k in kws)]

def classify(topics, fallback=""):
    # 토픽이 안 걸리면 작업 디렉터리 경로로 판단한다
    if not topics:
        return ("Work", []) if any(k in fallback for k in ['pj-work', 'src-work']) \
               else ("Personal", [])
    wt = [n for n, v in topics if v == "Work"]
    pt = [n for n, v in topics if v == "Personal"]
    vault = "Work" if len(wt) >= len(pt) else "Personal"
    return vault, list({n for n, v in topics})

키워드 테이블로 토픽과 vault를 동시에 결정

이 방식은 처음엔 다소 복잡하게 느껴졌지만, 분류 정확도를 높이는 데 큰 도움이 됐다. 키워드 매칭을 우선으로 하되, 디렉터리 경로를 보조 지표로 활용하면서 이전보다 훨씬 정확하게 대화를 분류할 수 있었다. 특히, 프로젝트와 관련된 대화가 정확하게 Work 노트에 저장되면서 정보 관리가 더 체계적으로 이뤄졌다. 결과적으로 노트가 잘못 분류되는 빈도가 크게 줄어들고, 원하는 정보를 쉽게 찾을 수 있게 됐다. 하지만 아직도 키워드 테이블의 주기적인 업데이트는 필요하다.

다음 세션이 과거를 기억하게 만들기

기존의 대화 저장 방식에서는 각 세션이 독립적으로 저장되어, 새로운 대화를 시작할 때마다 과거의 맥락을 일일이 다시 추적해야 하는 불편함이 있었다. 특히 프로젝트 진행 중에 중요한 대화를 여러 번 반복하게 되는 경우가 많았다. 이 문제를 해결하기 위해, 각 대화가 시작될 때마다 관련된 과거 대화 내용이 자동으로 불러와질 수 있는 방법을 고민했다. 대화를 시작할 때마다 근처의 관련 정보를 손쉽게 접근할 수 있도록 하는 것이 목표였다.

이 문제를 해결하기 위해 프로젝트 디렉터리에 CLAUDE.md라는 파일을 만들어, 최근 관련 대화의 요약 정보를 저장하도록 했다. 다음 대화가 시작될 때 이 파일을 참조하여 과거의 대화 맥락을 자동으로 불러올 수 있도록 설계했다. 이렇게 해서 같은 질문을 반복하거나 이미 결정된 사항을 다시 논의하는 시간을 줄이고, 더 나은 효율로 대화를 진행할 수 있게 됐다.

def inject_to_claude_md(project_cwd, results):
    """프로젝트 CLAUDE.md에 과거 대화 요약을 직접 써넣는다.
    다음에 이 디렉터리에서 Claude Code를 열면 자동으로 읽힌다."""
    cwd = Path(project_cwd)
    if not cwd.exists() or str(cwd) in EXCLUDE_CWD:
        return

    recent = sorted(results, key=lambda x: x[3], reverse=True)[:5]
    lines = ["## 과거 AI 대화 요약 (자동 주입)", ""]
    for vault, topics, _, title, msgs in recent:
        q = next((m[1] for m in msgs if m[0] == 'user'), '')[:120]
        a = next((m[1] for m in msgs if m[0] == 'assistant'), '')[:120]
        lines += [f"- [{title}] ({', '.join(topics)})",
                  f"  Q: {q}", f"  A: {a}", ""]

    md = cwd / "CLAUDE.md"
    old = md.read_text() if md.exists() else ""
    # 이전 자동 주입 블록만 걷어내고 새로 쓴다 (수동 작성분은 보존)
    old = re.sub(r'## 과거 AI 대화 요약 \(자동 주입\).*?(?=\n## |\Z)',
                 '', old, flags=re.S).rstrip()
    md.write_text((old + "\n\n" + "\n".join(lines)).strip() + "\n")

다음 세션이 과거를 기억하게 만드는 부분

초기 구현에서는 관련 대화 전부를 불러오는 방식이었지만, 이렇게 하면 너무 많은 정보가 한 번에 불러와져 불필요한 내용까지 포함될 위험이 있었다. 그래서 대화의 요약 정보만을 저장하고 불러오도록 수정했다. 이 방식으로 필요한 정보만 간결하게 제공할 수 있었다. 덕분에 사용자는 간결하게 요약된 과거 대화를 한눈에 파악할 수 있게 되었다. 이제 세션을 시작할 때마다 과거에 어떤 대화가 오갔는지 쉽게 회상할 수 있어, 전체적인 대화의 흐름을 더 손 안 가게 관리할 수 있게 됐다.

노이즈 대화 걸러내기

대화 로그를 정리하며 가장 먼저 부딪힌 문제는 노이즈 대화였다. “네”, “응”, “좋습니다” 같은 짧고 반복적인 대화는 대화의 맥락 파악에 전혀 도움이 되지 않았다. 그저 로그를 불필요하게 길게 만들 뿐이었다. 이 때문에 중요한 정보가 묻히거나, 노트가 불필요하게 길어지는 문제가 있었다. 그래서 노이즈 필터링 기능을 추가하기로 했다.

노이즈 필터링은 간단한 규칙 기반으로 작동한다. 일정 길이 이하의 대화나 특정 키워드만 포함된 대화는 자동으로 걸러내도록 설정했다. 예를 들어 “네”, “응”, “계속” 같은 단어들은 별도의 리스트에 저장하고, 이를 기준으로 필터링을 한다. 이 방법은 비교적 간단하지만, 대화의 품질을 크게 향상시켰다. 단순히 로그 길이를 줄이는 것뿐만 아니라, 진짜 중요한 내용에만 집중할 수 있게 해주었다.

노이즈 필터링을 적용한 이후로, 대화 노트의 품질이 체감될 만큼 나아졌다. 덕분에 더 깔끔하고 유용한 기록을 남길 수 있게 되었고, 작업의 쓰기도 자연스럽게 증가했다. 중요한 대화에만 집중할 수 있어, 이후 대화의 흐름을 파악하기도 수월해졌다. 이런 개선 덕분에 프로젝트의 다른 부분에서도 더 나은 결과를 얻을 수 있었다.

물론 이 방식이 빈틈없는 것은 아니다. 때로는 유용한 대화가 필터링되는 경우도 있었다. 하지만 전체적인 사용성을 고려했을 때, 이 정도의 노이즈 필터링은 여전히 유익하다고 판단했다. 결국 중요한 것은 어떤 대화가 남고, 어떤 대화가 걸러지는지를 적절히 조절하여 최적의 결과를 얻는 것이다. 이 때문에 필터링 조건을 더 정교하게 조정할 계획도 염두에 두고 있다.

쓰면서 달라진 점

프로젝트를 처음 시작했을 때는 Claude Code의 로그 형식을 충분히 이해하지 못한 상태였다. JSONL 형식에서 content의 변형된 형태를 다루며 빈 노트가 저장되는 문제가 발생했다. 이 과정에서 다양한 데이터 형태를 처리하는 모듈을 추가하여 유연성을 확보했다. 프로젝트 초기에는 단순히 대화 내용을 저장하는 것을 목표로 했지만, 점차 대화의 맥락을 어떻게 빠르게 관리할지에 대한 고민이 생겼다.

노트 시스템을 개선하면서 작업 디렉터리에 따라 노트를 분류하는 구조도 자연스럽게 변모했다. 처음에는 키워드를 통한 자동 분류가 주가 되었지만, 점차 디렉터리 정보까지 활용하여 보다 섬세한 분류를 시도하게 되었다. 이는 예상치 못한 주제와 대화를 다루면서 분류 기준이 애매해지는 경우가 많았기 때문이다. 덕분에 작업 디렉터리의 필요성을 다시금 깨닫게 되었다.

또한, 프로젝트 진행 중에는 옵시디언과의 연동성도 많은 부분에서 향상되었다. 초기에는 단순히 노트를 저장하는 데 그쳤다면, 이후에는 옵시디언의 그래프 기능을 적극 활용하게 되었다. 이렇게 해서 대화 주제와의 연결성을 강화할 수 있었다. 이 과정에서 [[토픽]] 링크를 추가하여 대화의 효용성을 극대화하려는 시도를 했고, 결국 자동 갱신 인덱스 노트로 이어졌다.

마지막으로, CLAUDE.md 파일에 요약을 기록하게 되면서 이전 대화의 맥락을 보존하는 데 큰 도움이 되었다. 이는 특히 프로젝트를 다시 시작할 때 맥락을 빠르게 복원할 수 있어 작업 효율을 높였다. 이러한 변화들은 개발 초기의 단순한 목표에서 벗어나, 사용성과 속도를 높이는 방향으로 프로젝트를 진화시키는 계기가 되었다.

함께 볼 글

이 글과 이어지는 내용은 아래에 정리해뒀다.

황민

황민 (Hwang Min)

IT·RPA·AI 분야 개발자. 웹앱 개발, UiPath RPA, n8n 자동화 실무 경력 4년. AI·금융·IT 트렌드를 현장 개발자 시각으로 분석합니다.

댓글 남기기

𝕏fin