LLM은 똑똑한데, 왜 우리 회사 일은 모를까

LLM은 똑똑한데, 왜 우리 회사 일은 모를까

김병균 · 토스 Node.js Developer
2026년 7월 30일

요즘 LLM은 공개된 지식에 관한 질문에는 꽤 능숙하게 답해요. 코드를 작성하고, 긴 문서를 요약하고, 복잡한 요청을 여러 단계로 나눠 처리하기도 하죠. 하지만 회사 안으로 들어오면 이야기가 달라져요.

“이 기능의 현재 정책은 무엇인가요?” “문서에 적힌 내용이 실제 코드에도 반영되어 있나요?” “지난번 논의에서 결론이 바뀌지 않았나요?”

이런 질문에는 모델의 추론 능력만으로 답할 수 없어요. 답을 만드는 데 필요한 정보가 문서, 코드, 미팅 로그와 사내 메신저 등에 흩어져 있고, 각 정보가 여전히 유효한지 알기도 어렵기 때문이에요.

처음에는 이 문제를 검색의 문제로 생각했어요. LLM이 필요한 사내 정보를 더 잘 찾게 만들면 된다고 봤죠. 하지만 검색 결과가 좋아져도 중요한 문제가 남았어요. 찾은 문서가 최신인지, 사내 메신저에서의 논의가 최종 결정인지, 문서의 설명과 실제 코드가 일치하는지는 검색만으로 판단하기 어려웠어요.

결국 문제를 다시 정의했어요.

LLM이 사내 정보를 잘 찾게 하는 것이 아니라, LLM과 사람이 함께 사용할 수 있는 신뢰 가능한 컨텍스트를 관리해야 한다.

이 문제를 해결하기 위해 만들고 있는 시스템이 Topic이에요. 이 글에서는 문서 · 코드 · 사내 메신저의 원본 데이터를 Topic이 어떤 단위로 추출하고, 어떻게 서로 연결하며, 신뢰할 수 있는 상태로 관리하는지 소개할게요.

검색만으로는 신뢰를 만들 수 없었어요

사내 검색 시스템은 일반적으로 질문과 가까운 텍스트 조각을 찾아줘요. 이 조각은 답변을 만드는 재료로는 유용하지만, 그 자체로 신뢰할 수 있는 컨텍스트는 아니에요.

예를 들어 주문 타임아웃의 재시도 정책을 묻는다고 해볼게요.

세 정보는 모두 질문과 관련이 있어요. 따라서 일반적인 검색 결과에는 모두 나타날 수 있죠. 하지만 LLM이 답하려면 검색 이후에 더 많은 판단을 해야 해요.

각 에이전트가 원본 데이터를 직접 검색하면 이런 판단도 에이전트마다 반복돼요. 같은 질문에도 에이전트가 어떤 자료를 찾았는지, 충돌을 어떻게 해석했는지에 따라 답이 달라질 수 있고요.

Topic은 이 반복되는 작업을 정보의 출처와 소비자 사이의 공통 계층으로 옮겨요. 원문을 수집하는 데서 끝나지 않고, 컨텍스트의 출처와 관계, 최신성, 충돌 상태까지 함께 관리해요. LLM 클라이언트와 토스 팀원은 Topic을 통해 같은 근거와 같은 검증 상태를 공유할 수 있어요.

신뢰의 여섯 가지 축

처음에는 컨텍스트마다 하나의 신뢰도 점수를 계산하는 방식을 떠올렸어요. 하지만 0.87처럼 하나로 압축된 숫자는 무엇이 잘못되었는지 설명해주지 못해요. 원문 근거가 부족한 것과 오래된 정보인 것은 전혀 다른 문제이고, 해결 방법도 다르기 때문이에요.

그래서 Topic에서는 신뢰할 수 있는 컨텍스트의 조건을 여섯 가지 질문으로 나눴어요.

이 여섯 항목이 모두 하나의 검증 모델로 처리되는 것은 아니에요. 출처를 가져오는 단계에서 해결해야 하는 문제도 있고, 해시 비교처럼 규칙으로 판단할 수 있는 문제도 있어요. 의미 해석이 필요한 판단에는 LLM을 사용하고, 애매하거나 영향이 큰 결정은 사람에게 넘겨야 해요.

Topic의 품질 관리는 서로 다른 판단을 한 점수로 섞지 않고, 각 판단을 가장 적합한 단계와 방법에 배치하는 것에서 시작했어요.

Ingest: 서로 다른 세 출처를 하나의 컨텍스트 단위로

Topic은 문서와 코드, 사내 메신저에서 가져온 내용을 공통 ContentUnit 형태로 정규화해요. 하지만 모든 출처를 같은 길이의 텍스트 청크로 자르지는 않아요. 출처마다 의미가 완결되는 단위와 신뢰를 판단하는 방법이 다르기 때문이에요.

Topic이 사용하는 ContentUnit의 개념을 단순화하면 다음과 같아요.

ContentUnit
├─ source_type        # document, code, messenger
├─ unit_type          # doc_section, messenger_thread, ...
├─ source_uri         # 원문으로 돌아가기 위한 주소
├─ content            # 추출하거나 요약한 본문
├─ content_hash       # 변경 감지
├─ created_at_src
├─ updated_at_src
└─ metadata           # 출처별 구조와 식별자

공통 형식을 사용하는 이유는 이후의 추출과 검증 파이프라인을 일관되게 만들기 위해서예요. 반대로 출처별 구조를 보존하는 이유는 원문이 가진 의미의 경계와 증거의 출처를 잃지 않기 위해서고요.

문서는 제목 계층을 보존했어요

문서를 고정된 글자 수로 나누면 제목과 본문이 분리되거나, 서로 다른 주제가 하나의 청크에 섞일 수 있어요. Topic은 Markdown 문서를 제목 기준으로 나누고 상위 제목의 계층을 함께 저장해요. 섹션이 너무 길 때만 제한된 크기로 추가 분할해요.

덕분에 검색 결과가 “재시도 횟수는 세 번이다”라는 한 문장만 반환하는 대신, 이 내용이 ‘결제 요청 실패 처리’ 아래의 ‘타임아웃 정책’에 속한다는 구조까지 유지할 수 있어요. 문서 경로, 원문 URL, 작성 · 수정 시각도 unit에 함께 남겨 이후 검증에서 사용할 수 있어요.

사내 메신저는 메시지가 아니라 스레드를 단위로 삼았어요

사내 메신저에서 한 메시지만 떼어내면 질문인지 결론인지 구분하기 어려워요. “그렇게 하죠”라는 메시지는 앞선 대화가 없으면 아무 의미가 없고, 여러 선택지를 논의한 대화도 마지막 결론을 함께 보지 않으면 잘못 해석할 수 있어요.

그래서 Topic은 메신저의 스레드 전체를 하나의 의미 단위로 보고 요약해요. 이때 일반적인 요약과 다른 제약을 두었어요.

이 요약은 사람이 읽기 좋게 줄이는 것이 목적이 아니에요. 나중에 검색하고 관계를 추출할 때 스레드의 결정 상태를 잃지 않는 것이 더 중요해요. 그래서 Topic에서는 사내 메신저 스레드 요약을 편의 기능이 아니라 품질을 좌우하는 수집 단계로 다뤘어요.

코드는 심볼로 나누고 동작으로 다시 묶었어요

코드를 텍스트 파일처럼 일정한 크기로 자르는 방식에도 한계가 있어요. 하나의 비즈니스 동작이 여러 함수와 파일에 묘사되어 있을 수도 있고, 반대로 하나의 큰 파일에 서로 다른 규칙이 섞일 수도 있기 때문이에요.

Topic은 먼저 파서로 코드 저장소를 읽어 함수와 클래스 같은 코드 심볼을 ContentUnit으로 만들어요. 이 단계에는 LLM을 사용하지 않아요. 파일 경로와 심볼 종류, 정확한 시작 · 종료 줄, import 관계를 규칙 기반으로 추출해 원문으로 돌아갈 수 있는 경로를 만들어요.

하지만 심볼 목록만으로는 비즈니스 동작을 설명하기 어려워요. 그래서 그 위에 여러 심볼을 가로지르는 동작과 규칙을 설명하는 semantic card를 만들어요.

CodeSemanticCard
├─ subject            # 무엇에 관한 카드인가
├─ behavior           # 실제로 어떤 동작을 하는가
├─ domain_terms       # 업무에서 사용하는 표현
├─ code_terms         # 코드 식별자
├─ spans[]            # repo, file, line, symbol
└─ commit_sha         # 어느 시점의 코드인가

모든 코드 심볼을 동일하게 요약하지도 않아요. import graph에서의 중요도와 코드 변경 빈도 같은 신호를 이용해 먼저 읽을 영역을 정해요. 그런 다음 코드를 탐색하는 LLM 에이전트가 실제 구현을 따라가며 동작 설명과 근거 span을 생성해요.

LLM이 만든 결과는 그대로 저장하지 않아요. 파일과 줄 위치가 실제로 존재하는지, 설명을 뒷받침할 span이 있는지, 비슷한 카드가 과도하게 반복되지는 않는지를 규칙 기반으로 다시 검사해요. 특정 코드 영역의 카드만 지나치게 많아지지 않는지도 함께 살펴보고요.

semantic card는 코드를 대신하는 새로운 문서라기보다는, 코드를 실제 비즈니스 로직으로 묶어내기 위한 중간 계층에 가까워요. Topic이 답을 만들거나 최신성을 검사할 때는 카드의 설명만 믿지 않고 현재 코드의 실제 span을 다시 읽어 검증해요.

Extract: 흩어진 정보를 개념과 관계로 연결하기

출처별 unit을 만들었다고 해서 곧바로 신뢰 가능한 컨텍스트가 되는 것은 아니에요. 같은 대상을 가리키는 정보가 여러 unit에 흩어져 있고, 서로 다른 표현을 쓰거나 다른 시점의 상태를 설명할 수 있기 때문이에요.

Topic은 unit에서 개념 후보와 근거를 추출한 뒤, 후보 사이의 관계를 별도의 단계에서 만들어요. 중요한 원칙은 개념과 관계를 원문 근거에서 떼어내지 않는 것이에요.

예를 들어 OrderService.placeOrder, ‘주문 생성’, ‘결제 주문 요청’이 같은 업무 동작을 가리킬 가능성이 있다고 해볼게요. Topic은 이름만 보고 하나로 합치지 않아요. 각 표현이 등장한 문서 섹션, 메신저 스레드, 코드 span을 함께 보존한 상태에서 통합 후보를 만들어요.

추출과 연결 과정은 크게 다음 순서로 진행돼요.

사내 용어는 자동 병합만으로 해결하지 않았어요

띄어쓰기나 대소문자 차이처럼 명확한 중복은 임베딩 유사도와 정규화 규칙으로 상당 부분 찾을 수 있어요. 하지만 토스 내에서 쓰이는 약어와 별칭은 더 까다로워요. 비슷한 표현이 실제로는 다른 시스템을 가리킬 수도 있고, 전혀 다른 이름이 같은 프로젝트를 가리킬 수도 있어요.

잘못된 병합은 이후 검색과 검증 전체를 오염시켜요. 그래서 Topic은 애매한 동의어를 곧바로 적용하지 않고, 관련 원문을 포함한 별칭 후보(Synonym Proposal)로 만들어요. 사람이 승인하면 관리되는 별칭 관계가 되고, 거절된 후보는 다시 반복해서 제안되지 않도록 기록해요.

자동화의 목표를 ‘사람이 전혀 개입하지 않는 것’으로 잡지 않은 이유예요. 명확한 연결은 자동으로 처리하되, 팀의 맥락이 필요한 판단은 근거와 함께 사람에게 전달하는 편이 전체 컨텍스트의 신뢰도를 높일 수 있었어요.

문서와 코드의 관계에도 타입을 부여했어요

두 unit의 임베딩이 가깝다는 사실은 같은 주제를 말한다는 신호일 뿐이에요. 문서의 설명이 코드로 구현되었다는 뜻은 아니죠.

Topic은 문서와 code semantic card 사이의 관계를 다음처럼 구분해요.

  • supported_by: 문서의 설명을 코드 동작이 뒷받침해요.
  • contradicted_by: 문서의 설명과 코드 동작이 충돌해요.
  • mentions: 같은 기능을 언급하지만 뒷받침되는지 충돌하는지를 판단하기에는 부족해요.

모든 문서와 모든 코드를 서로 비교하면 비용이 빠르게 커져요. 먼저 임베딩 검색으로 unit마다 제한된 수의 후보만 만들고, 의미 판단이 필요한 후보를 배치로 검증해요. 검증 결과에는 관계 유형뿐 아니라 신뢰도, 판단 이유, 원문 인용, 검증 상태를 남겨요.

검증 호출이 실패하거나 신뢰도가 기준보다 낮으면 관계를 만들지 않아요. 틀릴 가능성이 있는 연결을 일단 저장하는 것보다, 연결되지 않은 상태로 두는 편을 선택한 거예요. Topic에서 관계가 없다는 것은 ‘관련이 없다’는 단정이 아니라 ‘아직 신뢰할 만한 연결을 확인하지 못했다’는 뜻이에요.

Verify: 신뢰의 여섯 가지 축을 서로 다른 방식으로 관리하기

앞에서 정의한 여섯 가지 질문은 파이프라인의 여러 단계에 나누어 반영돼요.

변경되지 않은 컨텍스트는 다시 검증하지 않았어요

문서와 코드는 계속 바뀌어요. 컨텍스트를 한 번 검증하는 것만으로는 부족하지만, 매번 전체 데이터를 다시 처리하는 것도 현실적이지 않아요.

Topic은 각 unit의 안정적인 식별자와 content hash를 이용해 변경 범위를 좁혀요. 같은 원문에서 내용이 바뀌지 않았다면 추출과 연결 결과를 재사용하고, 새로 생기거나 변경된 unit만 다시 처리해요. 원본이 삭제되면 그 원본을 참조하던 관계도 함께 정리하고요.

코드 근거의 최신성을 확인할 때도 같은 원칙을 사용해요. code anchor에는 검증 당시의 commit과 span hash가 저장돼요.

현재 코드에서 anchor를 찾을 없음
  orphaned

anchor가 있고 span hash가 같음
  fresh, 의미 검증 생략

anchor가 있지만 span hash가 달라짐
  변경된 코드로 faithfulness 재검증

해시가 같다는 사실을 확인하기 위해 LLM을 호출할 필요는 없어요. 먼저 규칙 기반 검사로 변경 여부와 참조 무결성을 판단하고, 실제 내용이 달라졌을 때만 의미적 검증을 수행해요.

이 설계는 비용을 줄이기 위한 최적화인 동시에 품질을 위한 장치예요. 모델의 비결정적인 판단을 꼭 필요한 곳으로 제한할 수 있고, 어떤 변경 때문에 컨텍스트의 상태가 달라졌는지도 추적할 수 있기 때문이에요.

Granularity: 좋은 컨텍스트 단위인가

문서는 제목 구조, 메신저는 스레드, 코드는 동작을 설명하는 카드를 기준으로 나누는 출처별 수집 방식이 첫 번째 장치예요. 이후에도 너무 짧거나 긴 unit, 내용이 거의 없는 문서 섹션, 근거가 부족한 semantic card를 점검과 선별 단계에서 걸러요.

즉, granularity는 추출이 끝난 뒤 모델 하나가 판정하는 값이 아니에요. 원본을 어떤 단위로 읽을지 결정하는 설계부터 시작되는 품질이에요.

Faithfulness: 원문이 정말 그 주장을 뒷받침하는가

검증할 주장과 원문 span을 한 쌍으로 만들고, 먼저 원문이 존재하는지와 anchor가 유효한지를 확인해요. 그다음 의미 판단이 필요하면 NLI 기반 검증으로 supported, refuted, not enough information을 구분해요.

근거를 찾지 못했을 때 모델이 그럴듯한 설명으로 빈칸을 채우지 않도록 not enough information을 정상적인 결과로 취급하는 것이 중요했어요.

Staleness: 지금도 유효한 근거인가

문서의 수정 시각과 content hash, 코드의 commit과 span hash를 이용해 변경을 감지해요. 변경이 발견되었다고 곧바로 ‘틀린 정보’가 되는 것은 아니에요. 변경된 원문에서도 기존 주장이 여전히 성립할 수 있기 때문에, 변경은 의미 재검증을 시작하는 신호로 사용해요.

Canonicality: 같은 대상을 중복해서 관리하고 있지 않은가

문자열 정규화와 embedding similarity로 중복 후보를 만들고, 근거 수와 이름의 구체성 등을 이용해 대표 개념을 선택해요. 사내 별칭처럼 모호한 후보에는 자동 병합 대신 사람의 승인을 사용해요.

Consistency: 여러 근거가 서로 양립할 수 있는가

같은 개념을 설명하는 evidence가 둘 이상이면 출처가 다른 조합을 우선 비교해요. 결과는 단순히 일치와 불일치로만 나누지 않아요.

  • consistent: 같은 내용을 설명해요.
  • complementary: 서로 다른 부분을 설명하지만 함께 성립할 수 있어요.
  • contradicts: 동시에 성립하기 어려운 주장을 해요.
  • unrelated: 동일한 개념과 관련되어 있지만 직접 비교할 수 없어요.

작성 시각이 크게 다르다면 이전 정보를 대체한 것인지도 별도로 살펴봐요. 코드와 문서가 다르다는 이유만으로 항상 코드를 정답으로 결정하지는 않아요. 문서가 목표 상태를 설명하고 코드가 아직 이전 상태일 수도 있기 때문이에요.

Coverage: 중요한 근거가 빠지지 않았는가

coverage는 가장 판단하기 어려운 품질이에요. 존재하는 근거가 맞는지는 확인할 수 있어도, 회사 안에 존재해야 할 모든 정보를 알고 있는지는 쉽게 증명할 수 없기 때문이에요.

현재 Topic은 출처별 점검, 코드 영역별 semantic card 분포, 추출 과정에서 발견한 coverage gap처럼 관찰 가능한 신호를 관리해요. coverage를 완성된 하나의 점수로 주장하기보다, 어느 출처와 영역을 아직 충분히 읽지 못했는지 드러내는 방식에 가까워요.

충돌은 제거하지 않고 상태로 관리했어요

신뢰할 수 있는 컨텍스트 레이어라고 하면 충돌이 없는 깨끗한 데이터베이스를 떠올리기 쉬워요. 하지만 실제 회사의 지식은 원래 충돌해요. 정책이 바뀌는 중일 수도 있고, 문서와 구현의 반영 시점이 다를 수도 있으며, 두 팀이 같은 용어를 다르게 사용할 수도 있어요.

Topic은 충돌을 발견했을 때 한쪽 정보를 조용히 삭제하거나 임의로 승자를 정하지 않아요. 서로 다른 양쪽의 주장과 인용문, 원문 링크를 함께 보존해요. 그리고 disputedstale_risk처럼 컨텍스트를 사용하는 사람이 이해할 수 있는 상태로 노출해요.

사람이 한쪽 문서를 오래된 것으로 확인하거나 특정 근거를 현재 정책으로 승인하면 그 판단도 별도의 유효성 판단으로 저장해요. 원본 자체를 수정하지 않고도 다음 검색과 답변에서 검증된 근거를 우선하고, 오래된 근거에는 주의를 표시할 수 있어요.

이 구조에서 사람은 모든 답변을 검수하지 않아요. Topic이 먼저 변경과 충돌 후보를 좁히고, 사람이 없이는 결정하기 어려운 항목만 검토해요. 사람의 판단은 다시 이후의 컨텍스트 선택에 반영되고요.

에이전트는 검색 결과가 아니라 상태가 있는 컨텍스트를 받아요

Topic을 도입한 뒤에도 LLM은 여전히 답을 만들어요. 달라진 점은 답을 만들기 전에 받는 재료예요.

기존에는 질문과 가까운 raw context가 주어졌다면, Topic을 사용하는 클라이언트는 다음 정보를 함께 받을 수 있어요.

Topic 기반의 질의응답 에이전트는 이를 trusted, partly_trusted, disputed, stale_risk, insufficient_evidence 같은 상태로 표현해요. 상태만 보여주는 데 그치지 않고, 왜 그런 상태가 되었는지 확인할 수 있는 근거도 함께 반환하고요.

여기서 trusted는 Topic의 답이 절대적으로 옳다는 뜻이 아니에요. 현재 연결된 출처 안에서 답을 뒷받침하는 근거가 있고, 알려진 충돌이나 최신성 문제가 발견되지 않았다는 뜻에 가까워요. 반대로 insufficient_evidence도 실패가 아니에요. 확인되지 않은 내용을 그럴듯하게 답하지 않기 위한 중요한 결과예요.

API나 MCP를 통해 Topic을 사용하는 여러 LLM 클라이언트는 같은 근거와 컨텍스트 상태를 공유할 수 있어요. 새로운 에이전트를 만들 때마다 문서와 코드의 충돌 처리, 최신성 판단, 사내 용어 연결을 다시 구현할 필요도 줄어들어요.

답을 만드는 것보다, 답을 믿을 수 있게 만드는 일

Topic을 만들며 가장 크게 바뀐 것은 기술보다 문제를 바라보는 방식이었어요.

처음에는 LLM이 회사 정보를 더 잘 검색하면 된다고 생각했어요. 하지만 실제로 필요했던 것은 더 많은 정보가 아니었어요. 어떤 출처에서 나온 정보인지, 지금도 유효한지, 다른 근거와 어떤 관계인지, 어디까지 확인되었는지를 신뢰할 수 있도록 관리하는 계층이었어요.

아직 모든 품질 문제를 자동으로 해결한 것은 아니에요. 특히 Coverage처럼 ‘무엇이 빠졌는가’를 판단하는 문제와 팀의 암묵적인 별칭을 이해하는 문제에는 사람의 맥락이 계속 필요해요. 다만 이 불확실성을 숨기지 않고 관리 가능한 상태로 만드는 것부터가 신뢰할 수 있는 컨텍스트 레이어의 시작이라고 생각해요.

Topic은 답을 만드는 시스템이 아니에요.

답을 만드는 데 사용되는 컨텍스트를 믿을 수 있게 만드는 시스템이에요.

뉴스레터가 발행되면
이메일로 알려드릴게요
구독하기