입사 후 개인의 불편함에서 시작해 전사 AI 플랫폼을 만들기까지

Tech/기타 2026. 3. 28. 13:53
728x90
728x90

 

서론

 

AI 시대에 개발자로 살아남으려면 - 백엔드 개발자의 스타트업 수습 회고

서론이직 후 딱 3개월이 되는 오늘, 수습 전환 계약서에 사인을 했다.돌아보면 정신없었다. 이전 환경과 달리, 스택도 도메인도 업무 방식도 전부 다른 환경에 던져졌다. 이전 회사에서는 IDC 환

mag1c.tistory.com

 

 

스타트업으로 이직 후, 수습 회고 통해 입사 후 혼란스러운 환경을 개선하기 위한 노력들을 소개했었습니다.

 

요약하자면 빠르게 적응하기 위해 개인적으로 가장 필요했던 것이 무엇인지 생각해봤고, 자주 연달아 이어지는 구두 논의나 회의 때문에 이전 맥락을 잊어버리기 일쑤였습니다. 이를 극복하기 위해 미팅 컨텍스트 허브를 만들어서 개인적으로 사용해왔습니다. 미팅 녹음을 올리면 AI가 자동으로 요약하고, 결정사항을 뽑아주고, 임베딩해서 검색 가능하게 만들어주는 도구였습니다.

 

개인적으로 만들어 사용했던 미팅 컨텍스트 허브

 


개인적으로 MCH(Meeting-Context-Hub)라고 부르는 이 도구의 사용성이 꽤나 좋았어서 전사적으로 공유해야겠다고 생각했던 찰나, 사내에서도 티로라고 불리는 비슷한 제품을 구독해서 사용하기 시작했습니다. 미팅록을 한 곳에 모으고 정리하는 역할은 티로가 해결해버렸습니다. 전사적으로 제 도구를 도입하지는 못했지만, 이 과정을 겪으면서 돌아보게 된 것이 있습니다.

 

구성원들이 더 효율적으로 빠르게 일하기 위해, 현재 워크플로우의 문제들이 무엇일까?


미팅에서 결정된 내용은 티로를 통해 확인할 수 있습니다. 하지만 그 결정이 실행되는 과정은 슬랙 스레드에 흩어집니다. 더불어 큰 의사결정 안에서 작은 논의들이 계속 오가고, 자잘하게 변경되는 내용도 많습니다.

또, 저는 이제 막 수습을 지났습니다. 제가 입사하기 이전의 수 많은 컨텍스트는 무시되어도 되는 걸까요? 이전 조직 구성원들이 슬랙에 나눈 대화와 정리된 노션 문서 또한 조직의 자산입니다. 이건 저희 조직 뿐 아니라 어떤 조직이든 공통적인 부분이라고 생각합니다.

MCH를 만들면서 느낀 건, 개인의 기억력 문제가 아니라 조직의 맥락이 검색 가능한 형태로 존재하지 않는다는 것이었습니다. 미팅록은 그 퍼즐의 한 조각일 뿐이고, 여러 컨텍스트들이 전부 한 곳에 모여야 비로소 재활용 가능한 가장 좋은 형태가 될 것이라고 생각했습니다. 원활하게 꺼내볼 수 있어야 이걸 찾아보는 리소스도 줄어들고, 자연스레 다음 스텝이 있을 수 있다고 판단했습니다. 그래서 이 허브를 전사의 컨텍스트 허브로 확장해보기로 했습니다.

 

 

사내 컨텍스트 허브인 아라

 

 

 

데이터 수집 파이프라인 설계

사람이 작업을 하든, AI가 하든 맥락이 있어야 합니다. 당연하게도 슬랙과 노션에 흩어진 조직의 지식을 한 곳에 모으는 것이 첫 시작이었습니다. 현재 파이프라인을 시각화하면 아래와 같습니다.

 

 

 

 

임베딩

임베딩을 통해 텍스트를 고정 길이의 숫자 벡터로 변환합니다. 예를 들어 연차 규정휴가 정책의 글자는 다르지만, 임베딩 벡터 간의 거리는 가깝다는 논리입니다. 이를 이용하면 키워드가 정확히 일치하지 않아도 의미적으로 유사한 문서를 찾을 수 있게 됩니다.

 

 

 

임베딩에는 OpenAI의 text-embedding-3-small 모델을 사용했습니다. 우선은 빠르게 PoC를 구축하는 것이 목표였기 때문에 API 호출 한 번으로 바로 쓸 수 있고, 비용이 저렴한 것을 우선에 뒀습니다. 블로그를 작성하는 지금 시점에서는 임베딩 벤치마크 리더보드에서 괜찮은 모델로 마이그레이션 할 계획을 가지고 있습니다.

 

변환된 벡터를 SQLite에 AI가 추출한 메타데이터와 함께 저장합니다. AI가 뽑아낸 메타데이터(제목, 요약, 태그)는 키워드 검색과 필터링에 활용하고, 임베딩은 시맨틱 검색에 활용됩니다. 이 때 수집된 원본 텍스트는 반드시 그대로 보존합니다.  조직의 데이터 규모가 수천 건 수준이기 때문에 파일 하나로 단순하게 관리되고, 백업에도 용이하기 때문입니다. 코사인 유사도(임베딩) 계산은 애플리케이션 레벨에서 수행할 수 있는 수준이기 때문에 SQLite를 사용했습니다.

 

 

 

 

메타데이터 추출 파이프라인

메타데이터를 수집 시점에 AI가 한 번 추출하지만, 특히 긴 문서의 경우 중요한 결정사항이나 맥락을 놓치는 경우가 있었습니다. 

그래서 2-pass 구조로 확장하여, 최초 수집 시점은 빠르게 처리하고, 그 뒤에 정교한 분석을 수행하도록 했습니다.

 

// notion-ingester.ts — 2nd pass는 fire-and-forget
const firstPass: FirstPassResult = {
  title: ctx.context.title ?? '',
  summary: ctx.context.summary ?? '',
  decisions: ctx.context.decisions ?? [],
  actionItems: ctx.context.actionItems ?? [],
  tags: mergedTags,
};

runEnhancedExtraction(ctx.context.id, rawInput, firstPass, null, { skipGuard: true })
  .catch(err => console.warn(`Enhanced extraction failed:`, err));

 

2nd pass에서는 1st pass의 결과와 원본 텍스트를 함께 LLM에게 전달하여 최종 검토를 하게 됩니다.

 

 

 

 

데이터 수집

현재 조직의 컨텍스트는 노션과 슬랙으로 관리되고 있습니다. 두 도구의 성격이 당연히 다른데요.

  • 슬랙: 러프한 대화 위주의 내용. 잡담도 포함. 데이터 구조가 단순하며 플랫함(최대 1Depth Thread 구조)
  • 노션: 구조화된 포맷을 기반으로 각 논의된 내용들을 어느정도 정리함. 페이지, DB 등 여러 Depth로 구성되어 복잡함

도구의 성격이 다른 만큼, 수집 방법도 달랐는데요, 슬랙과 노션의 수집 방법은 이렇습니다.

 

 

슬랙

평일 1시간 단위의 스케줄러가 슬랙의 채널 대화를 수집합니다. 채널 중에는 정말 잡담을 위한 채널도 존재하고, 레거시 채널도 존재합니다. 수집할 필요가 없는 채널들을 제외하고 화이트리스트 방식으로 수집 대상 채널만 직접 선택하는 구조로 만들었습니다. 이 채널의 대화는 조직의 지식으로 보존할 가치가 있다고 판단되는 채널만 등록했습니다.

 

굳이 디테일을 언급하자면, 증분 수집을 위해 채널 단위의 독립적인 워터마크를 추가했습니다.

// slack-watermark.service.ts
export interface ChannelWatermark {
  channelId: string;
  channelName: string;
  lastThreadTs: string;    // 마지막 수집 시점의 타임스탬프
  updatedAt: string;
}

 

이를 통해 각 채널의 메세지와 쓰레드를 증분 수집했고, 별도 노이즈 필터링을 통해 봇 메세지나 시스템 메세지, 10자 미만 메세지나 특정 노이즈 키워드(ㅋㅋ, 넵 등등)를 위한 딕셔너리를 별도로 두어 관리하고 있습니다.

 

 

노션

노션은 특정 결정의 중간/최종 산물의 성격을 띱니다. 그렇기에 평일 12시간 단위로 08:00, 20:00에 하루 일과 시작 전/후를 고려하여 배치 시간을 정했습니다.

 

위에서 언급했듯이 노션은 복잡한 뎁스의 트리 구조입니다. 페이지 안에 하위 페이지가 있고, 그 안에 또 하위 페이지가 있을 수 있죠. 데이터베이스 안에 row가 있고, 각 row도 하나의 페이지입니다. 그래서 기존 노션 문서들의 포맷을 보면서, 어느 깊이까지 탐색해야 할 지 정했습니다.

 

 

 

더불어 중복과 업데이트 처리도 같이 신경 써야했습니다. 같은 배치 안에서 상위 하위 페이지가 모두 수집 대상이 될 수 있고, 이미 수집한 페이지가 수정되었을 경우도 충분히 발생할 수 있는 경우이기 때문입니다.

 

// notion-ingester.ts — 중복/업데이트 판별
for (const page of pages) {
  // 제외 대상 페이지 또는 그 하위 트리 전체를 건너뜀
  if (excludeSet.has(page.id) || (page.parentId && excludeSet.has(page.parentId))) {
    result.skippedExcluded++;
    continue;
  }

  // 동일 배치 내 중복 제거
  if (seenPageIds.has(page.id)) {
    result.skippedDuplicate++;
    continue;
  }
  seenPageIds.add(page.id);

  // 마지막 수집 이후 수정되지 않은 페이지는 건너뜀
  const existing = existingByPageId.get(page.id);
  if (existing && existing.lastEditedTime >= page.lastEditedTime) {
    result.skippedUnchanged++;
  } else {
    toProcess.push(page);
  }
}

 

 

결론적으로, 제외 목록을 건너뛰고, 배치 내 중복을 건너뛰며, 마지막으로 변경 여부를 확인하여 건너 뛰는 로직을 통과한 페이지만 5개의 동시 워커로 병렬 처리했습니다.

 

 

 

 

RAG는 실패했다

결론부터 얘기하자면, 저는 RAG 방식으로 대차게 실패했습니다. 첫 시도부터 틀려먹어서 좀 당황했습니다. 이 허브를 구축하면서 RAG에 관련된 레퍼런스를 많이 찾아봤는데, 정말 매력적이고 좋은 패턴이라고 생각합니다. 다만 저는 이 패턴에 대한 이해도가 모자랐기 때문에 실패했다고 생각합니다.

 

 

제 최초 설계는 5단계 RAG 파이프라인이었습니다.

  1. SEARCH -  임베딩 유사도 + 키워드(SQL LIKE) 하이브리드 검색
  2. FILTER - 유사도 0.3(30%) 미만 결과 제거
  3. ENRICH - 슬랙 메타(채널명, 날짜), 미팅록이 있다면 미팅록의 메타로 검색 결과 보강
  4. ASSEMBLE - Token Budget(21000자, 약 6000토큰) 내에서 컨텍스트 조립
  5. SYNTHESIZE - Claude에게 조립된 컨텍스트와 질문을 전달하여 답변 생성

 

 

거창하게 5단계를 나눠서 썻지만, 결론적으로 RAG란 제가 미리 특정 컨텍스트를 단정지어서 LM에게 넘기는 방식입니다.

 

위 실패 케이스를 자세히 살펴보고 비슷한 실험들을 토대로 결론을 내릴 수 있었습니다. 결국 긴 문서에서 제가 설계한 방법은 틀려먹었다는 겁니다. 저희 노션 페이지에 긴 문서들의 특징은 한 페이지 안에 여러 주제의 내용들이 혼재되어 있는 경우에 해당합니다. 위 연차 규정 같은 경우도 신규 입사자 온보딩 가이드라는 큰 문서의 한 섹션에 있었고, 저는 그 가이드 전체를 통째로 임베딩 했었던 것이었습니다.

 

문서 전체를 하나의 벡터로 변환했을 때, 그 벡터는 문서의 평균적인 의미를 담습니다. 과연 이 문서가 연차 규정인지 알까요? 당연히 연차 규정이라는 질문과 코사인 유사도가 0.3 미만으로 나왔고, 2단계 필터에서 잘려나갔습니다.

 

 

 

문제점을 파악했다면 당연히 개발자스럽게 해결하면 되겠습니다. 문서를 작은 단위로 쪼개면 해결하기 쉽겠죠? 바로 청킹(Chunking)을 사용하면 됩니다. 청킹이란 방대한 정보나 개별 요소를 의미 있는 작은 덩어리(Chunk)로 묶어 기억하거나 처리하는 인지 심리학 기법이라고 합니다. 여기서의 청킹이란, 긴 문서를 청크 단위로 쪼개서 각각을 별도로 임베딩한다는 의미로 이해하시면 됩니다. 온보딩 가이드를 섹션별로 나누면 연차 섹션의 임베딩이 질문과 높은 유사도를 보일 것입니다.

 


하지만 청킹은 새로운 튜닝 포인트를 만듭니다. 청크 크기는 얼마로 할 지? 크기에 따라 문맥이 잘릴 수도 있겠죠... 문맥이 잘리면 이전 결정 사항과 분리되어 의미를 잃을 수 있고, 너무 크다면 원래 문제가 재현됩니다. 이를 위해 오버랩을 사용할 수 있습니다. 하지만 오버랩은 노션 페이지가 수정될 때 마다 청크를 다시 나누고 임베딩을 또 해야되는 문제가 있겠죠...

 

또 다른 방법으로, 청킹 과정에서도 별도 LLM 레이어를 두어 문맥 단위로 청킹 시키도록 할 수 있겠습니다. 하지만 저는 이런 방법들로 또 다른 문제를 야기할 수도 있다는 생각에, 검색 파이프라인을 고도화하는 방법에서 LLM의 자율 주행에 몸을 맡겨보기로 했습니다.

 

 

 

Agentic Search

사실 선택의 이유는 두 가지가 더 있습니다. Claude Code의 모델이 발전함에 따라 알아서 잘 찾겠구나 라는 생각과 거기에 기름을 부어준 클로드 코드 창시자인 Boris Cherny의 트윗을 보고 빠르게 전환을 결심했습니다. 트윗의 내용은 Claude Code 자체도 초기에는 RAG + 로컬 벡터 DB 기반이었다가 Agentic Search로 전환했다는 내용입니다.

 

Agentic Search를 통해 검색 파이프라인의 각 단계를 사람이 하드코딩하지 않고, LLM에게 검색 도구를 쥐어주고 스스로 판단하게 했습니다. 사람이 어떤 순서로 검색할지 정하는 게 아니라 LLM이 질문을 보고 건 키워드 검색이 낫겠다, 이 문서의 3번째 섹션을 읽어야겠다 를 자율적으로 결정합니다.

 

 

Agentic Search도 거창해보이지만 간단합니다. 로컬 CLI로 여태까지 Claude Code를 사용했던 경험을 떠올려 보니 자연스레 MCP를 떠올렸습니다. 저는 8가지 MCP를 통해 LLM의 자율 주행을 보장했습니다. 각 MCP는 Claude Agent SDK의 tool()로 정의하고, query()를 통해 질문과 MCP를 포함한 각종 옵션들을 넘겨 사용합니다. 

 

// tools.ts — MCP 도구 정의 예시
const searchByMeaning = tool(
  'search_by_meaning',
  '의미 기반(시맨틱) 검색입니다. 질문과 의미적으로 유사한 문서를 찾습니다.',
  {
    query: z.string().describe('검색할 질문 또는 문장'),
    sourceType: z.enum(['slack', 'notion', 'meeting', 'cs-kb']).optional(),
    channel: z.string().optional().describe('채널명 필터'),
  },
  async ({ query, sourceType, channel }) => {
    const results = await semanticSearch(query, { sourceType, channel, limit: 30 });
    return {
      content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
    };
  },
);
// agentic-orchestrator.ts
const conversation = query({
  prompt: req.question,
  options: {
    systemPrompt,
    mcpServers: { 'hub-search': mcpServer },
    maxTurns: 12,
    model: 'claude-sonnet-4-6',
  },
});

 

 

 

현재 구성해둔 MCP들은 다음과 같습니다.

MCP 기능 설계 의도
search_by_meaning 임베딩 기반 시멘틱 검색 의미가 유사한 문서를 넓게 탐색
search_by_keyword SQL LIKE로 제목/요약/본문 매칭 고유명사, 정확한 키워드 검색
search_by_tag 태그 AND 조건 필터(ex. doctype:policy) 문서 유형별 필터링
list_channel_contexts 특정 슬랙 채널 벌크 조회 채널 단위 맥락 파악
read_context 문서 1건 전체 읽기 검색 결과의 상세 내용 파악
read_context_section 문서 내 제목, 소제목 기준 특정 섹션만 읽기 긴 문서에서 필요한 부분만 추출
list_sources 수집된 데이터 목록 조회 어떤 데이터가 있는지 빠르게 파악
generate_document 마크다운 > DOCX 변환 후 슬랙 업로드 검색 결과를 문서로 정리

 

 

이렇게 MCP를 분리해서 LLM이 질문의 성격에 따라 전략을 선택할 수 있게 했습니다. 위의 연차 규정처럼 명확한 키워드가 있으면 바로 search_by_keyword를 사용하고, 최근 팀 분위기가 어떠니? 같은 모호한 질문에는 search_by_meaning을 사용합니다. 짧은 스레드는 read_context로 전체를 읽으면 되지만, 긴 맥락은 read_context_section으로 맥락 내 특정 섹션만 읽어야 효율적입니다. 

 

LLM이 완전히 자율적으로 도구를 선택하지만, 효율적인 전략을 가이드했습니다.
예를 들어 검색형 질의에는 search_by_meaning을 우선 사용하고 고유명사가 포함된 질문에는 search_by_keyword을 먼저, 분석형 질의에는 list_channel_contexts로 벌크 조회를 수행하라는 가이드가 있습니다.

 

 

 

 

 

연차 규정 알려줘 라는 동일한 질문에 대해 이제 Agent Search는 다음과 같이 동작합니다.

 

 

 

모든 도구 선택을 LLM이 자율적으로 수행합니다. 임베딩 유사도에 의존하지 않기 때문에 긴 문서 안에 묻힌 정보도 정확히 찾아낼 수 있게 되었습니다. 

 

 

 

운영하면서 겪은 문제들

2~3주 정도 운영해오면서 아래와 같은 이슈들을 발견했고, 다음과 같이 수정했습니다.

 

  1. 유사도가 낮은 문서까지 여러 개 확인하면서 응답 시간이 수 분까지 늘어났다
    • 비동기 + graceful degradation(실패해도 답변을 돌려줌)로 개선
  2. 슬랙 스레드에서 후속 질문을 하면 이전 대화 맥락을 몰라 발생하는 문제
    • 스레드의 이전 메세지를 수집해서 질문에 컨텍스트로 주입

 

 

 

 

맺으며

개인의 미팅록 관리 도구에서, 필요한 것들을 하나씩 추가하며 점진적으로 구조를 정리하고 전사적인 플랫폼으로 발전시키려고 했습니다. 이 허브가 궁극적으로 지향하는 것은 조직의 기억입니다. 누구든지 직군의 구분 없이 조직의 맥락을 쉽게 파악하고, 과거 논의와 결정을 즉시 찾아주고 담당자가 바뀌어도 히스토리가 유실되지 않게 하는 것. 그것을 사람의 손으로 항상 문서를 정리하고 인수인계를 만들어야 했던 것들을 당장에 해결할 수 있겠죠. 또, 이 허브를 바탕으로 CS 자동화도 쉽게 구축할 수 있습니다. 허브에 과거 CS들을 최초 정리만 하고, 정말 필요 시 CX 담당자가 직접 소통 할 때, CX 담당자의 상담 내용을 재학습하는 하네스를 구축한다면 쉽게 도달 할 수 있는 영역입니다.

(이미 CS 봇은 테스트 중에 있습니다.)

 

물론 위에서 언급한 UX가 전부라 생각하지는 않습니다. 저는 이 허브를 바탕으로, 다양한 시도들을 해나갈 생각입니다. 이 허브가 정말 두뇌라면, AX화를 하기 위한 멀티 에이전트의 오케스트레이터가 되기에 가장 적합하다고 생각합니다. 이러한 방향으로 발전시켜 볼 생각입니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록