본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 08. 13:19

Memex 완성: CLI 기반 MVP를 프로덕션 준비가 된 웹 서비스로 전환하기

요약

CLI 기반 MVP를 Next.js 웹 서비스로 확장한 개인용 메모리 앱 Memex의 개발 과정을 다룹니다. Gemini 임베딩과 Supabase pgvector를 활용한 RAG 시스템을 구축하여 자연어 기반의 메모 저장 및 검색 기능을 구현했습니다.

핵심 포인트

  • Gemini 임베딩과 pgvector를 활용한 RAG 시스템 구축
  • 의도 라우팅을 통한 메모 저장, 회상, 일반 대화 분리 처리
  • 시간대 인식을 반영한 정교한 시간적 메모 회상 기능
  • CLI와 웹 앱 간의 동일한 백엔드 및 데이터 공유

이 게시물은 GitHub Finish-Up-A-Thon Challenge를 위한 제출물입니다.

내가 만든 것

Memex는 개인용 메모리 앱입니다. 일상적인 언어로 무언가를 말하면, 나중에 질문을 통해 확인할 수 있습니다.

대부분의 노트 앱은 기록하기 전에 정리할 것을 요구합니다. 폴더를 선택하고, 파일 이름을 정하고, 이것이 작업(tasks)인지, 참고 자료(references)인지, 아니면 아이디어(ideas)인지 결정해야 합니다. Memex는 이 모든 과정을 건너뜁니다. 당신은 들은 내용, 떠오른 생각, 기억하고 싶은 것을 입력하기만 하면 됩니다. 그러면 앱이 이를 저장합니다. 나중에 "그 재킷들에 대해 어디서 들었지?"라고 물으면 앱이 답해줍니다. 터미널(terminal)에 있든 브라우저(browser)에 있든, 동일한 계정으로 동일한 메모리를 공유합니다.

내부적으로는 RAG (Retrieval-Augmented Generation, 검색 증강 생성) 시스템으로 작동합니다. 모든 메모리는 Gemini의 gemini-embedding-001 모델을 사용하여 768차원으로 임베딩(embedding)되며, Supabase의 pgvector에 저장됩니다. 질문을 하면 코사인 유사도(cosine similarity)를 통해 검색됩니다. Gemini는 당신이 실제로 말한 내용에만 근거하여 답변을 합성(synthesise)합니다. 관련 내용을 찾지 못하면 없다고 말하며, 모델이 추측하는 일은 절대 발생하지 않습니다.

두 개의 클라이언트가 하나의 백엔드(backend)를 공유합니다: Python CLI와 Next.js 웹 앱이며, 둘 다 동일한 Supabase 계정을 읽고 씁니다.

완성된 웹 앱 버전에는 채팅 페이지, 메모리 라이브러리(Memory Library), 그리고 설정 패널이 있습니다. 채팅은 세 가지 종류의 입력을 처리합니다: 메모리 저장, 메모리 회상(선택적 시간 범위 지정 가능), 그리고 일반적인 대화입니다. 인삿말에는 따뜻한 답변을 건넵니다. "이번 주에 내가 무엇을 저장했지?"라고 물으면 해당 기간의 모든 내용을 나열합니다. "치과에 대해 말했던 거 잊어버려"라고 하면 일치하는 메모리를 찾아 보여준 뒤, 당신이 확인했을 때만 삭제합니다. 답변은 토큰(token) 단위로 스트리밍(stream)됩니다. 저장 확인 메시지는 작은 세트 내에서 교차로 나타나므로, 앱이 매번 단순한 양식 제출(form submission)처럼 느껴지지 않도록 했습니다.

Memory Library를 통해 CLI나 Supabase 대시보드로 이동할 필요 없이 메모리를 직접 탐색, 검색, 고정(pin), 수정 및 삭제할 수 있습니다. 메모리는 저장 시점에 idea, task, person, place와 같은 가벼운 카테고리로 자동 태깅(auto-tagged)됩니다. "다음 달에 여권 갱신", "화요일에 치과 예약"과 같이 미래의 날짜를 포함하는 메모리는 마감일(due date)이 추출되어 적절한 시점에 마감/예정(due/upcoming) 뷰에 나타납니다. 설정 패널에는 작동 가능한 다크 모드(dark mode), 모든 데이터를 JSON으로 덤프하는 내보내기(export) 옵션, 그리고 중복 제거(deduplication)를 거치며 다시 임베딩(re-embed)되는 가져오기(import) 경로가 포함되어 있습니다.

완성된 버전에는 다음과 같은 기능들이 탑재되어 있습니다:

  • 삼원향 의도 라우팅 (Three-way intent routing): 메모리 저장, 메모리 회상, 일반 대화가 각각 별도로 처리되어 인사가 저장소(store)를 오염시키지 않습니다.
  • 시간대 인식 윈도우를 통한 시간적 회상 (Temporal recall with timezone-aware windows): "오늘/이번 주/어제 무엇을 저장했나"라는 질문이 UTC가 아닌 사용자의 로컬 날짜 기준으로 정확하게 작동합니다.
  • 마감일 추출 (Due-date extraction): 미래 날짜가 포함된 메모리는 적절한 시점에 예정 뷰에 나타납니다.
  • 2단계 확인을 통한 자연어 삭제 (Natural-language forget with two-step confirmation): 서버는 첫 번째 요청에서 절대 삭제를 수행하지 않습니다.
  • JSON 인코딩된 SSE를 통한 손실 없는 스트리밍 (Lossless streaming via JSON-encoded SSE): 다중 행(multi-line) 콘텐츠를 누락하지 않고 답변이 토큰 단위로 생성됩니다.
  • 합성 전 관련성 하한선 설정 (Relevance floor before synthesis): 약한 매칭(weak matches)은 모델에 도달하기 전에 필터링되므로, 관련 없는 메모리 하나가 답변을 망치지 않습니다.
  • Memory Library: CLI 없이 탐색, 검색, 고정, 수정 및 삭제 가능
  • 저장 시 카테고리(idea, task, person, place)로 자동 태깅 (핵심 흐름을 방해하지 않음)
  • 토큰 기반 테마를 적용한 다크 모드 (하드코딩된 헥스(hex) 값 없음)
  • JSON 내보내기 및 중복 제거를 포함한 재가져오기
  • 하나의 Supabase 백엔드와 Gemini API를 공유하는 Python CLI 및 Next.js 웹 앱

초기 버전은 작동했습니다. 하지만 터미널에서만 작동했고, 단 한 명의 사용자만을 위한 것이었으며, 인증(auth)도, 웹 UI도 없었습니다. 인사는 메모리로 저장되었고, 오늘 무엇을 저장했는지 물어볼 방법도 없었습니다. 초기 버전은 핵심 아이디어를 증명했습니다. 하지만 다른 사람에게 건네줄 수 있는 수준은 아니었습니다.

그것을 웹 앱, 실제 멀티 디바이스 인증 (multi-device auth), 메모리 라이브러리 (Memory Library), 다크 모드 (dark mode), 마감일 알림 (due-date reminders), 자연어 삭제 (natural-language deletion), 그리고 신뢰성을 보장하는 모든 정확성 작업 (correctness work)을 갖춘 완성된 제품으로 만드는 것이 이번 제출의 진정한 목적입니다.

데모 (Demo)

라이브 사이트: https://memex-web-eta.vercel.app/
이전 버전 저장소: https://github.com/Raiden505/memex-cli

재기 스토리 (The Comeback Story)

원래의 Memex는 터미널 도구였습니다. 메모리를 저장하고, 의미론적으로 (semantically) 검색하며, 질문에 답할 수 있었지만 — 오직 명령줄 (command line)에서만 가능했고, 실제 인증 (auth) 기능이 없는 단일 계정만을 위한 것이었으며, 브라우저나 휴대폰에서 사용할 방법도 없었습니다. "hi"라고 말하면 "hi"를 메모리로 저장하려고 시도했습니다. 오늘이나 이번 주라는 개념도 없었습니다. 삭제를 하려면 메모리의 UUID를 알아야 했습니다. 저는 그것을 시연할 수는 있었지만, 누구에게도 줄 수는 없었습니다.

그것이 바로 작동하는 개념(working concept)을 출시할 때 아무도 말하지 않는 격차입니다. 데모 경로(demo path)는 아주 잘 작동합니다. 하지만 데모 경로에서 조금이라도 벗어나는 모든 것은 제대로 작동하지 않습니다. "hi"라고 말하면 "hi"를 메모리로 저장합니다. 메모리 카드를 클릭하여 질문하면 때때로 질문 대신 메모리를 다시 저장해 버립니다. "오늘 내가 너에게 뭐라고 말했지?"라고 물으면 정규 표현식(regex)이 단어 자체만 매칭하고 문장 안의 단어는 매칭하지 못하기 때문에 아무것도 반환하지 않습니다. 한 가지 주제에 대한 답변이, 포함될 만큼 충분히 높은 점수를 받은 약하게 연관된 메모리에 의해 조용히 왜곡되기도 합니다. 이 중 그 어떤 것도 데모를 망치지는 않습니다. 하지만 이 모든 것들이 제품(product)을 망칩니다.

이후 17개의 단계는 바로 그러한 범주의 문제들, 즉 통제된 시연(walkthrough)에서는 작동하지만 실제 사용 시에는 깨져버리는 문제들을 찾아내고 수정하는 과정이었습니다.

Memex가 가장 먼저 바로잡아야 했던 것은 정확성(correctness)이었습니다. 자신 있게 틀린 정보를 말하는 메모리 도구는 대부분의 소프트웨어와는 다른 종류의 실패를 의미합니다. 그것은 당신의 과거에 대해 거짓말을 함으로써 실패하기 때문입니다. 그래서 처음부터 '환각(hallucination) 금지' 규칙을 도입했습니다. 개인적 회상 질문에 대해 관련 메모리가 반환되지 않으면, 시스템은 모델(model)을 호출하기 전에 회로를 차단(short-circuit)하고 고정된 메시지를 반환합니다. LLM이 개인적인 사실을 추측할 기회를 아예 주지 않는 것입니다. 이 제약 사항은 이후 모든 단계에서 그대로 유지되었습니다.

초기 버전은 모든 메시지를 "저장(store)" 또는 "질의(query)" 중 하나로 분류했습니다. 명확한 경우에는 잘 작동했지만, 경계 사례(edge cases)에서는 당혹스러운 결과를 초래했습니다. 예를 들어 "hi"라고 말하면 앱은 "hi"를 메모리로 저장하려고 시도합니다. "프랑스의 수도는 어디인가요?"라고 물으면 질문 자체를 저장하거나, "그에 대해 저장된 내용이 없습니다"라고 반환합니다. 매일 사용하고 싶은 서비스에서 이 중 어느 것도 용납될 수 없습니다.

따라서 세 번째 의도(intent)인 general이 추가되었습니다. 인사나 일반적인 지식 질문에는 Gemini로부터 짧은 대화형 답변을 받습니다. 이러한 메시지는 절대 저장되지 않으며 메모리 검색을 트리거하지도 않습니다. 명백한 사례들은 LLM 호출 없이도 처리되는 패스트 패스(fast-path)를 통해 처리됩니다. 분류기(classifier)가 실행되기도 전에

타임존(timezone) 처리는 보기보다 훨씬 중요합니다. "오늘"은 UTC가 아닌 사용자의 현지 날짜를 의미합니다. UTC+5 지역에 있는 누군가가 밤 10시에 오늘에 대해 질문한다면, 그들은 5시간 전에 종료된 UTC 시간대가 아니라 자신의 현지 달력상의 날짜를 기대합니다. 웹 클라이언트(web client)는 Intl.DateTimeFormat().resolvedOptions().timeZone을 통해 타임존을 읽어 들여 모든 채팅 요청과 함께 전송합니다. 모든 윈도우(window) 계산은 데이터베이스 쿼리를 위해 UTC로 변환하기 전, 제공된 타임존 내에서 실행됩니다.

스트리밍(streaming) 버그는 직접 찾아보기 전까지는 보이지 않았습니다. 답변은 제대로 작동하는 것처럼 보였습니다. 답변이 돌아오고 내용도 정확했지만, 실제로 스트리밍되는 것은 아무것도 없었으며 일부 다중 행(multi-line) 답변은 잘린 채로 도착했습니다. 원인은 SSE(Server-Sent Events) 프레임(frame)을 파싱(parsing)하는 방식에 있었습니다. 백엔드(backend)는 각 모델 토큰(token)을 `data: <raw token>

형식으로 방출했습니다. 프론트엔드(frontend)는 버퍼(buffer)를"\n"` 기준으로 나누고

합성(synthesis) 전 약한 매칭(weak matches)을 필터링한 후에는 답변의 정확도 또한 크게 향상되었습니다. 기존의 검색(retrieval) 방식은 관련성(relevance)에 관계없이 최대 5개의 결과를 반환하여 모델에 모두 전달했습니다. 관련성이 낮은 메모 하나가 완전히 다른 주제에 대한 답변을 왜곡할 수 있었습니다. 이제는 관련성 하한선(relevance floor)을 두어 결과가 합성기(synthesiser)에 도달하기 전에 필터링합니다. 가장 높은 유사도 점수(similarity score)와 0.2 차이 이내인 항목만 통과하며, 0.2를 최소 하한선으로 설정합니다. 만약 아무것도 하한선을 통과하지 못하면, 환각 방지 단락 회로(no-hallucination short-circuit)가 작동하여 모델을 호출하지 않습니다. 실제 질문에 대한 품질 개선은 즉각적으로 나타났습니다.

저장된 메모 카드를 클릭하여 질문하는 과정에서 라우팅(routing) 문제가 드러났습니다. 클릭 시 메모 내용이 일반 쿼리 문자열(plain query string)로 채팅에 삽입되었고, 이것이 의도 라우팅(intent routing)을 거치게 되었습니다. 만약 노트에 특정 단어(날짜, "due", "task" 등)가 포함되어 있다면, 라우터는 입력을 STORE로 분류하여 메모를 회상(recall)하는 대신 다시 저장하려고 시도할 수 있었습니다. 이제 API에 mode: "recall" 파라미터를 추가하여 해당 요청에 대해서는 의도 라우팅을 완전히 건너뛰고 즉시 의미론적 검색(semantic search)으로 넘어가도록 했습니다.

자연어 삭제 기능은 2단계 확인 프로토콜(two-step confirm protocol)을 중심으로 설계되었습니다. 첫 번째 요청은 후보군을 식별하지만 아무것도 삭제하지 않습니다. 서버는 응답으로 내용과 날짜가 포함된 매칭 메모 목록인 forget_candidates를 반환합니다. 클라이언트는 이를 확인 카드(confirm card)에 표시합니다. 확인을 누르면 클라이언트는 confirm_forget: [ids]와 함께 원래 메시지를 다시 전송합니다. 서버는 무언가를 삭제하기 전에 user_id를 통해 소유권을 다시 확인합니다. "모두 잊어줘"와 같은 스타일의 요청은 훨씬 더 강력한 확인 단계를 거칩니다. 규칙은 서버가 첫 번째 턴에서는 예외 없이 절대 삭제하지 않는다는 것입니다. 삭제는 되돌릴 수 없으며, 확인 단계는 선택 사항이 아닙니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0