<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Dongjun&apos;s Blog</title><description>AI Solutions Architect&apos;s Tech Blog</description><link>https://blog.dongjun.win/</link><language>ko-KR</language><item><title>AI가 코드를 쓰는 시대, 좋은 아키텍처란 무엇일까?</title><link>https://blog.dongjun.win/posts/ai-era-architecture-context-boundaries/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/ai-era-architecture-context-boundaries/</guid><description>AI가 코드를 읽고 수정하는 환경에서 좋은 아키텍처를 컨텍스트 크기, 경계, 위치 파악과 사이드 이펙트 관점으로 다시 살펴봅니다.</description><pubDate>Tue, 28 Jul 2026 02:30:26 GMT</pubDate><content:encoded>&lt;p&gt;우리가 쓰는 개발 방식과 구조는 대부분 사람이 코드를 읽고 이해하며 운영하는 환경에서 발전해왔다. 지금은 AI가 코드 작성과 유지보수에 참여하는 팀이 빠르게 늘고, 일부 팀에서는 운영 중인 코드도 AI가 수정한다. 그렇다면 사람이 중심이던 환경에서 정립된 좋은 구조의 기준은 지금도 충분할까. 이 질문이 한동안 머릿속을 떠나지 않았다.&lt;/p&gt;
&lt;p&gt;특히 레거시를 운영할 때 그렇다. AI로 코드를 고치고 나서 의존도가 높은 지점의 사이드 이펙트가 자주 생긴다는 얘기를 들었다. 직접 통계를 낸 건 아니고 전해 들은 얘기지만, 원인을 짐작하기는 어렵지 않다. 고친 파일은 맞는데 그 파일에 매달린 것들을 안 읽었거나 못 읽었다.&lt;/p&gt;
&lt;p&gt;AI는 프로젝트 전체를 머릿속에 담고 있지 않다. 매번 필요한 만큼을 읽어서 컨텍스트에 올린다. 그 컨텍스트에는 한계가 있다. 토큰 한도라는 하드 리밋이 있고, 그보다 훨씬 앞에서도 과제에 따라 정확도가 저하될 수 있다.&lt;/p&gt;
&lt;p&gt;질문이 바뀌었다. &quot;이 구조가 읽기 좋은가&quot;가 아니라 &quot;AI가 이 기능을 고치려면 몇 개의 파일을 읽어야 하고, 안 읽은 곳에서 무엇이 깨지는가&quot;다.&lt;/p&gt;
&lt;p&gt;이 글은 그 질문을 놓고 정리한 생각과, 실제 프로젝트에서 측정해본 숫자의 기록이다. 결론부터 적으면 판단 기준 한 줄로 압축된다. &lt;strong&gt;AI가 특정 기능을 수정할 때, 해당 모듈과 소수의 파일만 읽고 안전하게 끝낼 수 있는가.&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;컨텍스트는 유한한 자원이다&lt;/h2&gt;
&lt;p&gt;Anthropic이 정리한 &lt;a href=&quot;https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents&quot;&gt;Effective context engineering for AI agents&lt;/a&gt;는 컨텍스트를 &quot;수확 체감이 있는 유한한 자원&quot;으로 보는 실무적 관점을 제시한다. 토큰을 하나씩 더 넣을 때마다 모델의 어텐션 예산이 소모된다는 설명이다.&lt;/p&gt;
&lt;p&gt;명목상 컨텍스트 윈도우가 커졌다고 그 범위 전체에서 같은 정확도가 보장되지는 않는다. Chroma가 2025년 7월에 낸 Context Rot 리포트는 18개 LLM을 여러 장문맥 과제로 평가했다. 실험 전반에서 입력이 길어질수록 성능이 대체로 저하되거나 더 불안정해졌지만, 하락 폭과 시작 지점은 모델과 과제마다 달랐고 모든 모델이 모든 실험에 포함된 것도 아니다. 별도 연구인 &lt;a href=&quot;https://arxiv.org/abs/2502.05167&quot;&gt;NoLiMa&lt;/a&gt;에서도 최소 128K 컨텍스트를 지원한다고 알려진 13개 모델 중 11개가 32K 입력에서 짧은 입력 기준 성능의 절반 아래로 떨어졌다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;표준 full self-attention&lt;/a&gt;은 길이 n의 입력에서 토큰 관계를 n² 규모로 계산한다. 다만 이 계산 복잡도 자체가 정확도 저하의 직접 원인으로 입증된 것은 아니다. Anthropic은 많은 관계에 어텐션이 분산되는 문제와 장문맥 학습 데이터의 부족을 가능한 설명으로 든다. 적어도 &quot;다 넣으면 알아서 찾겠지&quot;가 정확도를 보장하지 않는다는 점은 여러 장문맥 평가가 공통으로 보여준다.&lt;/p&gt;
&lt;p&gt;Anthropic이 제시하는 대안 중 하나가 just-in-time 방식이다. 데이터를 미리 다 올려두는 대신 파일 경로 같은 가벼운 식별자만 들고 있다가, 런타임에 도구로 필요한 것만 끌어온다. 사람이 데이터베이스를 통째로 외우지 않고 필요할 때 조회하는 것과 같은 구조다. 핵심 정보는 미리 넣고 나머지는 런타임에 찾는 하이브리드도 가능하다.&lt;/p&gt;
&lt;p&gt;이 방식이 성립하려면 전제가 하나 필요하다. &lt;strong&gt;필요한 것만 골라낼 수 있어야 한다.&lt;/strong&gt; 여기에는 폴더 구조뿐 아니라 검색 가능한 메타데이터, 인덱스, 도구와 탐색 휴리스틱이 함께 작용한다. 이 글은 그중 코드 아키텍처가 맡는 부분을 본다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;안 읽은 코드에서 깨진다&lt;/h2&gt;
&lt;p&gt;에이전트가 도구로 조회하지 못했고 탐색 과정에서도 찾지 못한 코드는 사실상 존재하지 않는 코드와 같다. 여기서 실패 모드가 둘로 갈린다. 하나는 고쳐야 할 파일을 못 찾는 경우, 다른 하나는 고친 파일에 매달린 것들을 모르는 경우다. 앞의 것은 작업이 실패하니 바로 안다. 뒤의 것은 작업이 성공한 것처럼 끝난다.&lt;/p&gt;
&lt;p&gt;결합도가 높은 레거시에서 두 번째가 특히 잘 터진다. 함수 하나를 고치는 변경이 그 자체로는 완전히 맞다. 그 함수를 부르는 곳이 일곱 군데인데 그중 세 곳이 컨텍스트 밖에 있다는 게 문제다. 사람이라면 &quot;여기 건드리면 결제 쪽이 위험하다&quot; 같은 감을 오래 일하면서 쌓아둔다. 에이전트에게 그 감은 이번에 읽은 파일 안에만 존재한다.&lt;/p&gt;
&lt;p&gt;정보 은닉이 여기서 다시 걸린다. Parnas가 1972년에 쓴 &lt;a href=&quot;https://dl.acm.org/doi/10.1145/361598.361623&quot;&gt;On the Criteria To Be Used in Decomposing Systems into Modules&lt;/a&gt;는 모듈을 나누는 기준을 &quot;각 모듈이 무엇을 감추는가&quot;로 잡았다. 어렵거나 바뀔 가능성이 높은 설계 결정을 나열하고, 각 모듈이 그중 하나씩을 숨기게 한다. 인터페이스는 내부를 최대한 드러내지 않도록 고른다. 시스템을 한 번에 한 모듈씩 이해할 수 있게 하는 게 목표다.&lt;/p&gt;
&lt;p&gt;&quot;다른 모듈의 내부를 몰라도&quot;라는 조건이 사람에게는 편의였다. 에이전트에게는 전제 조건이다. 모듈이 내부를 잘 감출수록 안 읽고 넘어가도 되는 범위가 분명해진다. 반대로 경계가 새는 구조에서는 안전하게 안 읽어도 되는 범위를 정하기 어려워진다. 더 많이 읽거나, 놓칠 위험을 감수해야 한다.&lt;/p&gt;
&lt;p&gt;약 54년 전 원칙이 지금 다시 걸리는 이유는 원칙이 새로워서가 아니다. 어겼을 때의 비용 구조가 바뀌었기 때문이다. 예전에는 경계가 흐려지면 사람이 헤맸고, 헤매면서도 감으로 막았다. 지금은 경계가 흐려지면 에이전트가 토큰을 태우고, 그러고도 안 읽은 곳을 깨뜨린다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;폴더 구조가 곧 탐색 신호다&lt;/h2&gt;
&lt;p&gt;같은 글에서 파일 계층과 이름 자체가 모델에게 신호로 작동한다는 대목이 인상적이었다. &lt;code&gt;tests&lt;/code&gt; 폴더 안의 &lt;code&gt;test_utils.py&lt;/code&gt;와 &lt;code&gt;src/core_logic/&lt;/code&gt; 안의 &lt;code&gt;test_utils.py&lt;/code&gt;는 이름이 같아도 다른 의미를 갖는다. 모델은 그 배치에서 &quot;이걸 언제 어떻게 써야 하는지&quot;를 읽어낸다.&lt;/p&gt;
&lt;p&gt;위치 파악이 중요한 문제라는 것은 성능 지표로도 확인된다. &lt;a href=&quot;https://arxiv.org/abs/2407.01489&quot;&gt;Agentless 논문&lt;/a&gt;은 복잡한 에이전트 루프 없이 위치 파악(localization) → 수정 → 검증이라는 3단계로 SWE-bench Lite에서 300문제 중 96개(32.00%)를 해결했고, 당시 오픈소스 접근 중 최고 성능이었다. 문제당 평균 비용은 0.70달러였다.&lt;/p&gt;
&lt;p&gt;이후 연구들이 파고든 지점 중 하나가 위치 파악이다. &lt;a href=&quot;https://aclanthology.org/2025.acl-long.426/&quot;&gt;LocAgent&lt;/a&gt;는 SWE-bench Lite에서 기존 함수 수정이 없는 사례를 뺀 274문제를 대상으로 파일 Acc@1 77.74%를 기록했다. 같은 표본과 지표를 쓴 &lt;a href=&quot;https://arxiv.org/abs/2603.01012&quot;&gt;FastCode&lt;/a&gt;는 86.13%를 보고했고, 별도 설정으로 평가한 &lt;a href=&quot;https://arxiv.org/abs/2606.24820&quot;&gt;SHERLOC&lt;/a&gt;은 84.33%를 보고했다. 수치들은 사용 모델과 평가 조건이 달라 하나의 연속된 상승 곡선으로 비교할 수는 없다. 그래도 정확한 위치 파악이 문제 해결의 중요한 전제이자 큰 탐색 비용이라는 점은 공통으로 드러난다. 수정 생성, 진단 품질, 테스트와 패치 선택은 별도의 병목으로 남는다.&lt;/p&gt;
&lt;p&gt;이 연구들이 파일명이나 폴더 구조를 사람이 더 잘 설계했을 때의 효과를 직접 실험한 것은 아니다. 다만 Anthropic이 설명한 메타데이터 신호와 위치 파악 연구를 함께 놓으면, 파일명과 폴더 구조로 역할을 예측할 수 있게 만드는 것은 합리적인 실무 선택이다. 어디에 무엇이 있는지가 구조로 드러나면 에이전트가 탐색을 시작할 단서가 늘어난다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;토큰으로 계산해보면 50배 차이였다&lt;/h2&gt;
&lt;p&gt;여기까지는 원칙이고, 실제로 얼마나 차이가 나는지 궁금해서 진행 중인 프로젝트로 재봤다. 도메인 특화 AI 에이전트를 붙인 서비스고, API는 Bun + TypeScript, 프런트는 Vue다. 아래 나오는 모듈명과 파일명은 일반화했고, 바이트와 줄 수는 실측 그대로다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;api/src&lt;/code&gt; 아래에서 &lt;code&gt;/test/&lt;/code&gt; 경로를 제외한 TypeScript 파일 전체가 1,017,880바이트다. 이 안에서 에이전트 v3의 프로덕션 TypeScript 49개가 364,087바이트를 차지한다. 이 모듈에 검색 도구를 하나 추가하는 작업의 진입 컨텍스트로 고른 파일은 네 개다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;읽는 범위&lt;/th&gt;
&lt;th&gt;크기&lt;/th&gt;
&lt;th&gt;전체 대비&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API 프로덕션 TypeScript&lt;/td&gt;
&lt;td&gt;1,017,880 B&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent-v3 프로덕션 TypeScript&lt;/td&gt;
&lt;td&gt;364,087 B&lt;/td&gt;
&lt;td&gt;2.8분의 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도구 추가의 진입 파일 4개&lt;/td&gt;
&lt;td&gt;19,120 B&lt;/td&gt;
&lt;td&gt;53분의 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;네 개 파일은 모듈 README, 도구 레지스트리, AI SDK 어댑터, 도구 타입 정의다. 실제 구현에서는 새 도구와 유사한 구현이나 테스트를 추가로 찾아볼 수 있다. 19,120바이트는 안전한 전체 작업 범위를 증명한 숫자가 아니라, 작업을 시작하기 위해 먼저 읽을 범위를 잰 숫자다.&lt;/p&gt;
&lt;p&gt;토큰으로 환산하면 체감이 더 분명해진다. 정확한 토크나이저를 돌린 값은 아니고 TypeScript 소스 기준 대략 3.5바이트당 1토큰으로 잡은 추정치다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;API 소스 전체     1,017,880 B  ≈ 291,000 토큰
도구 추가 4개 파일    19,120 B  ≈   5,500 토큰
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/about-claude/pricing&quot;&gt;Claude Opus 5의 표준 입력 단가&lt;/a&gt;는 100만 토큰당 5달러다. 위 추정 토큰 수가 맞다고 가정하면 전체를 한 번 읽을 때 약 1.46달러, 네 개의 진입 파일은 약 0.03달러다. 이후 요청에서 같은 프롬프트 구간이 캐시에 적중하면 해당 토큰의 읽기 가격은 기본 입력가의 10분의 1로 내려간다. 다만 최초 캐시 쓰기 비용이 별도로 들고, 두 범위에 같은 캐시 적중 조건을 적용할 때만 비율이 유지된다.&lt;/p&gt;
&lt;p&gt;비용보다 중요한 건 앞 절의 정확도 문제다. Chroma의 실험 과제는 TypeScript 코드 수정이 아니므로 291,000 토큰이라는 숫자만으로 이 작업이 더 많이 틀린다고 단정할 수는 없다. 다만 장문맥 연구가 공통으로 보여주듯, 관련 없는 입력을 더 넣는다고 정확도가 보장되지는 않고 오히려 불안정해질 수 있다. 전체를 읽히는 선택은 비싸고, 정확도 이득도 불확실하다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;내 프로젝트가 지킨 것과 못 지킨 것&lt;/h2&gt;
&lt;p&gt;원칙을 정리해놓고 내 코드를 보면 성적표가 갈린다.&lt;/p&gt;
&lt;p&gt;지킨 쪽부터. 모듈 경계를 문서가 아니라 테스트로 막아뒀다. v3 모듈이 레거시 모듈을 import하면 테스트가 깨진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;test(&apos;production source가 legacy 모듈을 import하지 않는다&apos;, async () =&amp;gt; {
  const violations: string[] = [];
  for (const file of await sourceFiles(root)) {
    const source = await readFile(file, &apos;utf8&apos;);
    if (/from\s+[&apos;&quot;][^&apos;&quot;]*(?:legacy-a|legacy-b)\//.test(source)) {
      violations.push(file.replace(`${root}/`, &apos;&apos;));
    }
  }
  expect(violations).toEqual([]);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정규식 몇 줄짜리 테스트인데, &quot;의존하지 마세요&quot;라고 적어둔 주석보다 훨씬 잘 작동한다. 사람이든 에이전트든 규칙을 어기면 테스트 실행에서 바로 드러난다.&lt;/p&gt;
&lt;p&gt;모듈마다 README도 뒀다. v3 모듈 README에는 API 목록, 히스토리 저장 원칙, 모델 설정이 어느 파일에서 조립되는지, 도구 이름이 뭐가 있는지가 들어 있다. 9,823바이트짜리 파일 하나를 읽으면 이 모듈에서 무엇을 어디서 고쳐야 하는지가 대충 잡힌다. 에이전트 입장에서 이건 소스 49개를 훑는 것보다 훨씬 싼 진입점이다.&lt;/p&gt;
&lt;p&gt;못 지킨 쪽. 파일이 크다. 테스트 제외 상위 파일을 뽑아보면 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2,067줄  agent-v3/service/agent-v3.service.ts
2,024줄  agent-v3-message-list.vue
2,007줄  agent-v3/dao/agent-v3-history.dao.ts
1,606줄  shared/service/reference.resolver.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모듈 경계는 그었는데 모듈 안에서 파일이 비대해졌다. 파일 단위로 여는 도구라면 2,067줄짜리 서비스의 함수 하나를 고칠 때도 파일 전체가 컨텍스트에 올라온다. 함수나 줄 범위만 읽는 도구는 이를 피할 수 있지만, 큰 파일 안에서 관련 범위를 찾고 영향도를 판단하는 비용은 남는다. 모듈 단위로 줄여놓은 컨텍스트가 파일 단위에서 다시 커질 수 있는 구조다.&lt;/p&gt;
&lt;p&gt;여기서 원래 메모에 적어뒀던 것 하나를 정정해야 한다. &lt;a href=&quot;https://openai.com/index/harness-engineering/&quot;&gt;OpenAI의 harness engineering 사례&lt;/a&gt;에서 100줄 안팎이라는 구체적 기준이 붙는 대상은 소스 파일이 아니라 &lt;code&gt;AGENTS.md&lt;/code&gt;다. 에이전트 컨텍스트에 항상 들어가는 진입 문서를 짧게 유지하고 상세는 구조화된 &lt;code&gt;docs/&lt;/code&gt; 디렉터리로 넘긴다. 다만 같은 글은 별도로 소스 파일 크기 제한도 커스텀 린트로 강제한다고 밝힌다. 공개된 것은 제한의 존재까지이고 구체적인 줄 수 기준은 없다.&lt;/p&gt;
&lt;p&gt;그러니 OpenAI 사례에서 가져올 수 있는 결론은 &quot;100줄짜리 소스 파일&quot;이 아니라 &quot;파일 크기를 기계적으로 관리한다&quot;까지다. 내 프로젝트에서 어디를 나눌지는 위 표와 실제 변경 작업을 보고 별도로 정해야 한다.&lt;/p&gt;
&lt;p&gt;솔직히 나는 &lt;code&gt;AGENTS.md&lt;/code&gt;를 그렇게 쓰지 않는다. 이 프로젝트에도 루트에 &lt;code&gt;AGENTS.md&lt;/code&gt;가 있긴 한데 1,480바이트짜리고, 내용은 전부 프로젝트 메모리 도구 사용법이다. 코딩 컨벤션도, 아키텍처 설명도, &lt;code&gt;docs/&lt;/code&gt;로 가는 지도도 없다. 안 써도 충분히 잘 돌아간다.&lt;/p&gt;
&lt;p&gt;왜 그런지 생각해보면 답이 앞에 있다. 모듈이 기능 단위로 갈려 있고, 폴더 이름이 &lt;code&gt;controller&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt;, &lt;code&gt;dao&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt;, &lt;code&gt;llm&lt;/code&gt;으로 일정하고, 모듈마다 README가 있고, 경계는 테스트가 막는다. 에이전트가 알아야 할 것의 대부분이 이미 구조에 박혀 있다. 진입 문서가 따로 필요한 상황은 구조만으로는 역할을 예측할 수 없을 때 생긴다.&lt;/p&gt;
&lt;p&gt;그러니까 &lt;code&gt;AGENTS.md&lt;/code&gt;가 필요 없다는 얘기가 아니라, 순서가 있다는 얘기에 가깝다. 문서는 구조가 말해주지 못하는 것을 대신 말해준다. 구조를 고칠 수 있는 상황이라면 문서보다 구조를 먼저 고치는 편이 낫다. 문서는 쉽게 낡고, 테스트와 린트는 규칙을 어기면 즉시 알려준다. 물론 테스트에 새긴 규칙 자체도 계속 관리해야 한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;불변식의 중복은 OpenAI 팀의 원칙과 충돌한다&lt;/h2&gt;
&lt;p&gt;원래 정리해둔 항목 중에 &quot;과도한 공통화로 결합도를 높이기보다 단순한 로직은 약간의 중복을 허용한다&quot;가 있었다. 확인해보니 이 문장 전체가 harness engineering 사례와 반대인 것은 아니다. 충돌하는 지점은 여러 곳에서 어긋나면 안 되는 불변식이다.&lt;/p&gt;
&lt;p&gt;Ryan Lopopolo가 소개한 해당 팀의 golden principles에는 &quot;불변식을 중앙화하기 위해 hand-rolled helper보다 공유 유틸리티 패키지를 선호한다&quot;가 명시돼 있다. 별도의 동시성 사례에서 이 팀은 범용 &lt;code&gt;p-limit&lt;/code&gt; 계열 패키지 대신 OpenTelemetry 계측과 런타임 요구에 맞춘 자체 &lt;code&gt;map-with-concurrency&lt;/code&gt; 헬퍼를 만들었다. 이것은 중복 제거 사례라기보다 에이전트가 저장소 안에서 완전히 읽고 검증할 수 있는 의존성을 선호한 사례다.&lt;/p&gt;
&lt;p&gt;강제 방식도 문서에만 맡기지 않았다. 의존 방향은 &lt;code&gt;Types → Config → Repo → Service → Runtime → UI&lt;/code&gt; 순서로 고정하고, 커스텀 린터와 구조 테스트로 기계적으로 강제했다. 앞 절의 모듈 경계 테스트와 같은 발상이다.&lt;/p&gt;
&lt;p&gt;그러면 중복 허용은 틀린 얘기인가. 한 겹 들어가보면 충돌 지점이 좁다.&lt;/p&gt;
&lt;p&gt;OpenAI가 공유 유틸리티로 모으라고 한 대상은 &lt;strong&gt;불변식(invariant)&lt;/strong&gt; 이다. 여러 곳에서 각자 구현하면 서로 어긋나고, 어긋난 걸 나중에야 발견하는 종류의 로직. 동시성 제어, 경계 검증 같은 것들이다. 이건 내가 정리해둔 다른 항목(&quot;핵심 비즈니스·보안·데이터 정합성 규칙은 한곳에서 관리&quot;)과 정확히 같은 말이다.&lt;/p&gt;
&lt;p&gt;반면 내가 중복을 허용하겠다고 한 대상은 날짜 포맷팅이나 짧은 변환 함수처럼 어긋나도 티가 나고 고치기 쉬운 로직이다. 이런 걸 공통 모듈로 끌어올리면 모듈 간 의존이 하나 늘어난다. 에이전트가 A 모듈을 고치다가 공통 모듈을 열고, 공통 모듈을 고치면 B와 C가 깨질까 봐 다시 B와 C를 읽는다. 세 줄짜리 함수 하나 때문에 읽어야 할 범위가 모듈 세 개로 번진다.&lt;/p&gt;
&lt;p&gt;그래서 기준은 중복이냐 공통화냐가 아니라 &lt;strong&gt;틀렸을 때 조용히 넘어가느냐&lt;/strong&gt;다. 조용히 어긋나는 규칙은 한곳에 모으고 기계로 강제한다. 어긋나면 바로 티가 나는 로직은 모듈 안에 두고 경계를 지킨다.&lt;/p&gt;
&lt;p&gt;다만 이건 논문으로 검증된 결론이 아니라 모듈 독립성과 컨텍스트 비용을 같이 놓고 내린 실무 판단이다. 반대 사례를 만나면 바뀔 수 있다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;남은 기준 한 줄&lt;/h2&gt;
&lt;p&gt;쌓인 원칙을 추리면 다섯 개다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;파일명과 폴더 구조로 역할을 예측할 수 있게 만든다. 이름과 배치는 탐색의 중요한 신호다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;모듈 경계는 문서에만 맡기지 않고 테스트와 린트로 막는다. 문서와 규칙은 둘 다 관리한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;모듈이 내부를 잘 감출수록 에이전트가 안 읽고 넘어가도 되는 범위가 분명해진다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;조용히 어긋나는 규칙은 한곳에 모으고, 그렇지 않은 로직은 모듈 안에 둔다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;파일 크기를 방치하면 전체 파일을 읽는 도구의 컨텍스트가 커지고, 부분 읽기 도구에서도 탐색 비용이 늘어난다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 전부를 하나로 줄이면 처음에 적었던 질문으로 돌아간다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;AI가 특정 기능을 수정할 때, 해당 모듈과 소수의 파일만 읽고 안전하게 완료할 수 있는가.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 문장에서 무게가 실리는 단어는 &quot;소수의 파일&quot;과 &quot;안전하게&quot; 두 개다. 앞은 읽는 비용이고 뒤는 안 읽어서 깨지는 비용이다. 낮은 결합도는 대체로 둘을 같은 방향으로 개선하지만, 안전성은 구조만으로 보장되지 않는다. 테스트, 의존 관계 탐색, 런타임 검증이 함께 있어야 한다.&lt;/p&gt;
&lt;p&gt;이 질문이 좋은 이유는 적어도 일부를 측정할 수 있기 때문이다. &quot;읽기 좋은 코드&quot;는 사람마다 다르지만 &quot;이 작업을 시작하려면 몇 바이트를 읽는가&quot;는 셀 수 있다. 내 프로젝트에서 진입 컨텍스트의 숫자는 53분의 1이었다. 다만 이 숫자는 컨텍스트 양만 말해준다. 안전성까지 재려면 실제 변경 파일 수, 위치 파악 재현율, 회귀 테스트와 실패율을 함께 봐야 한다. 2,067줄짜리 파일은 그다음 측정 대상이다.&lt;/p&gt;
&lt;p&gt;사람이 편하자고 만든 방식이 지금도 맞는지는 방식마다 다르게 답할 문제다. 다만 이제는 답을 감이 아니라 숫자로 낼 수 있게 됐다. 다음에 손볼 곳은 이미 정해진 셈이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;참고 자료&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents&quot;&gt;Effective context engineering for AI agents — Anthropic&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://openai.com/index/harness-engineering/&quot;&gt;Harness engineering: leveraging Codex in an agent-first world — OpenAI&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/2407.01489&quot;&gt;Agentless: Demystifying LLM-based Software Engineering Agents&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/2502.05167&quot;&gt;NoLiMa: Long-Context Evaluation Beyond Literal Matching&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://aclanthology.org/2025.acl-long.426/&quot;&gt;LocAgent: Graph-Guided LLM Agents for Code Localization&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/2603.01012&quot;&gt;FastCode: Fast and Cost-Efficient Code Understanding and Reasoning&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/2606.24820&quot;&gt;SHERLOC: Structured Diagnostic Localization for Code Repair Agents&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Attention Is All You Need&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://dl.acm.org/doi/10.1145/361598.361623&quot;&gt;On the Criteria To Be Used in Decomposing Systems into Modules — D.L. Parnas (1972)&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://research.trychroma.com/context-rot&quot;&gt;Context Rot: Evaluating LLM Performance Degradation with Increasing Input Tokens — Chroma&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/about-claude/pricing&quot;&gt;Claude API pricing — Anthropic&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>STT 모델 실제 성능 비교: 한국어 회의 녹음 35분, 7개 모델 테스트</title><link>https://blog.dongjun.win/posts/korean-stt-7-model-comparison/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/korean-stt-7-model-comparison/</guid><description>35분 한국어 개발 회의 녹음으로 7개 STT 모델의 속도, 정확도, 용어 인식, 환각과 타임스탬프 품질을 실제 비교합니다.</description><pubDate>Wed, 03 Jun 2026 21:24:45 GMT</pubDate><content:encoded>&lt;hr /&gt;
&lt;p&gt;2026년 2월, 한국어 개발 회의 녹음 하나를 가지고 로컬 STT(Speech-to-Text) 모델 7개를 비교했다.&lt;/p&gt;
&lt;p&gt;테스트 오디오는 약 35분 45초 길이의 2인 개발 회의 녹음이다. 정제된 벤치마크 데이터셋이 아니라 실제 회의 녹음이었다. 발화는 비격식 대화체였고, 중간중간 Claude, TDD, CRUD, agent.md, Cursor, Codex, vector DB 같은 개발 용어가 섞여 있었다.&lt;/p&gt;
&lt;p&gt;이 글은 최신 STT 모델 순위가 아니다. 당시 내 환경에서 실제 회의록 자동화에 어떤 모델이 쓸 만한지 확인한 실험 기록이다. 단일 오디오 기준이므로 모든 상황에 일반화할 수는 없지만, 실제 한국어 회의 녹음에서 모델별 차이가 꽤 뚜렷하게 드러났다.&lt;/p&gt;
&lt;h2&gt;테스트 조건&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;테스트 시점&lt;/td&gt;
&lt;td&gt;2026년 2월&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 환경&lt;/td&gt;
&lt;td&gt;macOS, Apple Silicon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;테스트 오디오&lt;/td&gt;
&lt;td&gt;한국어 개발자 회의 녹음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;오디오 길이&lt;/td&gt;
&lt;td&gt;약 35분 45초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;화자&lt;/td&gt;
&lt;td&gt;2명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;발화 특성&lt;/td&gt;
&lt;td&gt;비격식 회의체, 개발 기술 용어 다수 포함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비교 대상&lt;/td&gt;
&lt;td&gt;STT 모델 7개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;평가 기준&lt;/td&gt;
&lt;td&gt;속도, 정확도, 핵심 용어 인식, 환각, 타임스탬프, 안정성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;테스트한 모델은 아래와 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;프레임워크&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;Python, FunASR&lt;/td&gt;
&lt;td&gt;알리바바 다국어 모델&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;Python, faster-whisper&lt;/td&gt;
&lt;td&gt;OpenAI Whisper 경량 모델, 244M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;Python, faster-whisper&lt;/td&gt;
&lt;td&gt;Large-v3 기반 경량 최적화 변형&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;Python, faster-whisper&lt;/td&gt;
&lt;td&gt;OpenAI Whisper 중형 모델, 769M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;Python, faster-whisper&lt;/td&gt;
&lt;td&gt;OpenAI Whisper 대형 모델, 1.55B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;Swift, WhisperKit&lt;/td&gt;
&lt;td&gt;Apple Neural Engine 최적화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;Swift, WhisperKit&lt;/td&gt;
&lt;td&gt;632MB 양자화 경량 모델&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;한눈에 보는 결과&lt;/h2&gt;
&lt;p&gt;결론부터 보면, 가장 좋은 모델은 CoreML Large-v3 Turbo FP16이었다. 속도만 보면 Whisper Small과 CoreML 4-bit이 빨랐지만, 회의록에 필요한 정확도와 타임스탬프까지 고려하면 CoreML FP16이 가장 안정적이었다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;순위&lt;/th&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;종합 점수&lt;/th&gt;
&lt;th&gt;한줄 평가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;4.5/5&lt;/td&gt;
&lt;td&gt;정확도, 용어 인식, 타임스탬프, 안정성 모두 최상위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;4.0/5&lt;/td&gt;
&lt;td&gt;Python 환경에서 가장 안정적인 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;4.0/5&lt;/td&gt;
&lt;td&gt;빠른 처리와 준수한 품질의 균형&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;3.3/5&lt;/td&gt;
&lt;td&gt;가장 빠르지만 기술 용어 오류가 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;3.1/5&lt;/td&gt;
&lt;td&gt;속도는 괜찮지만 용어 인식과 안정성이 기대 이하&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;2.1/5&lt;/td&gt;
&lt;td&gt;큰 모델이지만 환각과 타임스탬프 문제가 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;2.1/5&lt;/td&gt;
&lt;td&gt;한국어 긴 회의 녹음에는 부적합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;속도 비교&lt;/h2&gt;
&lt;p&gt;속도는 모델 로드 시간, 추론 시간, 총 소요 시간을 나눠서 봤다. RTF는 Real-Time Factor로, 추론 시간 ÷ 오디오 길이다. 1.0x보다 낮으면 실시간보다 빠르게 처리한 것이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;모델 로드&lt;/th&gt;
&lt;th&gt;추론 시간&lt;/th&gt;
&lt;th&gt;총 소요 시간&lt;/th&gt;
&lt;th&gt;RTF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;8.1초&lt;/td&gt;
&lt;td&gt;3분 20.8초&lt;/td&gt;
&lt;td&gt;3분 28.9초&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;56.9초&lt;/td&gt;
&lt;td&gt;2분 31.4초&lt;/td&gt;
&lt;td&gt;3분 28.3초&lt;/td&gt;
&lt;td&gt;0.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;3분 54.9초&lt;/td&gt;
&lt;td&gt;6분 48.0초&lt;/td&gt;
&lt;td&gt;10분 42.9초&lt;/td&gt;
&lt;td&gt;0.19x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;3분 24.6초&lt;/td&gt;
&lt;td&gt;7분 46.0초&lt;/td&gt;
&lt;td&gt;11분 10.6초&lt;/td&gt;
&lt;td&gt;0.22x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;17분 53.8초&lt;/td&gt;
&lt;td&gt;32분 46.7초&lt;/td&gt;
&lt;td&gt;50분 40.5초&lt;/td&gt;
&lt;td&gt;0.92x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;10.8초&lt;/td&gt;
&lt;td&gt;10분 46.3초&lt;/td&gt;
&lt;td&gt;10분 57.1초&lt;/td&gt;
&lt;td&gt;0.30x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;5.6초&lt;/td&gt;
&lt;td&gt;4분 47.0초&lt;/td&gt;
&lt;td&gt;4분 52.6초&lt;/td&gt;
&lt;td&gt;0.13x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;총 소요 시간 기준으로 보면 Whisper Small과 SenseVoice-Small이 약 3분 28초로 가장 빨랐다. 하지만 이 둘은 정확도에서 크게 밀렸다.&lt;/p&gt;
&lt;p&gt;실무적으로 가장 눈에 띈 모델은 CoreML 4-bit이었다. 35분 45초 오디오를 약 4분 53초에 처리했고, 모델 로드도 5.6초로 가장 빨랐다. 회의 내용을 빠르게 훑기 위한 초안 생성 용도로는 가장 효율적인 모델이었다.&lt;/p&gt;
&lt;p&gt;반대로 Whisper Large-v3는 로드에만 17분 53.8초, 추론에 32분 46.7초가 걸렸다. 총 소요 시간은 50분 40.5초였다. 35분짜리 오디오를 처리하는 데 50분이 걸렸으므로, 이번 환경에서는 실무용으로 쓰기 어려웠다.&lt;/p&gt;
&lt;h2&gt;핵심 용어 인식 비교&lt;/h2&gt;
&lt;p&gt;회의록에서 중요한 것은 문장이 자연스러운지만이 아니다. 개발 회의에서는 특정 용어를 정확히 받아 적는 것이 중요하다. 예를 들어 TDD를 PDD로 적거나, CRUD를 CR 요기로 적으면 나중에 검색과 요약 단계에서 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;아래 표는 원본 결과 파일에서 핵심 기술 용어를 직접 대조한 결과다. &lt;code&gt;✅&lt;/code&gt;는 정확, &lt;code&gt;⚠️&lt;/code&gt;는 부분 오류, &lt;code&gt;❌&lt;/code&gt;는 오인식을 뜻한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;원본 용어&lt;/th&gt;
&lt;th&gt;SenseVoice&lt;/th&gt;
&lt;th&gt;W-Small&lt;/th&gt;
&lt;th&gt;W-Turbo&lt;/th&gt;
&lt;th&gt;W-Medium&lt;/th&gt;
&lt;th&gt;W-Large-v3&lt;/th&gt;
&lt;th&gt;CoreML FP16&lt;/th&gt;
&lt;th&gt;CoreML 4-bit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;클로드 (Claude)&lt;/td&gt;
&lt;td&gt;✅ 클로드&lt;/td&gt;
&lt;td&gt;❌ 클로드 안 드시겠&lt;/td&gt;
&lt;td&gt;❌ 클로즈&lt;/td&gt;
&lt;td&gt;✅ 클로드&lt;/td&gt;
&lt;td&gt;❌ code&lt;/td&gt;
&lt;td&gt;✅ 클로드&lt;/td&gt;
&lt;td&gt;❌ 클로즈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TDD&lt;/td&gt;
&lt;td&gt;❌ 필리비&lt;/td&gt;
&lt;td&gt;❌ PDV&lt;/td&gt;
&lt;td&gt;❌ pdd&lt;/td&gt;
&lt;td&gt;✅ TDD&lt;/td&gt;
&lt;td&gt;✅ TDD&lt;/td&gt;
&lt;td&gt;✅ TDD&lt;/td&gt;
&lt;td&gt;❌ PDD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRUD&lt;/td&gt;
&lt;td&gt;❌ 시알요디&lt;/td&gt;
&lt;td&gt;❌ CR 요기&lt;/td&gt;
&lt;td&gt;❌ cr-od&lt;/td&gt;
&lt;td&gt;✅ CRUD&lt;/td&gt;
&lt;td&gt;⚠️ crd&lt;/td&gt;
&lt;td&gt;✅ CRUD&lt;/td&gt;
&lt;td&gt;⚠️ CR,UD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent.md&lt;/td&gt;
&lt;td&gt;❌ 에전트&lt;/td&gt;
&lt;td&gt;❌ 에전템디&lt;/td&gt;
&lt;td&gt;✅ agent.md&lt;/td&gt;
&lt;td&gt;✅ Agent.md&lt;/td&gt;
&lt;td&gt;✅ agent.md&lt;/td&gt;
&lt;td&gt;✅ agent.md&lt;/td&gt;
&lt;td&gt;✅ Agent.md&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커서 (Cursor)&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;✅ 커서&lt;/td&gt;
&lt;td&gt;⚠️ 커서/컷&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커서 룰스&lt;/td&gt;
&lt;td&gt;⚠️ 커서롤수&lt;/td&gt;
&lt;td&gt;⚠️ 커서 루스&lt;/td&gt;
&lt;td&gt;⚠️ 커서 루스&lt;/td&gt;
&lt;td&gt;⚠️ 커서 롤소&lt;/td&gt;
&lt;td&gt;⚠️ 커서 룰수&lt;/td&gt;
&lt;td&gt;⚠️ 커서 룰 수&lt;/td&gt;
&lt;td&gt;⚠️ 커서 루스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;오픈클로 (OpenClaw)&lt;/td&gt;
&lt;td&gt;✅ 오픈클로&lt;/td&gt;
&lt;td&gt;⚠️ 오픈 클로&lt;/td&gt;
&lt;td&gt;✅ OpenClaw&lt;/td&gt;
&lt;td&gt;✅ 오픈클로&lt;/td&gt;
&lt;td&gt;✅ 오픈클로&lt;/td&gt;
&lt;td&gt;✅ 오픈클로&lt;/td&gt;
&lt;td&gt;✅ 오픈클로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해피코더&lt;/td&gt;
&lt;td&gt;⚠️ 해피&lt;/td&gt;
&lt;td&gt;⚠️ 해피&lt;/td&gt;
&lt;td&gt;⚠️ 해피&lt;/td&gt;
&lt;td&gt;⚠️ 해피 코더&lt;/td&gt;
&lt;td&gt;✅ 해피코더&lt;/td&gt;
&lt;td&gt;✅ 해피코더&lt;/td&gt;
&lt;td&gt;⚠️ 해피 코더&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코덱스 (Codex)&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;td&gt;✅ 코덱스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;벡터 DB&lt;/td&gt;
&lt;td&gt;❌ 벡터디이&lt;/td&gt;
&lt;td&gt;⚠️ 백터디비&lt;/td&gt;
&lt;td&gt;✅ 벡터 DB&lt;/td&gt;
&lt;td&gt;✅ vectorDB&lt;/td&gt;
&lt;td&gt;✅ 벡터 DB&lt;/td&gt;
&lt;td&gt;✅ 벡터 DB&lt;/td&gt;
&lt;td&gt;⚠️ 벡터디비&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;노션 (Notion)&lt;/td&gt;
&lt;td&gt;✅ 노션&lt;/td&gt;
&lt;td&gt;✅ 노션&lt;/td&gt;
&lt;td&gt;✅ 노션&lt;/td&gt;
&lt;td&gt;✅ 노션&lt;/td&gt;
&lt;td&gt;✅ notion&lt;/td&gt;
&lt;td&gt;✅ notion&lt;/td&gt;
&lt;td&gt;✅ 노션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;옵시디언 (Obsidian)&lt;/td&gt;
&lt;td&gt;⚠️ 옵시디안&lt;/td&gt;
&lt;td&gt;✅ 옵시디언&lt;/td&gt;
&lt;td&gt;✅ 옵시디언&lt;/td&gt;
&lt;td&gt;✅ 옵시디언&lt;/td&gt;
&lt;td&gt;✅ obsidian&lt;/td&gt;
&lt;td&gt;✅ obsidian&lt;/td&gt;
&lt;td&gt;✅ obsidian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;텔레그램&lt;/td&gt;
&lt;td&gt;⚠️ 텔레그룸&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;td&gt;✅ 텔레그램&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;젠마 (Gemma)&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;td&gt;✅ 젠마&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;점수로 환산하면 다음과 같다. 정확 인식은 1점, 부분 오류는 0.5점, 오인식은 0점으로 계산했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;정확&lt;/th&gt;
&lt;th&gt;부분 오류&lt;/th&gt;
&lt;th&gt;오인식&lt;/th&gt;
&lt;th&gt;용어 인식 점수&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;69%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;69%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;CoreML FP16과 Whisper Medium이 확실히 좋았다. 특히 CoreML FP16은 TDD, CRUD, Claude, agent.md를 모두 정확히 인식한 유일한 모델이었다.&lt;/p&gt;
&lt;p&gt;Whisper Turbo는 이름만 보면 기대가 컸지만, 이번 테스트에서는 TDD를 pdd로, CRUD를 cr-od로, Claude를 클로즈로 인식했다. 문장 흐름은 어느 정도 자연스러웠지만 핵심 용어 인식에서는 Medium보다 낮았다.&lt;/p&gt;
&lt;h2&gt;동일 구간 문장 비교&lt;/h2&gt;
&lt;p&gt;동일한 발화 구간을 비교하면 모델별 차이가 더 잘 보인다. 원본 발화는 대략 다음과 같은 내용이었다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;변환 결과&lt;/th&gt;
&lt;th&gt;평가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어&lt;/td&gt;
&lt;td&gt;✅ 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;URL을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어&lt;/td&gt;
&lt;td&gt;✅ 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;url을 호출해서 컨트롤러를 실행하는 방식으로 TDD를 만들었어&lt;/td&gt;
&lt;td&gt;✅ 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;url을 호출해서 컨트롤러를 실행하는 방식으로 pdd를 만들었어&lt;/td&gt;
&lt;td&gt;❌ TDD 오류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML 4-bit&lt;/td&gt;
&lt;td&gt;URL을 호출해서 컨트럴러를 실햌하는 방식으로 PDD를 만들었어&lt;/td&gt;
&lt;td&gt;⚠️ 양자화 오타&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;URL을 호출해서 컨트롤러를 실행하는 방식으로 PDV를 만들었어&lt;/td&gt;
&lt;td&gt;❌ TDD 오류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;유아를 호해서 컨트롤를 실행하는 방식으로든지 필리비를 만들었어&lt;/td&gt;
&lt;td&gt;❌ 심각한 오류&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;이 구간만 보면 CoreML FP16과 Whisper Medium이 가장 안정적이었다. CoreML 4-bit은 전체 맥락은 유지했지만 양자화로 인한 오타가 눈에 띄었다. Whisper Small은 문장은 자연스럽게 만들었지만 핵심 용어를 틀렸다.&lt;/p&gt;
&lt;h2&gt;환각 및 결함 비교&lt;/h2&gt;
&lt;p&gt;정확도보다 더 위험한 문제는 환각이었다. 회의록에서 없는 말을 만들어내면 후속 요약이나 의사결정 기록까지 오염될 수 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;환각/결함 유형&lt;/th&gt;
&lt;th&gt;심각도&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;외국어 무작위 삽입&lt;/td&gt;
&lt;td&gt;심각&lt;/td&gt;
&lt;td&gt;한국어 문장 중간에 영어, 태국어, 아랍어, 타밀어 등이 섞임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;마지막 구간 반복&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;녹음 끝부분에서 &quot;고마워요&quot;를 여러 번 반복&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;세그먼트 반복&lt;/td&gt;
&lt;td&gt;경미&lt;/td&gt;
&lt;td&gt;6분 17초 부근 일부 세그먼트 중복&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;중국어 문자 혼입&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;한국어 텍스트에 중국어 문자가 섞임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML FP16&lt;/td&gt;
&lt;td&gt;공백 구간&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;20분 10초 부근 약 30초 누락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML 4-bit&lt;/td&gt;
&lt;td&gt;빈 세그먼트, 양자화 오타&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;일부 빈 세그먼트와 &quot;컨트럴러&quot;, &quot;실햌&quot; 같은 오타&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;오인식&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;환각은 없었지만 기술 용어 오인식이 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Whisper Large-v3의 환각은 특히 심각했다. 한국어 회의 녹음 중간에 여러 언어가 무작위로 섞였고, 해당 구간은 회의록으로 사용할 수 없었다. 또한 전체 텍스트가 하나의 블록으로 출력되어 타임스탬프 기반으로 문제 구간을 찾기도 어려웠다.&lt;/p&gt;
&lt;p&gt;CoreML FP16도 완벽하지는 않았다. 약 30초 누락 구간이 있었다. 다만 없는 내용을 생성하는 환각은 발견되지 않았고, 타임스탬프 품질이 좋아서 후처리 가능성이 높았다.&lt;/p&gt;
&lt;h2&gt;타임스탬프 품질 비교&lt;/h2&gt;
&lt;p&gt;회의록 자동화에서는 타임스탬프도 중요하다. 텍스트만 있으면 전체 요약은 가능하지만, 특정 발화가 나온 구간으로 돌아가기는 어렵다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;세그먼트 수&lt;/th&gt;
&lt;th&gt;세그먼트 크기&lt;/th&gt;
&lt;th&gt;정밀도&lt;/th&gt;
&lt;th&gt;평가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;약 563&lt;/td&gt;
&lt;td&gt;2~8초&lt;/td&gt;
&lt;td&gt;밀리초&lt;/td&gt;
&lt;td&gt;매우 우수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;약 493&lt;/td&gt;
&lt;td&gt;2~6초&lt;/td&gt;
&lt;td&gt;초&lt;/td&gt;
&lt;td&gt;우수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;약 524&lt;/td&gt;
&lt;td&gt;2~10초&lt;/td&gt;
&lt;td&gt;밀리초&lt;/td&gt;
&lt;td&gt;양호&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;약 1,481&lt;/td&gt;
&lt;td&gt;약 1초&lt;/td&gt;
&lt;td&gt;초&lt;/td&gt;
&lt;td&gt;과분할&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;약 977&lt;/td&gt;
&lt;td&gt;약 2초&lt;/td&gt;
&lt;td&gt;초&lt;/td&gt;
&lt;td&gt;과분할&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;전체 블록&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;사용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;전체 블록&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;사용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;타임스탬프는 CoreML FP16이 가장 좋았다. 2~8초 단위로 자연스럽게 나뉘었고, 밀리초 단위 정밀도를 제공했다.&lt;/p&gt;
&lt;p&gt;Whisper Medium도 충분히 좋았다. 초 단위 정밀도이긴 하지만 문장 경계에 맞춘 2~6초 세그먼트라 회의록 용도로 사용하기 좋았다.&lt;/p&gt;
&lt;p&gt;반면 Whisper Turbo와 Whisper Small은 세그먼트가 너무 잘게 쪼개졌다. 한 문장이 여러 조각으로 나뉘어서 후처리가 필요했다. Whisper Large-v3와 SenseVoice-Small은 사실상 타임스탬프를 사용할 수 없었다.&lt;/p&gt;
&lt;h2&gt;종합 점수&lt;/h2&gt;
&lt;p&gt;속도, 정확도, 용어 인식, 타임스탬프, 안정성을 5점 만점으로 평가했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;속도&lt;/th&gt;
&lt;th&gt;정확도&lt;/th&gt;
&lt;th&gt;용어 인식&lt;/th&gt;
&lt;th&gt;타임스탬프&lt;/th&gt;
&lt;th&gt;안정성&lt;/th&gt;
&lt;th&gt;종합&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Turbo&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;3.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SenseVoice-Small&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;여기서 중요한 점은 속도 점수만으로 최종 순위가 정해지지 않았다는 것이다. Whisper Small과 SenseVoice-Small은 빠르지만 정확도가 낮았다. CoreML 4-bit도 빠르고 쓸 만했지만, 기술 용어 정확도에서는 FP16과 Medium보다 낮았다.&lt;/p&gt;
&lt;p&gt;회의록 자동화에서는 &quot;빠르게 대충 알아듣는 모델&quot;보다 &quot;중요한 용어를 틀리지 않고, 타임스탬프가 안정적인 모델&quot;이 더 가치 있었다.&lt;/p&gt;
&lt;h2&gt;상황별 추천&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;추천 모델&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;최고 품질 회의록 작성&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;td&gt;용어 인식, 타임스탬프, 안정성이 가장 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빠른 초안 생성&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;5분 이내 처리, 모델 로드도 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대량 반복 처리&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;td&gt;속도와 용량 측면에서 효율적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows/Linux/서버 환경&lt;/td&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;td&gt;Python 기반으로 범용성이 높고 품질이 안정적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;품질 타협 가능한 빠른 검색 인덱싱&lt;/td&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;td&gt;가장 빠르지만 기술 용어 오류 감수 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이번 테스트 기준 피하고 싶은 선택&lt;/td&gt;
&lt;td&gt;Whisper Large-v3&lt;/td&gt;
&lt;td&gt;느리고, 환각이 심하고, 타임스탬프가 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한국어 긴 회의 녹음&lt;/td&gt;
&lt;td&gt;SenseVoice-Small 비추천&lt;/td&gt;
&lt;td&gt;단어 분절 오류와 중국어 문자 혼입 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Apple Silicon Mac을 쓴다면 CoreML 계열을 먼저 보는 것이 좋았다. 최고 품질이 필요하면 CoreML FP16, 빠른 초안이 필요하면 CoreML 4-bit이 현실적인 선택이었다.&lt;/p&gt;
&lt;p&gt;Apple Silicon이 아닌 환경에서는 Whisper Medium이 가장 무난했다. 속도는 CoreML 4-bit보다 느리지만, 용어 인식과 문장 품질이 안정적이었다.&lt;/p&gt;
&lt;h2&gt;핵심 인사이트&lt;/h2&gt;
&lt;p&gt;이번 실험에서 가장 크게 배운 것은 세 가지다.&lt;/p&gt;
&lt;p&gt;첫째, 모델 크기가 실제 품질을 보장하지 않았다. 가장 큰 Whisper Large-v3는 이번 환경에서 심각한 환각과 타임스탬프 문제를 보였다.&lt;/p&gt;
&lt;p&gt;둘째, 런타임 구현의 차이가 컸다. 같은 Large-v3 Turbo 계열이라도 Python faster-whisper에서 돌린 Whisper Turbo와 Swift WhisperKit 기반 CoreML 모델의 결과는 달랐다. 특히 CoreML FP16은 용어 인식, 타임스탬프, 안정성에서 가장 좋은 결과를 냈다.&lt;/p&gt;
&lt;p&gt;셋째, 한국어 개발 회의에서는 일반 문장 품질보다 기술 용어 인식이 중요했다. TDD, CRUD, Claude, agent.md 같은 용어가 틀리면 회의록 검색과 요약 품질이 바로 떨어진다.&lt;/p&gt;
&lt;h2&gt;실무 적용에서 추가로 확인한 점&lt;/h2&gt;
&lt;p&gt;이 비교 이후 실제 녹음 앱에서는 CoreML Large-v3 Turbo를 사용했다. 다만 모델 선택만으로 문제가 끝나지는 않았다.&lt;/p&gt;
&lt;p&gt;처음에는 mic.wav와 speaker.wav를 따로 전사하는 방식도 중요하게 봤다. 내 음성과 상대 음성을 나눠 기록할 수 있으니 회의록 UI에서는 장점이 있었다. 그런데 speaker 트랙에 긴 선행 무음이 있으면 hallucination이나 timestamp 오류가 발생하는 경우가 있었다. 모델은 같아도 입력 오디오 구성이 달라지면 결과 품질이 달라졌다.&lt;/p&gt;
&lt;p&gt;그래서 앱에서는 전사 방식을 &lt;code&gt;전체 대화 전사(merged)&lt;/code&gt;와 &lt;code&gt;화자 구분 전사(separated)&lt;/code&gt;로 나눴고, 기본값은 merged 오디오 전사로 두었다. 회의 내용 요약과 기록이 목적이면 merged가 더 단순하고 안정적이고, 누가 말했는지 구분해야 할 때만 separated를 쓰는 구조다.&lt;/p&gt;
&lt;p&gt;중요한 것은 merged 전사 결과에서 mic 결과를 빼서 speaker만 복원하는 방식은 채택하지 않았다는 점이다. 동시 발화가 있으면 분리가 어렵고, Whisper가 같은 발화를 두 파일에서 항상 같은 텍스트로 전사한다는 보장도 없었다.&lt;/p&gt;
&lt;p&gt;결국 실무에서 STT 품질은 모델만의 문제가 아니었다. 어떤 오디오를 넣는지, 무음을 어떻게 다루는지, timestamp를 얼마나 신뢰할 수 있는지가 함께 중요했다.&lt;/p&gt;
&lt;h2&gt;최종 결론&lt;/h2&gt;
&lt;p&gt;이번 한국어 회의 녹음 35분 테스트 기준 최종 선택은 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;목적&lt;/th&gt;
&lt;th&gt;최종 선택&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;가장 좋은 품질&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo FP16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가장 좋은 속도와 품질 균형&lt;/td&gt;
&lt;td&gt;CoreML Large-v3 Turbo 4-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크로스 플랫폼 대안&lt;/td&gt;
&lt;td&gt;Whisper Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빠른 초안 확인&lt;/td&gt;
&lt;td&gt;Whisper Small&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;개인적으로 회의록 자동화 파이프라인을 만든다면, 먼저 CoreML 4-bit으로 빠르게 초안을 만들고, 중요한 구간이나 오류가 의심되는 구간만 CoreML FP16으로 다시 처리하는 방식이 가장 현실적이라고 봤다.&lt;/p&gt;
&lt;p&gt;이 실험은 단일 오디오 기준이기 때문에 절대적인 결론은 아니다. 하지만 실제 한국어 개발 회의 녹음에서는 &quot;큰 모델을 쓰면 무조건 좋아진다&quot;는 가정이 맞지 않았다. STT 모델을 고를 때는 모델 크기보다 실제 입력 데이터, 런타임, 타임스탬프 품질, 기술 용어 인식률을 함께 봐야 한다.&lt;/p&gt;
</content:encoded></item><item><title>내 문서를 읽는 작은 에이전트를 다시 만들며</title><link>https://blog.dongjun.win/posts/small-agent-that-reads-my-documents/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/small-agent-that-reads-my-documents/</guid><description>dongjun.win AI 어시스턴트를 Full Context 방식에서 도구 기반 문서 접근 구조로 바꾸며 정리한 작은 에이전트 설계 회고입니다.</description><pubDate>Wed, 27 May 2026 00:15:12 GMT</pubDate><content:encoded>&lt;p&gt;내 사이트 &lt;a href=&quot;https://www.dongjun.win/&quot;&gt;dongjun.win&lt;/a&gt;에 붙어 있던 작은 AI 어시스턴트를 최근에 다시 손봤다. 방문자가 &lt;a href=&quot;https://www.dongjun.win/assistant&quot;&gt;AI 어시스턴트 페이지&lt;/a&gt;에서 질문을 던지면, 내 이력서와 프로젝트 문서, 강점 진단, 리더십 리포트, 버크만(Birkman) 리포트를 바탕으로 답하는 기능이다.&lt;/p&gt;
&lt;p&gt;겉으로는 단순하다. &quot;최근 프로젝트는?&quot;, &quot;어떤 기술을 쓰나요?&quot;, &quot;일하는 방식은 어떤가요?&quot; 같은 질문에 답하는 채팅창이다. 하지만 구현 관점에서는 단순한 챗봇보다 작은 에이전트 런타임에 더 가까웠다.&lt;/p&gt;
&lt;p&gt;이번 작업의 핵심은 새 챗봇을 만드는 것도, 모델을 바꾸거나 프롬프트를 더 길게 쓰는 것도 아니었다. &lt;strong&gt;기존 어시스턴트가 문서를 언제, 어떤 경로로, 어떤 형태로 모델에게 보여줄 것인가&lt;/strong&gt;를 다시 설계하는 일이었다.&lt;/p&gt;
&lt;p&gt;기존 구현은 가장 쉬운 방식이었다. 모든 문서를 시스템 프롬프트에 넣었다. LLM 컨텍스트가 충분히 길어졌고, 문서도 몇 개 안 됐다. 별도 RAG를 붙이고 싶지도 않았다. 그래서 이력서, 프로젝트 문서, 성향 자료를 통째로 prompt에 넣고 질문만 덧붙였다.&lt;/p&gt;
&lt;p&gt;동작은 했다. 하지만 운영하기 좋은 구조는 아니었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;프롬프트를 데이터 저장소로 썼을 때&lt;/h2&gt;
&lt;p&gt;초기 구조는 이렇게 단순했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;system prompt
  - 답변 원칙
  - 이력서 전체
  - 사이드 프로젝트 문서
  - CliftonStrengths 요약
  - 리더십 리포트
  - 버크만(Birkman) 리포트

user question
  - &quot;최근 프로젝트는?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식의 장점은 분명하다. 검색 실패가 없다. chunk 설계도 필요 없다. embedding 모델도 고르지 않아도 된다. 문서가 적고 고정되어 있다면 Full Context는 꽤 합리적인 선택처럼 보인다.&lt;/p&gt;
&lt;p&gt;문제는 제품의 기본 경로가 매번 무거워진다는 점이었다. 질문이 가벼워도 모델은 항상 모든 문서를 들고 출발했다. 정확한 벤치마크를 따로 남겨두지는 않았지만, 첫 응답이 무겁게 늦어지는 체감이 분명했다.&lt;/p&gt;
&lt;p&gt;더 큰 문제는 노이즈였다. 기술 스택 질문에 성향 진단 문맥이 같이 들어오고, 프로젝트 질문에 리더십 리포트의 표현이 섞였다. 모델이 완전히 틀린 답을 한다기보다, 필요 없는 문맥까지 참고하면서 답변의 초점이 흐려졌다.&lt;/p&gt;
&lt;p&gt;이 구조에서는 시스템 프롬프트가 세 가지 역할을 동시에 맡고 있었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;assistant의 행동 원칙&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;내 문서 전체&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;문서 선택 전략&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 셋이 한 덩어리로 섞이면 변경의 단위가 흐려진다. 말투를 고친 것인지, 지식을 바꾼 것인지, 문서 접근 방식을 바꾼 것인지 추적하기 어렵다. 프롬프트가 길어진 것이 문제가 아니라, &lt;strong&gt;프롬프트가 런타임의 모든 책임을 떠안은 것&lt;/strong&gt;이 문제였다.&lt;/p&gt;
&lt;p&gt;그래서 방향을 바꿨다.&lt;/p&gt;
&lt;p&gt;RAG를 새로 만들지는 않는다. 하지만 모든 문서를 항상 넣지도 않는다. 중간 지점으로, LLM의 &lt;code&gt;tools&lt;/code&gt;를 이용해 필요한 문서만 열도록 했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;RAG 대신 문서 접근 도구를 둔 이유&lt;/h2&gt;
&lt;p&gt;이 작업에서 일반적인 벡터 RAG는 과했다.&lt;/p&gt;
&lt;p&gt;문서 수는 적고, 종류는 명확했다. 이력서, 사이드 프로젝트, 강점 진단, 리더십 리포트, 버크만 리포트. 이런 규모에서는 &quot;비슷한 chunk를 검색한다&quot;보다 &quot;어떤 문서 범주를 열어야 하는지 결정한다&quot;가 더 중요한 문제였다.&lt;/p&gt;
&lt;p&gt;그래서 검색 엔진을 새로 붙이는 대신, 기존 어시스턴트의 문서 접근을 tools 기반으로 분리했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;사용자 질문
-&amp;gt; LLM
-&amp;gt; 필요한 문서 범주 선택
-&amp;gt; searchDocuments
-&amp;gt; 필요한 documentId 선택
-&amp;gt; readDocument
-&amp;gt; 답변 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;searchDocuments&lt;/code&gt;는 벡터 검색이 아니다. 키워드 검색도 아니다. 질문에 필요한 문서 범주를 선택하게 하는 category router에 가깝다. &lt;code&gt;readDocument&lt;/code&gt;는 선택한 문서 본문 일부를 읽는다.&lt;/p&gt;
&lt;p&gt;이 구조의 의도는 명확했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;문서는 system prompt에서 분리한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;문서 선택은 모델에게 맡기되, 선택 가능한 경로는 제한한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;검색 실패 가능성은 줄이되, 모든 문서를 매번 넣는 비용은 피한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;RAG 인프라를 만들지 않고도 문서 접근을 lazy하게 만든다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, Full Context와 RAG 사이의 좁은 해법이다. 문서가 수백 개라면 부족한 구조다. 하지만 &lt;a href=&quot;https://www.dongjun.win/&quot;&gt;dongjun.win&lt;/a&gt;의 공개 프로필 AI에는 이 정도가 더 맞았다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;도구 설명은 문서 접근 계약이다&lt;/h2&gt;
&lt;p&gt;이 구조에서 품질을 결정한 것은 도구 개수가 아니라 도구 설명이었다.&lt;/p&gt;
&lt;p&gt;도구는 두 개뿐이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;searchDocuments&lt;/code&gt;: 필요한 문서 범주를 고르고 후보와 snippet을 반환한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;readDocument&lt;/code&gt;: 선택한 documentId로 본문 일부를 읽는다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;중요한 것은 &lt;code&gt;searchDocuments&lt;/code&gt;의 description이다. 단순히 &quot;문서를 검색한다&quot;가 아니라, 어떤 질문에서 어떤 문서를 열어야 하는지를 명시했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;이력서: 경력, 기술 스택, 주요 프로젝트, 연락처&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;사이드 프로젝트: Pikt, bunqldb 같은 개인 제품과 오픈소스&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CliftonStrengths: 강점, 커뮤니케이션, 팀 역할&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;리더십 리포트: 대인관리, 성과관리, 변화관리, 자기관리&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;버크만(Birkman) 리포트: 흥미, 욕구, 스트레스 행동, 선호 환경&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 도구 description은 문서실의 안내 표지판에 가깝다. 모델이 무엇을 할 수 있는지보다, &lt;strong&gt;언제 무엇을 열어야 하는지&lt;/strong&gt;가 더 중요했다.&lt;/p&gt;
&lt;p&gt;이 판단은 Hermes Agent 코드를 읽으며 정리했던 내용과도 맞닿아 있다. 도구는 시스템 프롬프트 안의 자연어 지시가 아니라 별도 채널로 들어간다. 시스템 프롬프트는 원칙과 전략을 담고, tools parameter는 호출 가능한 행동과 입력 schema를 담는다.&lt;/p&gt;
&lt;p&gt;이 분리를 하자 책임이 선명해졌다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;th&gt;위치&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;답변 원칙과 톤&lt;/td&gt;
&lt;td&gt;system prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 날짜와 경력 계산&lt;/td&gt;
&lt;td&gt;runtime context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;문서 접근 경로&lt;/td&gt;
&lt;td&gt;tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대화 상태&lt;/td&gt;
&lt;td&gt;messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장기 지식&lt;/td&gt;
&lt;td&gt;documents table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;프롬프트 하나에 모든 것을 밀어 넣을 때보다 훨씬 다루기 쉬운 구조가 됐다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;대화 저장은 부가 기능이 아니라 런타임이다&lt;/h2&gt;
&lt;p&gt;에이전트 런타임에서 대화 저장은 &quot;채팅 기록 보기&quot;를 위한 부가 기능이 아니다. 다음 호출의 입력을 복원하기 위한 핵심 경로다.&lt;/p&gt;
&lt;p&gt;LLM은 호출 사이에 기억을 갖지 않는다. 매번 &lt;code&gt;system + tools + messages&lt;/code&gt;를 새로 전달해야 한다. 따라서 어떤 메시지를 어떤 단위로 저장하느냐가 다음 호출의 동작을 결정한다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.dongjun.win/&quot;&gt;dongjun.win&lt;/a&gt;의 assistant-agent는 세 테이블로 상태를 나눴다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assistant_agent_threads
assistant_agent_sessions
assistant_agent_messages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메시지는 &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;tool&lt;/code&gt; role을 그대로 저장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:      &quot;주요 강점이 뭔가요?&quot;
assistant: searchDocuments tool-call
tool:      문서 후보 결과
assistant: readDocument tool-call
tool:      문서 본문 일부
assistant: 최종 답변
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서 중요한 것은 assistant의 텍스트만 저장하지 않는다는 점이다. tool-call과 tool-result의 관계를 보존해야 한다.&lt;/p&gt;
&lt;p&gt;assistant가 어떤 도구를 어떤 인자로 호출했는지, tool result가 어떤 call id에 대응하는지, 그 결과를 다음 모델 호출에 어떤 형태로 복원할지를 저장소가 잃어버리면 안 된다.&lt;/p&gt;
&lt;p&gt;그래서 메시지 저장에는 다음 필드를 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;parts
tool_calls
tool_call_id
tool_name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AI SDK가 provider별 변환을 상당 부분 맡아주더라도, 저장소는 provider가 바뀌어도 복원 가능한 중립 구조를 유지해야 한다. OpenAI 계열은 &lt;code&gt;tool&lt;/code&gt; role을 쓰고, Anthropic 계열은 user/assistant content block 안에 tool result를 묶는다. 표면 형식은 달라도 보존해야 하는 의미는 같다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;assistant가 어떤 도구를 요청했고, 앱이 어떤 결과를 돌려줬는가.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 페어를 보존하는 것이 메시지 저장의 핵심이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;최종 답변과 진행 상태를 분리했다&lt;/h2&gt;
&lt;p&gt;스트리밍 UI에서 흔히 어색해지는 지점이 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;먼저 문서를 검색해볼게요. 관련 내용을 확인해보겠습니다. 이제 답변드리겠습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;콘솔에서는 괜찮다. 하지만 제품 UI에서는 내부 진행 로그가 최종 답변 안에 섞인다. 사용자는 답변을 읽고 싶은데, 메시지는 도구 실행 일지를 보여준다.&lt;/p&gt;
&lt;p&gt;그래서 응답 본문과 진행 상태를 분리했다.&lt;/p&gt;
&lt;p&gt;백엔드는 SSE 이벤트를 별도로 보낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;start
activity
text-delta
finish
error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;도구 호출은 &lt;code&gt;activity&lt;/code&gt; 이벤트로 흘리고, assistant 본문에는 최종 답변만 남긴다. 프론트에서는 내부 도구명을 그대로 보여주지 않고 사용자에게 자연스러운 상태로 바꾼다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;자료 확인 중
자료 확인 완료
답변 정리 중
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;시스템 프롬프트에도 같은 원칙을 넣었다. &lt;code&gt;searchDocuments&lt;/code&gt;, &lt;code&gt;readDocument&lt;/code&gt;를 사용했다는 사실을 답변 본문에서 과정 설명처럼 쓰지 않는다. 진행 상태는 activity 이벤트가 담당하고, 최종 답변은 결론과 근거만 담는다.&lt;/p&gt;
&lt;p&gt;이 분리는 작지만 제품감에 영향을 크게 줬다. 도구 호출이 드러나지 않는 것이 아니라, &lt;strong&gt;도구 호출이 있어야 할 채널로 이동한 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;개인 소개 AI는 톤도 기능이다&lt;/h2&gt;
&lt;p&gt;이 어시스턴트는 일반 지식 챗봇이 아니다. 공개 프로필 사이트에 붙어 있고, 사용자는 나에 대해 묻는다. 따라서 답변 품질은 사실성만으로 결정되지 않는다.&lt;/p&gt;
&lt;p&gt;예를 들어 &quot;단점이 뭐예요?&quot;라는 질문은 단순 정보 검색이 아니다. 너무 방어적으로 답하면 신뢰가 떨어지고, 자기비하처럼 답하면 공개 사이트에 붙은 AI로서 부적절하다.&lt;/p&gt;
&lt;p&gt;그래서 단점 답변의 원칙을 별도로 잡았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;강점의 반대편
-&amp;gt; 주의할 점
-&amp;gt; 보완 방식
-&amp;gt; 잘 맞는 환경
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단점을 숨기지는 않는다. 다만 결함처럼 단정하지 않고, 업무 스타일과 강점의 반대편에 있는 특성으로 설명한다. 그리고 실제 보완 방식을 함께 말한다.&lt;/p&gt;
&lt;p&gt;이건 미화가 아니라 맥락화다. 개인 소개 AI는 일종의 대리 커뮤니케이션이다. 사용자는 &quot;이 사람이 어떤 사람인가&quot;를 묻고 있고, 답변은 문서 기반이어야 하면서도 공개 프로필의 맥락을 잃지 않아야 한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;시간에 따라 변하는 정보는 문서에서 빼냈다&lt;/h2&gt;
&lt;p&gt;이력서 기반 AI에서 자주 어긋나는 값이 경력 연차다.&lt;/p&gt;
&lt;p&gt;문서에는 &quot;14년&quot;, &quot;16년차&quot; 같은 정적 표현이 들어갈 수 있다. 하지만 시간이 지나면 틀린 정보가 된다. 문서를 수정하지 않는 한 모델은 오래된 숫자를 계속 인용한다.&lt;/p&gt;
&lt;p&gt;그래서 현재 날짜와 경력 계산은 문서가 아니라 runtime context로 분리했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;현재 날짜: Asia/Seoul 기준 YYYY-MM-DD
경력 시작 연도: 2010년
현재 날짜 기준 계산된 경력: 현재 연도 - 2010
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 답변 규칙은 이렇게 잡았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;경력 연차를 답변할 때는 문서의 정적 표현보다
runtime context의 계산값을 우선 사용한다.
가능하면 &quot;2010년부터 현재까지 약 N년&quot;처럼 기준을 함께 설명한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;stable한 지식과 volatile한 실행 정보를 분리한 것이다. 모든 정보를 documents table에 넣어두면 편하지만, 시간이 흐르면서 틀어지는 값은 호출 시점에 주입하는 편이 낫다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;공개 페이지에 필요한 최소 운영 장치&lt;/h2&gt;
&lt;p&gt;작은 기능이어도 공개 페이지에 붙는 AI라면 최소 운영 장치가 필요하다.&lt;/p&gt;
&lt;p&gt;이번 재설계에서 넣은 운영 장치는 크지 않다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;IP 기준 요청 제한&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IP 기준 동시 스트림 제한&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;세션별 active run lock&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;SSE heartbeat&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;스트림 취소 시 guard release&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;provider/model/usage/duration 저장&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;대화가 80,000자 이상 길어졌을 때 새 대화 권장 warning&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 세션별 active run lock은 중요했다. 같은 thread에서 동시에 두 답변이 생성되면 메시지 순서가 꼬일 수 있다. 그래서 session에 &lt;code&gt;active_run_id&lt;/code&gt;, &lt;code&gt;active_run_started_at&lt;/code&gt;을 두고, 이미 생성 중인 답변이 있으면 409로 막았다.&lt;/p&gt;
&lt;p&gt;모델 메타데이터도 메시지에 남긴다. 어떤 provider와 model이 답했는지, 얼마나 걸렸는지, usage는 어땠는지 남겨야 나중에 품질과 비용을 감으로 보지 않는다.&lt;/p&gt;
&lt;p&gt;처음에는 metadata에 대충 넣을 수도 있었다. 하지만 운영에서 반복해서 볼 값은 컬럼으로 빼는 편이 낫다. 최근에는 메시지 metadata를 단순화하고 &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;elapsed_ms&lt;/code&gt;, &lt;code&gt;usage&lt;/code&gt; 같은 필드만 명확히 남기는 쪽으로 정리했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;걷어낸 것과 넣지 않은 것&lt;/h2&gt;
&lt;p&gt;이번 작업에서는 오래된 Mastra 기반 career agent 경로를 걷어내고, 기존 어시스턴트를 assistant-agent 독립 런타임으로 정리했다.&lt;/p&gt;
&lt;p&gt;Mastra가 나쁘다는 뜻은 아니다. 처음 실험하기에는 좋은 추상화다. 다만 &lt;a href=&quot;https://www.dongjun.win/&quot;&gt;dongjun.win&lt;/a&gt;의 요구사항은 작았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 질문을 받는다.
2. 필요한 문서를 도구로 연다.
3. 답변을 스트리밍한다.
4. 메시지와 도구 결과를 저장한다.
5. UI에 진행 상태를 보낸다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 정도라면 AI SDK의 &lt;code&gt;streamText&lt;/code&gt;, 작은 tool set, DAO 몇 개로 직접 구성하는 편이 더 설명 가능했다.&lt;/p&gt;
&lt;p&gt;걷어낸 것만큼, 일부러 넣지 않은 것도 있다. 대표적으로 컨텍스트 압축이다.&lt;/p&gt;
&lt;p&gt;Hermes Agent에서 컨텍스트 압축은 꽤 정교한 기능이다. 오래된 대화를 요약하고, 앞뒤 메시지를 보존하고, role alternation과 tool-call/result 페어를 깨지 않도록 처리해야 한다. 구현할 가치는 있지만, 모든 에이전트에 필요한 기능은 아니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.dongjun.win/&quot;&gt;dongjun.win&lt;/a&gt;의 어시스턴트는 사용자가 하루 종일 붙잡고 작업하는 코딩 에이전트가 아니다. 방문자가 몇 가지 질문을 던지고 떠나는 공개 프로필 기능이다. 그래서 자동 압축 대신 80,000자 이상이면 새 대화를 권장하는 warning만 둔다.&lt;/p&gt;
&lt;p&gt;공부한 것을 전부 넣지 않는 것도 설계다. 필요한 전제만 가져오고, 기능은 문제 크기에 맞게 자른다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;최종 구조&lt;/h2&gt;
&lt;p&gt;결과적으로 남은 흐름은 이렇게 정리된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;사용자 질문
-&amp;gt; Vue chat store
-&amp;gt; /api/assistant/chat SSE 요청
-&amp;gt; request guard
-&amp;gt; thread/session resolve
-&amp;gt; session run lock
-&amp;gt; user message 저장
-&amp;gt; active config + system prompt 로드
-&amp;gt; runtime context 추가
-&amp;gt; streamText 호출
   -&amp;gt; searchDocuments
   -&amp;gt; readDocument
   -&amp;gt; 최대 5 step
-&amp;gt; assistant/tool messages 저장
-&amp;gt; text-delta/activity 이벤트 스트리밍
-&amp;gt; finish
-&amp;gt; run lock release
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;제품으로 보면 채팅창 하나다. 내부적으로는 에이전트 런타임의 기본 요소가 거의 다 들어 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;stateless LLM 호출&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;누적 messages&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;tool calling&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;tool-call/result 페어 보존&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;thread/session/message 저장&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;runtime context&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;streaming&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;activity event&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;request guard&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;model/usage/duration 관측&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;규모는 작지만 구조는 작지 않았다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;작게 만들수록 선명해진 것&lt;/h2&gt;
&lt;p&gt;이번 작업에서 남은 결론은 단순하다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;에이전트는 모델 하나가 아니라, stateless LLM 앞뒤에서 messages와 tools를 정확히 관리하는 런타임이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;여기서 중요한 단어는 &quot;정확히&quot;다.&lt;/p&gt;
&lt;p&gt;messages를 대충 저장하면 기억이 흔들린다. tool-call과 tool-result를 대충 다루면 다음 호출이 깨진다. 문서 접근 경로를 prompt에 묻어두면 느리고 흐려진다. 진행 상태와 최종 답변을 섞으면 UI가 지저분해진다. 시간이 흐르는 값을 문서에 박아두면 답변이 낡는다.&lt;/p&gt;
&lt;p&gt;반대로 경계를 잘 나누면 작은 에이전트도 안정적으로 동작한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;문서는 prompt가 아니라 tool로 연다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;대화 상태는 messages로 복원한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;현재 시점 정보는 runtime context로 넣는다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;진행 상태는 activity event로 보낸다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;운영 판단에 필요한 값은 컬럼으로 남긴다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이번 &lt;a href=&quot;https://www.dongjun.win/assistant&quot;&gt;dongjun.win AI&lt;/a&gt;는 범용 에이전트가 아니다. 내 문서를 읽고, 내 경력과 프로젝트에 대해 답하는 좁은 에이전트다. 그런데 오히려 그 좁음 덕분에 구조가 선명해졌다.&lt;/p&gt;
&lt;p&gt;큰 에이전트를 잘 만들려면 작은 에이전트부터 설명 가능해야 한다. 이번 작업은 그 기준을 맞추는 과정이었다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.dongjun.win/&quot;&gt;www.dongjun.win&lt;/a&gt;은 이제 단순한 포트폴리오가 아니라, 내가 최근에 어떤 구조를 공부했고 그것을 제품 안에 어떻게 녹였는지 보여주는 작은 데모가 됐다. 직접 확인해보고 싶다면 &lt;a href=&quot;https://www.dongjun.win/assistant&quot;&gt;AI 어시스턴트 페이지&lt;/a&gt;에서 질문을 던져보면 된다.&lt;/p&gt;
</content:encoded></item><item><title>내 실생활에 AI 더하기 (1) — 사진, 영상 하이라이트 만들기</title><link>https://blog.dongjun.win/posts/real-life-ai-1-media-highlight/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/real-life-ai-1-media-highlight/</guid><description>여행 사진과 영상을 AI가 선별하고 편집하도록 media-highlight 스킬을 만들며 자동화한 과정, 실패와 개선점을 정리합니다.</description><pubDate>Wed, 13 May 2026 20:58:37 GMT</pubDate><content:encoded>&lt;p&gt;폰 사진 앱을 켜다가 여행 영상 폴더 앞에서 매번 멈춘다.&lt;/p&gt;
&lt;p&gt;문제는 &quot;안 본다&quot;가 아니라 &quot;안 보게 된다&quot;였다. 분명히 좋아서 찍었는데, 시간이 지나니 불필요한 컷이 너무 많아서 다시 들어가기가 부담스러운 폴더가 된다. 핵심 장면만 추린 2~3분짜리 메모리 필름이 있다면 한 번에 그 시간을 다시 만날 수 있을 것 같았다.&lt;/p&gt;
&lt;p&gt;업무에서는 AI를 매일 많이 쓴다. 코드 리뷰, 설계 토론, 디버깅, 문서 정리. 거의 모든 작업이 AI와 함께 굴러간다. 그런데 &lt;em&gt;실생활&lt;/em&gt;은 그렇지 않다. 사진 검색이나 메모 정리에 가끔 도움을 받는 정도, 일회성으로 묻고 답을 받는 수준에서 멈춰 있다.&lt;/p&gt;
&lt;p&gt;이 간극이 이상했다. 같은 사람이 같은 도구를 쓰는데, 업무에서는 &lt;em&gt;체계적으로&lt;/em&gt; 굴리고 실생활에서는 &lt;em&gt;그때그때 한 번씩만&lt;/em&gt; 부른다. 도구의 문제도 아니고 능력의 문제도 아니다. &lt;em&gt;어디에 어떻게 적용할지를 한 번도 진지하게 생각해본 적이 없는&lt;/em&gt; 게 문제였다.&lt;/p&gt;
&lt;p&gt;이 글은 그 선을 처음 넘은 기록이다. 여행 영상 폴더부터 본격적으로 AI에게 맡기기 시작하면서, 어떤 데이터부터 시작할지 정하고, 어떻게 AI와 일할지 다듬은 과정. 그리고 그 과정에서 작은 피드백 한 줄이 &lt;em&gt;재사용 가능한 규칙&lt;/em&gt;으로 굳어진 이야기다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;0번 후보 — 안 보면 잃는 데이터부터&lt;/h2&gt;
&lt;p&gt;후보는 여러 개였다. 가계부 자동화, 이메일 정리, 일정 관리, 사진 백업, 손글씨 메모 정리. 우선순위를 정해 보니 답이 빨리 나왔다.&lt;/p&gt;
&lt;p&gt;가계부와 이메일은 &lt;em&gt;이미 안 본 채로도&lt;/em&gt; 시스템이 돌아간다. 안 보면 약간 불편하지만 잃는 게 없다. 사진은 이미 클라우드에 백업돼 있다. 그런데 여행 영상은 안 보면 &lt;em&gt;기억이 사라진다.&lt;/em&gt; 한 번의 여행은 다시 못 오고, 그 기록이 풀리지 않은 채 폴더에 쌓이면 결국 잊힌다.&lt;/p&gt;
&lt;p&gt;AI를 실생활에 도입하자는 막연한 동기를 구체화할 때 가장 단단한 기준은 &lt;em&gt;그것이 없으면 잃는 게 무엇인가&lt;/em&gt;였다. 여행 영상이 0번 후보로 떠올랐다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;들고 들어간 설계 윤곽&lt;/h2&gt;
&lt;p&gt;백지에서 시작하지는 않았다. 영상의 후보 컷을 &lt;em&gt;시각 신호&lt;/em&gt;(구도·노출·중복 제거·활동 분포)와 &lt;em&gt;음성 신호&lt;/em&gt;(STT 의미)로 점수 매겨 고르고, GPS 메타데이터로 위치 라벨을 묶는다 — 이 정도 설계 윤곽은 이미 머릿속에 있었다. 특히 STT를 &lt;em&gt;자막용&lt;/em&gt;만이 아니라 &lt;em&gt;어느 컷이 의미 있는지를 판단하는 선별 신호&lt;/em&gt;로도 쓴다는 것까지.&lt;/p&gt;
&lt;p&gt;그 위에서 AI에게 맡긴 것은 디테일이다. 어느 컷이 &lt;em&gt;진짜&lt;/em&gt; 좋은가. BGM은 어디서 어떤 톤으로 가져올까. 자막은 어떤 호흡으로 띄울까. 위치 라벨은 어디까지 광범위해야 &lt;em&gt;맵 앱&lt;/em&gt;처럼 보이지 않을까. 이런 디테일은 &lt;em&gt;문서 한 줄로 정의되지 않는다.&lt;/em&gt; 결과를 보고 &lt;em&gt;&quot;이건 이상해&quot;&lt;/em&gt; 라는 한 줄을 던지면서 깎아내야 한다.&lt;/p&gt;
&lt;p&gt;이 글의 본문은 그 &lt;em&gt;깎아내기&lt;/em&gt;의 기록이다. 설계는 들고 들어갔고, 다듬기는 AI와 함께 했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;도구 선택 — 평소에 쓰는 환경 안에서&lt;/h2&gt;
&lt;p&gt;도구는 짧게 결정됐다. 평소에 쓰는 Codex CLI 안에 &lt;em&gt;스킬&lt;/em&gt; 형태로 만든다. 매일 켜는 도구가 아니면 결국 안 쓰게 된다. 새 UI를 따로 만들 이유가 없었다.&lt;/p&gt;
&lt;p&gt;스킬 형태가 주는 이점은 명확했다. 의존성 관리(Homebrew, ffmpeg-full, whisper-cpp)를 스킬 안에 박아두니 다른 컴퓨터에서도 그대로 돈다. 매 단계 결과를 프롬프트로 검증할 수 있다. &lt;em&gt;&quot;이 장면은 왜 골랐어?&quot;&lt;/em&gt; 하면 Codex가 답한다. 그리고 한 번 만든 규칙이 &lt;em&gt;재사용 가능한 자산&lt;/em&gt;으로 남는다. 다음 폴더, 그 다음 폴더에서도 같은 규칙이 자동으로 작동한다.&lt;/p&gt;
&lt;p&gt;이게 일회성 작업과 &lt;em&gt;스킬 만들기&lt;/em&gt;의 결정적 차이다. 일회성으로 한 폴더 처리하는 건 누구나 한다. 같은 작업을 &lt;em&gt;다음 폴더에도&lt;/em&gt; 자동으로 시키려면 규칙을 어딘가에 박아둬야 한다. 스킬은 그 어딘가의 가장 깔끔한 형태였다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;작업 방식 — Diagnose → Execute → Verify&lt;/h2&gt;
&lt;p&gt;여러 폴더에 반복 적용하면서 같은 루프가 매번 돌았다. 각 폴더마다 한 사이클씩.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Diagnose  — 결과를 보고, 무엇이 잘못됐는지 한 줄로 말한다
2. Execute   — Codex에게 그 한 줄을 던진다. 구현은 맡긴다
3. Verify    — 다시 렌더링해서 같은 문제가 해결됐는지, 다른 게 망가지지 않았는지 본다
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;한 루프가 평균 30분 안에 돌았다. 이 사이클이 잘 돌아가게 만드는 건 사실 &lt;em&gt;한 줄로 문제를 말할 수 있는 능력&lt;/em&gt;이다. 길게 설명할수록 AI는 길게 헤맨다. 짧고 정확한 진단이 가장 빠른 코드를 만든다.&lt;/p&gt;
&lt;p&gt;이 루프를 돌면서 세 가지가 분명해졌다.&lt;/p&gt;
&lt;h3&gt;1. 구현을 맡길수록 결과가 더 좋아졌다&lt;/h3&gt;
&lt;p&gt;*&quot;BGM을 어떻게 깔지&quot;*는 결정하지 않았다. *&quot;BGM이 너무 이상해&quot;*만 말했다. ffmpeg 옵션을 어떻게 짤지, 어떤 트랙을 어디서 받을지는 Codex가 골랐다. 다만 결과 영상이 &lt;em&gt;어떻게 들리는가&lt;/em&gt;는 내가 판단했다.&lt;/p&gt;
&lt;p&gt;이게 의외로 중요했다. 구현까지 내가 지시하면 AI는 그 지시 안에서만 움직였다. *&quot;BGM 볼륨을 0.1로 설정해줘&quot;*라고 하면 0.1로 설정해 줬는데, 그게 좋은 결과인지는 따로 확인해야 했다. 반대로 *&quot;BGM이 너무 시끄러워&quot;*라고 하면 AI가 톤·맥락·믹스까지 같이 봤다. 그 쪽에서 더 좋은 결과가 자주 나왔다.&lt;/p&gt;
&lt;p&gt;결정은 외주했지만, 결과를 평가하는 책임은 내가 가지고 있었다. 결과를 평가할 능력만 있으면 됐고, 구현 디테일까지 다 알 필요는 없었다.&lt;/p&gt;
&lt;h3&gt;2. 내가 알려주지 않으면 AI도 모르는 영역이 있었다&lt;/h3&gt;
&lt;p&gt;생성형 AI에게 &lt;em&gt;완전히 모르는 영역&lt;/em&gt;을 맡기면 결과가 흔들렸다. BGM 선택이 그랬다. Codex가 무료 BGM을 검색해서 깔면 매번 어색했다. 그래서 직접 후보 7곡을 던졌다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Ikson - Sunny, Scandinavianz - Vacation, Scandinavianz - Sunny Island, MBB - Feel Good, LiQWYD - Feel, LiQWYD - Free, Joakim Karud - Dreams.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;모두 YouTube Audio Library에서 라이선스 확인 가능한 트랙이었다. 이 7곡을 스킬의 &lt;em&gt;기본 풀&lt;/em&gt;로 박았다. 이후로는 Codex가 영상 톤을 보고 풀에서 한 곡 골라서 다운받고 렌더에 넣었다. 추가 승인 없이.&lt;/p&gt;
&lt;p&gt;AI가 못 하는 게 아니라, &lt;em&gt;내가 알고 있는 것을 안 알려주면 AI도 모르는&lt;/em&gt; 거였다. 도메인 지식을 처음에 한 번 명시적으로 주입하는 게 작업의 질을 결정했다. 그리고 그걸 &lt;em&gt;스킬에 박아두면&lt;/em&gt; 다음 작업부터는 다시 안 알려줘도 됐다.&lt;/p&gt;
&lt;h3&gt;3. 숫자로 받은 자기보고가 가장 단단했다&lt;/h3&gt;
&lt;p&gt;가장 위험한 패턴이 *&quot;네, 다 처리했습니다&quot;*였다. 정확히 무엇을 어떻게 처리했는지 알 수 없었다.&lt;/p&gt;
&lt;p&gt;그래서 스킬 안에 &lt;em&gt;숫자로 보고하라&lt;/em&gt;는 규칙을 박았다. 사진 800장을 처리했으면 &lt;em&gt;&quot;812장 중 readable 810장, 컨택트 시트 13장 생성, 47장 선택, 8장 강한 후보로 표시&quot;&lt;/em&gt; 같이. 영상 QA를 했으면 &lt;em&gt;&quot;ffprobe 결과 12분 34초, blackdetect에서 1.5초 fade 외 검은 프레임 없음, silencedetect 0건&quot;&lt;/em&gt; 같이.&lt;/p&gt;
&lt;p&gt;숫자로 말하면 거짓말이 어려웠다. 내가 의심할 지점도 명확해졌다. &lt;em&gt;&quot;검토했어요&quot;&lt;/em&gt; 다음에는 더 물어볼 게 없었는데, &lt;em&gt;&quot;812장 중 47장 선택&quot;&lt;/em&gt; 다음에는 &quot;왜 47장만?&quot; 같은 다음 질문이 자연스럽게 따라왔다. 검증 가능한 진술만 받는 것 — 이게 AI에게 일을 맡길 때 가장 단단한 안전망이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;짧은 피드백이 영구 규칙으로 — 다섯 가지 변곡점&lt;/h2&gt;
&lt;p&gt;위 작업 방식이 만든 가장 중요한 결과는, &lt;em&gt;한 줄짜리 피드백이 SKILL.md의 한 단락으로 영구히 박힌다&lt;/em&gt;는 점이었다. 한 폴더에서 본 문제가 다음 폴더에서 안 보이는 규칙이 된다. 통영, 정리, 북한산, 페낭, 랑카위, 심천, 여수, 세부까지 10개 가까운 폴더를 거치며 다섯 개의 큰 변곡점이 그렇게 박혔다.&lt;/p&gt;
&lt;h3&gt;변곡점 1 — 구도 거부 규칙&lt;/h3&gt;
&lt;p&gt;첫 진짜 피드백은 화면 전환 자체였다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;1분에서 2분 정도까지는 구도가 이상한데도 하이라이트에 들어갔고, 화면 전환이 너무 자주 깜빡이면서 되니깐 보기가 힘드네.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 한 줄에서 두 규칙이 나왔다. 가린 렌즈, 다리만 찍힌 컷, 어두운 주머니 샷 같은 &lt;em&gt;카메라 핸들링 흔적&lt;/em&gt;은 기술적으로 선명해도 거부한다. 짧은 클립이 연속해서 깜빡이는 걸 막기 위해 &lt;code&gt;xfade&lt;/code&gt;와 &lt;code&gt;acrossfade&lt;/code&gt;로 전환을 부드럽게 잡는다.&lt;/p&gt;
&lt;p&gt;두 규칙이 그날 SKILL.md에 박혔다. 다음 폴더(정리)부터는 같은 문제가 안 나왔다. 한 폴더에서 던진 한 줄이, 다음 폴더에서 자동으로 작동했다.&lt;/p&gt;
&lt;h3&gt;변곡점 2 — STT 자막을 단어 단위로&lt;/h3&gt;
&lt;p&gt;영상을 보는데 자막 타이밍이 어긋났다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;자막이 보이스 나오는 속도보다 빠르게 나오는 거 같은데.&quot; &quot;STT 단어 단위로 뽑고 하는 게 가능해?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;문장 단위 STT는 타이밍이 어색했다. 단어 단위 타이밍을 받아서 &lt;em&gt;문장 자막의 시작·끝을 재조정&lt;/em&gt;하는 방식으로 바꿨다.&lt;/p&gt;
&lt;p&gt;STT 모델도 그날 굳었다. &lt;code&gt;whisper-cpp&lt;/code&gt;의 &lt;code&gt;ggml-medium.bin&lt;/code&gt; 멀티링구얼. 한국어 품질이 &lt;code&gt;tiny&lt;/code&gt;나 &lt;code&gt;base&lt;/code&gt;로는 부족했다. 스킬에는 *&quot;silently downgrade 금지&quot;*까지 명시했다. Codex가 임의로 가벼운 모델로 바꿔서 한국어 자막 품질을 떨어뜨리는 일을 막기 위해서다.&lt;/p&gt;
&lt;p&gt;자막에 대한 또 하나의 원칙도 같이 박혔다. &lt;strong&gt;자막은 메모리 큐이지 전사가 아니다.&lt;/strong&gt; 음성을 다 자막으로 박지 않는다. 지명, 반응, 결정, 감정적 코멘트만 짧게 남긴다. 나머지는 그냥 원본 오디오로 듣는다.&lt;/p&gt;
&lt;h3&gt;변곡점 3 — BGM은 만들지 않는다, 골라서 쓴다&lt;/h3&gt;
&lt;p&gt;세 번째 피드백은 BGM이었다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;BGM 너무 이상해.&quot; &quot;아예 생성 BGM은 쓰지 마.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;LLM이 BGM 분위기를 &lt;em&gt;생성&lt;/em&gt;해주려는 시도는 전부 어색했다. 그래서 도메인 지식 원칙대로 &lt;em&gt;직접 후보 7곡&lt;/em&gt;을 던졌고, 그 풀이 스킬의 기본 자산으로 박혔다. 이후로는 Codex가 영상 톤을 보고 풀에서 한 곡 골라서 다운받고 렌더에 넣는다.&lt;/p&gt;
&lt;p&gt;볼륨 정책도 박혔다. 원본 오디오/스피치가 중요한 영상은 &lt;code&gt;bgm_volume 0.08~0.14&lt;/code&gt;, 음악 중심 몽타주는 &lt;code&gt;0.14~0.22&lt;/code&gt;. 숫자로 박으니 Codex가 매번 헤매지 않는다.&lt;/p&gt;
&lt;p&gt;여기서 더 큰 원칙이 굳었다. &lt;strong&gt;원본 오디오를 보존한다.&lt;/strong&gt; 파도 소리, 발걸음, 도시 소음, 웃음, 반응. 이게 추억의 &lt;em&gt;진짜 앵커&lt;/em&gt;다. BGM은 그 위에 얇게 깔리는 보조 레이어지 주역이 아니다.&lt;/p&gt;
&lt;h3&gt;변곡점 4 — 광범위 위치 자막&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;장소가 있는 건 장소를 자막으로 보여주면 좋을 것 같거든. 상세한 장소까지는 아니고 지역 이름 정도만.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;GPS 메타데이터가 있는 영상은 위치를 자막으로 띄울 수 있었다. 다만 정확한 식당 이름이나 호텔 이름까지 박으면 메모리 필름이 &lt;em&gt;맵 앱&lt;/em&gt;처럼 느껴진다. 광범위 라벨, 즉 &lt;em&gt;Da Lat, Nha Trang, Seoul, Osaka&lt;/em&gt; 수준만 쓴다.&lt;/p&gt;
&lt;p&gt;근거 우선순위도 정했다. 영상 GPS → 인접 사진 EXIF(타임스탬프 매칭) → STT 지명 언급 → 파일명 → 화면 속 표지판. &lt;code&gt;(0,0)&lt;/code&gt; 좌표는 무효. GPS 없는 영상은 인접 시각의 사진 GPS로 추론한다.&lt;/p&gt;
&lt;h3&gt;변곡점 5 — 작은 실패가 만든 메타데이터 규칙&lt;/h3&gt;
&lt;p&gt;통영 영상에서 한 컷에 *&quot;통영 2일차&quot;*라는 자막이 박혔다. 보고 던진 말은 짧았다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;1일차 영상은 없는 거야?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;LLM이 임의로 *&quot;2일차&quot;*라는 내러티브를 만들어 박은 거였다. 1일차 폴더는 처음부터 없었으니 2일차도 있을 수 없다. &lt;strong&gt;소스 메타데이터에 없는 표현은 제목·자막에 넣지 않는다.&lt;/strong&gt; 이 규칙이 그날 박혔다.&lt;/p&gt;
&lt;p&gt;이런 작은 실패가 가장 중요했다. 큰 구조는 며칠 만에 잡혔지만, &lt;em&gt;디테일 한 줄&lt;/em&gt;은 매번 실패해야 보였다. 한 줄이 박힐 때마다 스킬은 다음 폴더에서 더 조용히 일했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2단계 QA — &quot;파일 만들어졌어요&quot;가 곧 완료는 아니다&lt;/h2&gt;
&lt;p&gt;마지막에 박힌 게 &lt;em&gt;QA 단계&lt;/em&gt;였다.&lt;/p&gt;
&lt;p&gt;처음에는 렌더링이 끝나면 그게 완료였다. 그러면 안 됐다. 중간에 무음 구간이 끼었고, 마지막 1초가 잘렸고, 자막이 영상 끄트머리를 잘랐다. 매번 영상을 끝까지 봐야 잡혔다. 그러면 &lt;em&gt;재사용 가능한 스킬&lt;/em&gt;의 의미가 없다.&lt;/p&gt;
&lt;p&gt;QA를 두 층으로 박았다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Technical QA.&lt;/strong&gt; &lt;code&gt;ffprobe&lt;/code&gt;로 코덱·해상도·fps·오디오 채널 확인. &lt;code&gt;blackdetect&lt;/code&gt;로 의도치 않은 검은 프레임. &lt;code&gt;silencedetect&lt;/code&gt;로 긴 무음 구간. &lt;code&gt;volumedetect&lt;/code&gt;로 클리핑이나 너무 작은 오디오. 그리고 &lt;em&gt;타임라인 컨택트 시트&lt;/em&gt;, 12초마다 한 프레임씩 뽑아 4×6 격자로 한 장에 모은다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory-highlight QA.&lt;/strong&gt; 소스 시간 순서와 결과 클립 순서가 일치하는가. 모든 중요한 시점·장소가 표현됐는가. 위치 라벨이 GPS·인접 증거에 근거하는가. 자막이 메모리 큐인가 전사인가. 반복되는 컷이 빠졌는가.&lt;/p&gt;
&lt;p&gt;두 단계 모두 &lt;em&gt;명시적으로 보고&lt;/em&gt;하게 했다. *&quot;QA 했어요&quot;*가 아니라 &lt;em&gt;&quot;ffprobe 결과는 X, blackdetect에서 1.5초 fade 외에는 검은 프레임 없음, 12개 클립 모두 시간순 일치&quot;&lt;/em&gt; 같이. 앞서 정리한 &lt;em&gt;&quot;자기보고는 숫자로&quot;&lt;/em&gt; 원칙이 QA에서도 그대로 작동했다.&lt;/p&gt;
&lt;p&gt;이 QA가 박힌 뒤로는 한 폴더당 &lt;em&gt;내가 봐야 하는 시간&lt;/em&gt;이 5분에서 30초로 줄었다. AI에게 일을 맡길 때 결국 가장 중요한 게 &lt;em&gt;검증 자동화&lt;/em&gt;다. 자동화된 검증이 없으면 매번 사람이 결과를 끝까지 봐야 하고, 그러면 도구가 아니라 &lt;em&gt;수동 작업의 다른 이름&lt;/em&gt;에 가까워진다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;사진까지 — media-highlight로 분기&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;video-highlight&lt;/code&gt;가 안정되자 다음 한계가 보였다. 여행은 영상만이 아니다. &lt;strong&gt;사진이 훨씬 많다.&lt;/strong&gt; 베트남 폴더에는 영상 30개에 사진 800장. 영상만으로 만든 하이라이트는 분명히 작동했지만, &lt;em&gt;내가 가장 잘 찍은 한 장의 풍경&lt;/em&gt;은 한 번도 메모리 필름에 들어가지 못했다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&quot;여행이라는 게 동영상뿐만 아니라 사진도 엄청 많이 찍자나… 둘 다 합쳐서 하나의 하이라이트… 일단 기존 스킬은 냅두고 새롭게 만들어보자.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 한 줄에서 &lt;a href=&quot;https://github.com/mayajuni/mayajuni-harness/tree/main/catalog/skills/media-highlight&quot;&gt;&lt;code&gt;media-highlight&lt;/code&gt; 스킬&lt;/a&gt;이 분기됐다. &lt;em&gt;기존 스킬을 망가뜨리지 않기 위해&lt;/em&gt; 새 스킬로 갔다. 검증된 워크플로우를 건드리지 않는 게 가장 안전한 확장이었다.&lt;/p&gt;
&lt;p&gt;새 스킬은 폴더를 보고 세 모드 중 하나를 고른다. photo-only, video-only, 또는 mixed. mixed 모드에서는 한 타임라인에 사진과 영상 아이템이 시간 순으로 섞인다. 베트남 첫째 날 풍경 사진 3장 → 시장 영상 8초 → 음식 사진 2장. 이 흐름이 자연스럽게 짜인다.&lt;/p&gt;
&lt;p&gt;사진 정책의 핵심은 &lt;strong&gt;exhaustive accounting&lt;/strong&gt;이다. 모든 사진을 적어도 기술 메타데이터·품질 점수·중복 그룹·컨택트 시트로 한 번씩 본다. &lt;em&gt;&quot;800장 모두 의미적으로 이해했다&quot;&lt;/em&gt; 같은 모호한 자기보고는 금지. 대신 &lt;em&gt;&quot;812장 중 47장 선택&quot;&lt;/em&gt; 같은 숫자 보고만 허용. 앞서 정리한 &lt;em&gt;&quot;자기보고는 숫자로&quot;&lt;/em&gt; 원칙이 여기서도 그대로 적용됐다.&lt;/p&gt;
&lt;p&gt;오디오 정책도 갱신됐다. 영상 구간에는 원본 오디오 위에 BGM을 얇게 깔고, 사진 구간에서는 BGM이 살짝 올라온다. 전환은 &lt;code&gt;acrossfade&lt;/code&gt;로 0.5~0.8초 겹쳐서 오디오 컷을 없앤다. &lt;em&gt;&quot;현장음이 있을 땐 현장음, 사진이 나올 땐 BGM, 자연스럽게.&quot;&lt;/em&gt; 이 한 줄이 정책이 됐다.&lt;/p&gt;
&lt;p&gt;부수 효과도 있었다. 영상은 GPS가 박힌 경우가 드문데, 사진은 거의 모든 폰 사진에 EXIF GPS가 있다. &lt;em&gt;영상 GPS가 없으면 인접 시각의 사진 GPS로 위치를 추론&lt;/em&gt;하는 흐름이 자연스럽게 강해졌다. 사진을 같이 보면 영상의 위치 추론도 더 정확해진다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;반복 사이클이 만든 것&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;video-highlight&lt;/code&gt; SKILL.md는 251줄, &lt;code&gt;media-highlight&lt;/code&gt;는 208줄이다. 그중 다수가 위 변곡점들로부터 한 줄씩 박혀 만들어졌다. 한 폴더에서 본 한 가지 문제가, 다른 폴더에 가서도 안 보이게 만드는 규칙이 된다.&lt;/p&gt;
&lt;p&gt;지금은 이렇다. 폴더 하나 던지면 2~3분짜리 메모리 필름이 나온다. 베트남, 몰디브, 인도네시아, 페낭, 여수, 세부, 다 만들어졌다. 그리고 그것들을 실제로 &lt;em&gt;다시 본다.&lt;/em&gt; 가족에게도 보여준다. 폴더에 잠들어 있던 800장 사진과 30개 영상이, 3분짜리 한 편이 되어 &lt;em&gt;기억으로 돌아왔다.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;업무에서 쓰던 AI 활용 패턴(Diagnose → Execute → Verify, 짧은 피드백, 숫자 기반 검증)이 실생활에서도 그대로 작동했다. 다만 &lt;em&gt;측정 지표가 달라졌다.&lt;/em&gt; 업무에서는 정답률이나 recall이지만, 여기서는 &lt;em&gt;내가 다시 보는가&lt;/em&gt;다. 더 단순하고 더 정직한 지표였다.&lt;/p&gt;
&lt;p&gt;이게 LLM 시대의 &lt;em&gt;스킬 만들기&lt;/em&gt;에서 가장 흥미로운 점이었다. 처음부터 완벽한 SKILL.md를 짤 수는 없다. &lt;strong&gt;한 줄짜리 피드백 30개가 폴더를 거치며 쌓여 만드는 것&lt;/strong&gt;에 가깝다. 그러려면 한 가지가 필요하다. &lt;em&gt;한 줄로 문제를 말할 수 있는 사람.&lt;/em&gt; 그게 거의 전부다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;끝나고 보니 — 다섯 가지가 남았다&lt;/h2&gt;
&lt;p&gt;이 작업을 거치면서 내가 알게 된 게 다섯 가지였다. 권유라기보다는, 같은 자리에서 시작해 보려는 사람과 나누고 싶은 회고에 가깝다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 안 보면 잃는 게 가장 큰 데이터가 0번 후보였다.&lt;/strong&gt; *&quot;AI를 어디에 쓸까&quot;*보다 *&quot;안 보면 무엇을 잃는가&quot;*가 더 단단한 기준이었다. 기능이 많은 데이터가 아니라 잃을 게 큰 데이터가 먼저였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 평소에 쓰는 도구 안에 들어가지 않으면 결국 안 쓰게 됐다.&lt;/strong&gt; 별도 앱은 그것까지 따로 켜야 하는 도구가 된다. 매일 켜는 환경 안에 자연스럽게 들어왔을 때 비로소 굴러갔다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 한 줄로 진단할 수 있을 때 사이클이 가장 빠르게 돌았다.&lt;/strong&gt; *&quot;BGM을 0.1로 설정해줘&quot;*보다 *&quot;BGM이 너무 시끄러워&quot;*가 더 좋은 결과를 냈다. 구현은 맡기고, 결과를 평가할 능력만 내가 가지고 있으면 됐다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 숫자가 박힌 자기보고가 가장 단단한 안전망이었다.&lt;/strong&gt; *&quot;검토했어요&quot;*는 검증할 수 없었지만, *&quot;812장 중 47장 선택&quot;*은 검증할 수 있었고 다음 질문도 따라왔다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. 한 번의 작업을 스킬로 굳히는 순간, AI가 진짜 내 도구가 됐다.&lt;/strong&gt; 한 폴더에서 효과를 본 규칙이 다음 폴더에서도 자동으로 작동했을 때, 비로소 &lt;em&gt;AI 도입&lt;/em&gt;이라는 말이 실체를 가졌다. 그 전까지는 매번 새로 시작하는 일회성에 가까웠다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;스킬은 한 번에 완성되지 않는다. 짧은 피드백 한 줄이 규칙으로 굳어지면서 만들어진다. 그러려면 한 줄로 문제를 말할 수 있어야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;앞으로의 방향&lt;/h2&gt;
&lt;p&gt;여행 영상은 0번 후보였을 뿐이다. 살아가면서 또 어디에 AI가 필요한지, 무엇을 어떻게 맡기게 될지는 그때그때 마주치게 될 것 같다. 이 시리즈는 그 기록이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;참고 — 실제 스킬 코드:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mayajuni/mayajuni-harness/tree/main/catalog/skills/media-highlight&quot;&gt;&lt;code&gt;media-highlight&lt;/code&gt; (영상·사진 통합 하이라이트)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (12) — Lane-based Retrieval 설계와 전체 회고</title><link>https://blog.dongjun.win/posts/legal-ai-search-12-lane-based-retrieval-retrospective/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-12-lane-based-retrieval-retrospective/</guid><description>벡터 검색부터 임베딩, selector, rewriter, graph, source router를 거쳐 lane-based retrieval에 이른 설계 과정과 전체 회고입니다.</description><pubDate>Mon, 11 May 2026 23:40:56 GMT</pubDate><content:encoded>&lt;p&gt;법률 QA 검색기를 만들면서 거쳐 온 설계 여정의 마지막 이야기다. 벡터 검색의 한계를 마주한 순간부터, 임베딩 선택, selector, rewriter, graph, source-router, 그리고 lane-based retrieval까지. 이 글에서는 최종 단계인 lane 구조 설계를 정리하고, 시리즈 전체를 돌아본다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;검색기 운영 설계의 최종 단계&lt;/h2&gt;
&lt;p&gt;query-prep 단계를 마무리하면서 자연스럽게 다음 질문이 떠올랐다. prerewriter와 source-router가 질문을 정리하고 어떤 소스를 열지 결정했다면, 그 다음은 무엇인가. 실제로 열린 소스들에서 문서를 가져오고, 정리하고, 합치는 구조를 어떻게 만들 것인가.&lt;/p&gt;
&lt;p&gt;이전까지는 하나의 벡터 쿼리로 모든 컬렉션을 한 번에 검색하는 구조였다. 법령 조문, 판례, 해석례, 행정해석이 모두 같은 쿼리, 같은 점수축 위에서 경쟁했다. 이 방식의 문제는 명확했다. 조문은 요건과 효과 중심이고, 판례는 사실관계와 책임 귀속 중심이다. 같은 질문이라도 소스마다 잘 맞는 검색 표현이 다르다. 하나의 쿼리로 모든 소스를 커버하려는 시도는 결국 어딘가에서 recall 손실을 낳았다.&lt;/p&gt;
&lt;p&gt;이 문제를 풀기 위해 도달한 구조가 lane-based retrieval이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Lane-based Retrieval 개념&lt;/h2&gt;
&lt;p&gt;lane-based retrieval의 핵심 아이디어는 단순하다. 소스마다 독립적인 검색 경로(lane)를 두고, 각 lane이 자기 소스에 맞는 방식으로 문서를 가져온 뒤, 후단에서 역할 기반으로 합치는 것이다.&lt;/p&gt;
&lt;p&gt;전체 흐름은 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;질문
-&amp;gt; 공통 router (어떤 lane을 열지 결정)
-&amp;gt; 병렬 lane 실행
   -&amp;gt; lane별 쿼리 변환
   -&amp;gt; lane별 벡터 검색
   -&amp;gt; 필요시 lane별 그래프 검색
   -&amp;gt; lane별 rerank 또는 selection
-&amp;gt; 역할 기반 merge
-&amp;gt; 조건부 final rerank
-&amp;gt; answer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 설계 판단이 몇 가지 있었다.&lt;/p&gt;
&lt;p&gt;첫째, 법령 조문을 primary anchor로 둔다. 법률 QA에서 조문은 가장 기본적인 근거다. 다른 소스들은 이 anchor를 보강하는 support 역할이다.&lt;/p&gt;
&lt;p&gt;둘째, 전체 후보를 하나의 점수축으로 flat merge하지 않는다. 예를 들어 여러 lane에서 각각 수십 개씩 회수하면 수백 개 이상의 raw 후보가 나온다. 이걸 한 번에 rerank하는 것은 비효율적일 뿐 아니라, 소스 역할이 다른 문서를 같은 기준으로 비교하는 것 자체가 부적절하다.&lt;/p&gt;
&lt;p&gt;셋째, lane별로 먼저 정리하고, 후단에서 quota merge를 한다. 각 lane은 자기 소스 안에서 relevance를 판단한 뒤 topN을 내놓는다. 후단 merge는 이 topN들을 역할(anchor/support) 기준으로 조합한다.&lt;/p&gt;
&lt;p&gt;이 구조는 RAG 분야에서 흔히 논의되는 multi-retriever fusion 패턴과 맥이 닿는다. LangChain의 MergerRetriever나 Pinecone의 two-stage retrieval 같은 접근법도 여러 검색 전략의 결과를 Reciprocal Rank Fusion(RRF) 등으로 합치는 구조를 쓴다. 다만 우리 설계에서 다른 점은, 단순히 semantic/lexical 같은 검색 방법의 차이가 아니라 소스 자체의 성격 차이를 lane 분리의 기준으로 삼았다는 것이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Lane Unit 실험&lt;/h2&gt;
&lt;p&gt;lane 구조를 세우면서 가장 먼저 부딪힌 질문은 &quot;실험 단위를 어떻게 잡을 것인가&quot;였다.&lt;/p&gt;
&lt;p&gt;8개 lane 전부에 대해 lane-specific rewriter를 한 번에 만드는 방향을 먼저 검토했다. 결론부터 말하면, 이 방향은 잘못됐다. 여러 lane을 동시에 바꾸면 어떤 lane이 좋아졌고 어떤 lane이 망가졌는지 분리가 안 된다. rewrite만 따로 만들어서는 실제 retrieval이 개선됐는지 확인하기도 어렵다.&lt;/p&gt;
&lt;p&gt;결론은 명확하다. 실험 단위는 lane 하나다. lane 내부의 rewrite, vector retrieval, 정리(selection/rerank)를 하나의 unit으로 묶어서 end-to-end로 평가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;질문 1개
-&amp;gt; 특정 lane
-&amp;gt; rewrite
-&amp;gt; vector
-&amp;gt; 정리
-&amp;gt; lane 결과 평가
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서 비교하는 것은 세 가지다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;raw query를 그대로 넣었을 때&lt;/li&gt;
&lt;li&gt;공통 rewrite를 넣었을 때&lt;/li&gt;
&lt;li&gt;lane-specific rewrite를 넣었을 때&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;그리고 비교 지점도 나눈다. vector 직후 후보의 품질, lane 정리 후 후보의 품질, merge 전 lane별 recall과 coverage.&lt;/p&gt;
&lt;p&gt;lane 단위 실험 프레임을 먼저 만들고, 한 lane씩 검증해 나가는 것. 8개 rewrite를 한꺼번에 만드는 것보다 이 순서가 훨씬 현실적이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Precedents Lane 설계&lt;/h2&gt;
&lt;p&gt;첫 실험 대상으로 precedents(판례) lane을 골랐다. 이유는 명확했다. 현재 공통 prerewriter가 law_articles 중심 성격이 강해서, 판례와의 성격 차이가 가장 크다. lane-specific rewrite의 효과를 관찰하기 가장 좋은 대상이다.&lt;/p&gt;
&lt;h3&gt;공통 rewriter의 한계&lt;/h3&gt;
&lt;p&gt;공통 prerewriter를 판례 검색에 그대로 쓰면 한계가 보였다. 공통 rewriter는 조문명, 요건, 효과, 절차 중심으로 질의를 정리하는 경향이 있다. 판례 검색에서 중요한 사실관계, 책임 귀속, 위법성 판단, 예외 적용 같은 표현이 따로 강조되지 않았다.&lt;/p&gt;
&lt;p&gt;실제 검색을 확인해 보면, raw 질문을 그대로 넣으면 generic한 손해배상/구상금류 결과가 많이 섞였다. 공통 searchQuery를 넣으면 약간 더 정돈되지만 여전히 generic 판례가 많았다. 쟁점을 분리한 subQuery를 넣으면 의미가 있었지만, 잘못 만든 subQuery는 noise를 크게 늘렸다.&lt;/p&gt;
&lt;h3&gt;Precedents Prerewriter&lt;/h3&gt;
&lt;p&gt;이 관찰에서 도출한 방향은, 판례 전용 prerewriter를 완전히 독립된 rewriter로 만드는 것이 아니라 공통 의미 구조를 받아서 판례형으로 변환하는 adapter로 두는 것이었다.&lt;/p&gt;
&lt;p&gt;입력: 원문 질문 + 공통 prerewriter 결과 (queryType, searchQuery, subQueries, keywords)
출력: precedents 전용 searchQuery, keywords, 조건부 subQueries&lt;/p&gt;
&lt;p&gt;판례용 searchQuery의 원칙은 조문형과 다르다. 핵심 사실관계, 책임 주체, 위법성/책임 귀속 포인트, 손해 범위 순으로 반영한다. &quot;택배 기사 개인과 회사의 배상 책임&quot;이나 &quot;운송 중 사고에서 사용자 책임 성립 여부&quot; 같은 판례형 표현이 허용된다.&lt;/p&gt;
&lt;p&gt;subQueries는 더 보수적이다. single 질문이면 기본 0개, multi_issue일 때만 1~2개, 최대 2개. 판례 subQuery는 적고 날카로워야 한다.&lt;/p&gt;
&lt;h3&gt;Rerank의 필요성&lt;/h3&gt;
&lt;p&gt;precedents lane에서 rerank가 특히 중요한 이유는 판례 title이 generic하기 때문이다. &quot;손해배상(기)&quot; 같은 제목만으로는 질문과의 관련도를 판단할 수 없다. summary와 holding을 읽어야 실제 관련도를 알 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 precedents lane은 small-window rerank를 기본으로 두되, 비용이 부담되면 support 모드에서는 light selection, expand 모드에서만 full rerank를 거는 fallback을 설계했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;시리즈 전체 회고&lt;/h2&gt;
&lt;p&gt;이 시리즈를 시작한 건 벡터 검색의 한계를 마주한 순간이었다. 법률 도메인의 질문을 벡터 DB에 그대로 넣으면, 의미적으로 비슷해 보이지만 법적으로는 전혀 다른 문서가 상위에 올라왔다. &quot;임대차 보증금 반환&quot;을 질문했는데 &quot;매매 대금 반환&quot; 판례가 나오는 식이다. 벡터 유사도만으로는 법률 도메인의 정밀한 검색 요구를 충족할 수 없었다.&lt;/p&gt;
&lt;p&gt;그래서 시작한 여정이 결국 여기까지 왔다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;임베딩 모델 선택.&lt;/strong&gt; 도메인 특화 임베딩을 쓸지, 범용 모델을 쓸지 고민했다. 결국 pplx-embed-v1-4b를 선택했고, 모델 자체보다 쿼리 표현의 품질이 더 중요하다는 것을 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prerewriter.&lt;/strong&gt; raw 질문을 그대로 검색에 넣는 대신, 질문의 의도를 정리하고 보조 신호를 생성하는 전처리층을 두었다. 중요한 판단은 prerewriter가 질문을 &quot;대체&quot;하는 것이 아니라 &quot;보조&quot;하는 것이라는 점이었다. raw 질문을 버리지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source-Router.&lt;/strong&gt; 여러 법률 소스 중 어떤 것을 열지 결정하는 router를 설계했다. 여러 버전을 실험하면서 확인한 핵심은 recall-priority 관점이다. 불필요한 소스를 여는 것보다, 필요한 소스를 닫아버리는 것이 훨씬 치명적이다. 최종 버전을 선택한 이유도 critical lane miss가 가장 낮았기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lane-based Retrieval.&lt;/strong&gt; 그리고 마지막으로, 소스마다 독립적인 검색 경로를 두고 역할 기반으로 합치는 구조에 도달했다.&lt;/p&gt;
&lt;h3&gt;핵심 설계 원칙&lt;/h3&gt;
&lt;p&gt;이 과정에서 확인한 설계 원칙을 정리한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;한 번에 모든 것을 바꾸지 않는다.&lt;/strong&gt; prerewriter, router, filter, retrieval, rerank를 동시에 바꾸면 무엇이 효과를 냈는지 알 수 없다. 한 축만 바꾸고, 전체를 다시 평가하고, 고정하고, 다음으로 넘어간다. 이것이 유일하게 신뢰할 수 있는 방법이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recall이 precision보다 먼저다.&lt;/strong&gt; 법률 QA에서 관련 근거를 놓치는 것은 잘못된 근거를 포함하는 것보다 위험하다. 잘못된 근거는 answer 단계에서 걸러낼 수 있지만, 놓친 근거는 복구가 안 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;소스의 역할이 다르면 검색도 달라야 한다.&lt;/strong&gt; 조문, 판례, 해석례는 같은 법률 도메인이지만 문서 구조와 검색 의도가 다르다. 하나의 쿼리로 모든 소스를 커버하려는 시도는 결국 어딘가에서 타협을 낳는다. lane 분리는 이 근본적 차이를 인정하는 설계다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실험 프레임이 실험보다 먼저다.&lt;/strong&gt; 좋은 아이디어가 있어도 그것을 검증할 틀이 없으면 소용없다. lane unit 실험 러너를 먼저 만들고, 비교 가능한 평가 포맷을 정의한 뒤에야 실제 실험이 의미를 갖는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;비용과 구조는 트레이드오프다.&lt;/strong&gt; lane별 LLM rewrite, lane별 rerank는 논리적으로 맞지만, 모든 lane에 다 붙이면 비용과 latency가 감당이 안 된다. 현실적 제약 안에서 필요한 곳에만 선택적으로 적용하는 것이 운영 설계의 핵심이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;앞으로의 방향&lt;/h2&gt;
&lt;p&gt;현재 시점에서 query-prep은 일단 마감이다. prerewriter와 source-router v1_6이 고정됐고, filter는 payload 기준 재설계가 필요할 때 다시 연다.&lt;/p&gt;
&lt;p&gt;다음 핵심 작업은 query-prep 결과를 downstream에 안정적으로 넘기는 것이다. 구체적으로는 prerewriter + source-router 출력 계약을 고정하고, retrieval이 merged plan만 보도록 인계 기준을 정리하고, retrieval에서 answer까지 end-to-end로 검증하는 것이다.&lt;/p&gt;
&lt;p&gt;lane-based retrieval 구조 자체는 precedents lane부터 한 lane씩 검증해 나갈 계획이다. precedents lane에서 lane-specific rewrite와 small-window rerank의 효과가 확인되면, 해석례와 행정해석 lane으로 확장한다.&lt;/p&gt;
&lt;p&gt;더 먼 미래에는 lane-aware query-prep, 즉 handoff 자체를 lane별로 분화하는 방향도 열려 있다. 공통 query에 lane별 override를 얹는 구조가 가장 현실적인 확장 경로로 보인다.&lt;/p&gt;
&lt;p&gt;이 시리즈를 통해 확인한 결론은 명확하다. 법률 도메인 RAG에서 검색 품질을 올리는 핵심은 더 좋은 임베딩 모델이 아니라, 질문의 의도를 정확히 파악하고 소스의 성격에 맞게 검색 전략을 분화하는 구조적 설계다. 모델은 바뀌어도 이 구조적 판단은 남는다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;이 글은 법률 QA 검색기 시리즈의 마지막 글입니다.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (11) — 오답 분석: 법률 RAG는 왜 자신 있게 틀리는가</title><link>https://blog.dongjun.win/posts/legal-ai-search-11-law-qa-error-analysis/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-11-law-qa-error-analysis/</guid><description>검색 근거가 맞아도 법률 RAG가 틀리는 사례를 유형별로 분석하고 retrieval과 generation 사이의 실패 지점을 추적합니다.</description><pubDate>Tue, 05 May 2026 23:40:59 GMT</pubDate><content:encoded>&lt;h2&gt;틀린 답 하나가 열어준 토끼굴&lt;/h2&gt;
&lt;p&gt;&quot;중소기업 특별세액감면이 최저한세 적용 대상인가요?&quot;&lt;/p&gt;
&lt;p&gt;단순해 보이는 질문이었다. 법령 QA 시스템은 자신 있게 답했다. &quot;조세특례제한법 제132조가 해당 감면 조문을 열거하므로, 최저한세 적용 대상입니다.&quot;&lt;/p&gt;
&lt;p&gt;조문 번호도 있고, 논리 구조도 있고, 결론도 명확했다. 문제는 하나뿐이었다. &lt;strong&gt;틀렸다는 것.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;실제로 제132조의 열거 조문과 해당 감면 조문의 관계를 확인하면, 시스템이 내린 결론과 실제 적용이 달랐다. 세무 실무에서 이런 오답은 납세자에게 직접적인 피해로 이어질 수 있다.&lt;/p&gt;
&lt;p&gt;이 오답을 추적하면서 법률 RAG 시스템이 &quot;자신 있게 틀리는&quot; 세 가지 구조적 원인을 발견했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;원인 1: 데이터 전파 과정에서 정보가 소실된다&lt;/h2&gt;
&lt;p&gt;가장 먼저 의심한 건 원본 데이터였다. 확인해 보니 원본은 정상이었다.&lt;/p&gt;
&lt;p&gt;한국 법률에는 &lt;code&gt;제N조의M&lt;/code&gt; 형태의 조문이 많다. 원본 데이터는 이걸 기본 번호와 분기 번호로 분리해서 저장하고 있었고, 이 구조 자체에는 문제가 없었다. 문제는 이 분리된 번호를 파이프라인 하류에서 제대로 합쳐 쓰지 못한 것이었다.&lt;/p&gt;
&lt;p&gt;RAG 시스템은 보통 여러 계층을 거친다. 원본 저장소 → 벡터 DB → 그래프 DB → LLM 컨텍스트. 이 과정에서 작은 필드 하나가 누락되면, 모든 하류 계층에서 동일한 오류가 반복된다. 이번 경우에도 분기 번호가 답변 생성, 검색 결과, 그래프 노드 세 곳 모두에서 빠져 있었다.&lt;/p&gt;
&lt;p&gt;이런 문제의 핵심은 &lt;strong&gt;겉으로 드러나지 않는다&lt;/strong&gt;는 것이다. 시스템은 여전히 자신 있게 답을 내고, 결과물만 보면 깔끔해 보인다. 원본과 대조해야 비로소 보인다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;판단&lt;/strong&gt;: RAG 파이프라인에서 데이터가 여러 저장소를 거칠 때, 각 계층에서 필드가 보존되는지 end-to-end로 검증해야 한다. 특히 한국 법률처럼 복합 식별자(&lt;code&gt;제N조의M&lt;/code&gt;)가 있는 도메인에서는 식별자 전파가 특히 중요하다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;원인 2: LLM은 &quot;없다&quot;를 증명하지 못한다&lt;/h2&gt;
&lt;p&gt;조문 번호 누락만으로는 이번 오답이 완전히 설명되지 않았다. 더 근본적인 문제가 있었다.&lt;/p&gt;
&lt;p&gt;시스템은 &quot;제132조가 해당 감면 조문을 열거한다&quot;고 단정했다. 실제로 제132조 원문을 확인하면 해당 조문이 열거 목록에 없거나, 시스템이 주장하는 방식과 다르게 적용되는 경우였다. LLM이 &lt;strong&gt;원문에 명시적으로 존재하지 않는 연결을 만들어 낸 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;이것은 법률 도메인에서 특히 위험한 유형의 hallucination이다. Stanford의 연구에 따르면 LexisNexis, Thomson Reuters 같은 선도적 법률 AI 도구들도 &lt;a href=&quot;https://onlinelibrary.wiley.com/doi/full/10.1111/jels.12413&quot;&gt;17~33%의 hallucination 비율&lt;/a&gt;을 보인다. 범용 LLM을 법률 작업에 쓰면 &lt;a href=&quot;https://dho.stanford.edu/wp-content/uploads/Legal_RAG_Hallucinations.pdf&quot;&gt;hallucination 비율이 58~80%&lt;/a&gt;까지 올라간다는 보고도 있다. RAG가 이 문제를 &quot;해결&quot;한다는 주장이 있지만, Harvard JOLT의 분석이 지적하듯 RAG는 hallucination을 줄일 뿐 &lt;a href=&quot;https://jolt.law.harvard.edu/digest/retrieval-augmented-generation-rag-towards-a-promising-llm-architecture-for-legal-work&quot;&gt;제거하지는 못한다&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;부정 검증 실험&lt;/h3&gt;
&lt;p&gt;&quot;A 조문이 B 조문을 참조하고 있는가?&quot;를 검증하는 실험을 해봤다. 원문 대조 결과 &quot;참조 없음&quot;이라는 사실을 prompt에 명시적으로 넣어 줬다.&lt;/p&gt;
&lt;p&gt;결과는 3회 실행 중 1회만 올바른 &quot;불확실&quot;을 냈다. 나머지 2회는 prompt에 &quot;없다&quot;고 적어 줬음에도 여전히 &quot;있다&quot;고 hallucination했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;부정(absence) 검증은 prompt 지시만으로는 LLM이 안정적으로 따르지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;결국 코드 레벨에서 원문 대조를 먼저 수행하고, 그 결과를 LLM의 판단보다 우선하는 구조를 만들어야 했다. LLM에게 &quot;이것이 없다는 걸 확인해 줘&quot;라고 시키면 안 된다. 코드로 먼저 확정하고, LLM에게는 확정된 사실만 전달해야 한다. 이것은 탐색/대조 문제이지, 추론 문제가 아니기 때문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;원인 3: corpus 경계 밖의 질문이 존재한다&lt;/h2&gt;
&lt;p&gt;이 질문은 애초에 조문 원문만으로는 답이 완결되지 않는 유형이었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;감면 조문&lt;/strong&gt;: 해당 세액감면의 직접 근거. 하지만 최저한세 적용 여부를 단독으로 확정하는 조문은 아니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;조세특례제한법 제132조&lt;/strong&gt;: 최저한세 일반 규정. 하지만 감면 조문과의 관계가 단순하지 않다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;국세청 공식 안내&lt;/strong&gt;: 실제 적용 방식을 명시. 하지만 corpus에 이 데이터가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;법령 조문 중심으로 설계된 시스템은 조문 기반 질문에는 강하지만, 세무 실무형 질문에서는 구조적 한계가 있다. 국세청 안내 페이지 같은 기관 공식 자료는 법령도 아니고 판례도 아니다. 별도의 source 카테고리로 수집해야 하는 성격의 데이터다.&lt;/p&gt;
&lt;p&gt;시스템이 &quot;모른다&quot;고 답하려면, 자기가 무엇을 모르는지 알아야 한다. corpus에 기관 공식 안내가 없다는 사실을 시스템 스스로 인식할 수 없으므로, 없는 근거를 만들어내는 대신 불확실성을 표현하도록 설계해야 한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;결론: 법률 RAG가 자신 있게 틀리는 구조&lt;/h2&gt;
&lt;p&gt;이번 오답 하나를 추적하면서 세 겹의 문제가 드러났다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;데이터 전파의 정합성&lt;/strong&gt; — 원본은 정상이지만 파이프라인을 거치면서 정보가 소실된다. 작은 필드 하나가 빠져도 결과가 틀어진다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM의 과추론&lt;/strong&gt; — 법률 도메인에서 LLM은 &quot;있다&quot;고 과추론하는 경향이 있다. prompt만으로는 억제할 수 없고, deterministic한 코드 검증과 결합해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;corpus 경계의 한계&lt;/strong&gt; — 조문만으로 답이 완결되지 않는 질문이 있다. 시스템이 답할 수 없는 영역을 인식하고 불확실성을 표현하는 설계가 필요하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;RAG 시스템에서 오답의 원인은 하나가 아니다. 데이터 전파 누락, LLM의 과추론, corpus 범위의 한계가 겹칠 때, 시스템은 자신 있게 틀린 답을 낸다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;화려한 모델 교체나 파라미터 튜닝보다, 데이터가 끝까지 올바르게 흐르는지 확인하는 게 먼저다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;참고 자료:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://onlinelibrary.wiley.com/doi/full/10.1111/jels.12413&quot;&gt;Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools (Stanford, 2025)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dho.stanford.edu/wp-content/uploads/Legal_RAG_Hallucinations.pdf&quot;&gt;Legal RAG Hallucinations - Journal of Empirical Legal Studies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jolt.law.harvard.edu/digest/retrieval-augmented-generation-rag-towards-a-promising-llm-architecture-for-legal-work&quot;&gt;RAG: Towards a Promising LLM Architecture for Legal Work? - Harvard JOLT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hai.stanford.edu/news/ai-trial-legal-models-hallucinate-1-out-6-or-more-benchmarking-queries&quot;&gt;AI on Trial: Legal Models Hallucinate in 1 out of 6 or More - Stanford HAI&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>AI한테 내 프롬프트 14,390개를 주고 물어봤다 — 내가 너를 어떻게 쓰고 있냐고</title><link>https://blog.dongjun.win/posts/ai-usage-analysis-2-ai-team-manager/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/ai-usage-analysis-2-ai-team-manager/</guid><description>Claude Code와 Codex에 보낸 14,390개 프롬프트를 분석해 AI를 검색 도구가 아닌 팀 관리자처럼 활용한 실제 작업 패턴을 돌아봅니다.</description><pubDate>Fri, 01 May 2026 03:56:02 GMT</pubDate><content:encoded>&lt;h2&gt;시작&lt;/h2&gt;
&lt;p&gt;호기심이 생겨서 내가 지난 두 달 동안 Claude Code와 Codex에게 던진 프롬프트를 전부 긁어모아 분석해봤다. &lt;code&gt;~/.claude/projects/&lt;/code&gt;에는 22개 프로젝트, 234개 세션, 6,232개 프롬프트가 쌓여 있었고, &lt;code&gt;~/.codex/&lt;/code&gt;에는 15개 프로젝트, 204개 세션, 8,158개 프롬프트가 있었다.&lt;/p&gt;
&lt;p&gt;처음에는 그냥 &quot;내가 토큰을 얼마나 썼나&quot; 정도가 궁금했다. 그런데 데이터를 펼쳐 놓고 보니 토큰 얘기보다 더 흥미로운 게 보였다. &lt;strong&gt;나는 AI 한 대를 잘 쓰는 사람이 아니라, AI 두 대를 역할로 나눠 굴리는 사람이었다.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;미리 밝혀두면 — 이 분석과 보고서는 내가 직접 쓴 게 아니라 &lt;strong&gt;AI가 써줬다.&lt;/strong&gt; 내가 한 일은 &quot;내 &lt;code&gt;~/.claude/&lt;/code&gt;와 &lt;code&gt;~/.codex/&lt;/code&gt; 데이터 전부 읽고 내 사용 스타일을 분석해줘&quot;라고 던진 것뿐이다. 즉 이 글은 &lt;em&gt;내가 AI를 어떻게 쓰는지를 AI에게 분석시킨 결과&lt;/em&gt;다. 그래서 더 재미있었다 — AI가 나를 거울처럼 비춰준 셈이니까.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 글은 그 분석 결과를 정리한 회고다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;한 줄 요약&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;AI를 잘 쓰는 사람이 아니라, AI 팀을 굴리는 매니저.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;처음에는 AI 한 대를 어떻게든 잘 써보려고 했다. 그런데 어느 순간부터 두 대를 &lt;strong&gt;역할로 나눠&lt;/strong&gt; 쓰고 있었다. Claude Code는 같이 사고하는 PM/Architect, Codex는 손발 빠른 Senior Engineer. 의도하고 그렇게 한 게 아닌데, 데이터로 보니 그렇게 굳어져 있었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;토큰 효율이 극단적으로 높은 사람&lt;/h2&gt;
&lt;p&gt;가장 먼저 눈에 띈 건 토큰 사용 패턴이었다. 한 줄로 요약하면 이거다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;직접 타자는 평균의 1/3로 치고, 컨텍스트는 평균의 2배를 읽히고, 출력은 평균의 5배를 받아낸다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;세 개의 숫자가 이 프레임을 그대로 증명한다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;비교 기준에 대해 미리 한 줄: 이 글의 &lt;em&gt;&quot;평균&quot;&lt;/em&gt; 표현은 공식 통계가 아니라, 분석을 맡은 AI가 일반적인 사용 패턴을 기준으로 추정한 값이다. 정확한 기준선이 아니라 &lt;em&gt;&quot;내 사용 스타일이 어느 쪽으로 쏠려 있나&quot;&lt;/em&gt; 를 보기 위한 참조선 정도로 읽어주시면 좋겠다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;(1) 캐시 적중률 92%&lt;/h3&gt;
&lt;p&gt;캐시 read 444M / (캐시 write 39M + read 444M) = 92%. AI 추정 기준 일반적인 분포는 70~80%대라고 한다. 같은 프로젝트로 다시 돌아오는 빈도가 높고, 한 세션 안에서 컨텍스트를 적게 갈아엎는다는 뜻이다. 코드를 갈아엎기보다 &lt;strong&gt;위에 쌓는 스타일&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h3&gt;(2) 출력/직접입력 비율 4.6배&lt;/h3&gt;
&lt;p&gt;내가 73 토큰 던지면 AI가 335 토큰으로 답한다. 일반적으로는 1.5~2배 정도라는 게 분석 결과였다. 4.6배는 AI에게 &lt;em&gt;결정/판단&lt;/em&gt;을 외주하는 비율에 가깝다. &lt;strong&gt;짧게 묻고 길게 답을 받는다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(3) 응답당 컨텍스트 부피 50K&lt;/h3&gt;
&lt;p&gt;한 응답에 약 50K 토큰의 컨텍스트가 들어간다. 일반 대비 1.5~2배 정도로 큰 편이라고 한다. 파일 첨부와 히스토리 참조가 많아서 그렇다. 자연어 설명은 짧지만 그 앞뒤에 실제 자료를 듬뿍 붙인다.&lt;/p&gt;
&lt;h3&gt;한 줄 정리&lt;/h3&gt;
&lt;p&gt;토큰을 적게 쓰는 게 아니라 &lt;strong&gt;토큰 효율이 극단적으로 높은 사람&lt;/strong&gt;이었다. 본인이 칠 비용은 아끼고, AI한테는 풍부한 자료와 길게 생각할 여유를 주는 — 시간당 의사결정 단가가 가장 낮은 운영 방식.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;평균 사용자&lt;/th&gt;
&lt;th&gt;나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;직접 입력 토큰&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1/3×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;컨텍스트 부피&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;출력 토큰&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캐시 적중률&lt;/td&gt;
&lt;td&gt;70~80%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;출력/입력 비율&lt;/td&gt;
&lt;td&gt;1.5~2배&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.6배&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;도구별 페르소나가 다르다&lt;/h2&gt;
&lt;p&gt;같은 사람인데 어휘가 완전히 다르다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단어&lt;/th&gt;
&lt;th&gt;Claude (6,232개 중)&lt;/th&gt;
&lt;th&gt;Codex (8,158개 중)&lt;/th&gt;
&lt;th&gt;비율&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;진행해&lt;/td&gt;
&lt;td&gt;0.34%&lt;/td&gt;
&lt;td&gt;1.74%&lt;/td&gt;
&lt;td&gt;Codex 5.0×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;오케이&lt;/td&gt;
&lt;td&gt;0.24%&lt;/td&gt;
&lt;td&gt;1.41%&lt;/td&gt;
&lt;td&gt;Codex 5.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해줘&lt;/td&gt;
&lt;td&gt;1.56%&lt;/td&gt;
&lt;td&gt;4.13%&lt;/td&gt;
&lt;td&gt;Codex 2.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;체크해줘&lt;/td&gt;
&lt;td&gt;0.35%&lt;/td&gt;
&lt;td&gt;0.88%&lt;/td&gt;
&lt;td&gt;Codex 2.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;토론&lt;/td&gt;
&lt;td&gt;0.22%&lt;/td&gt;
&lt;td&gt;0.21%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;거의 동일&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Codex 단골 멘트는 &lt;code&gt;Implement the plan.&lt;/code&gt; (36회), &lt;code&gt;진행해줘.&lt;/code&gt; 계열이다. Claude한테는 &quot;같이 보자&quot; 모드, Codex한테는 &quot;실행해라&quot; 모드.&lt;/p&gt;
&lt;p&gt;재미있는 건 &lt;strong&gt;&quot;토론&quot;이라는 단어 비율은 도구가 바뀌어도 거의 똑같다&lt;/strong&gt;는 점이다 (0.22% vs 0.21%). 이건 도구의 문제가 아니라 내 체질이다. 어떤 AI를 쓰든 같은 비율로 토론을 건다. 다만 토론 &lt;em&gt;후&lt;/em&gt; 실행을 어디서 하느냐가 다를 뿐.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;프로젝트도 자연스럽게 분리되어 있었다&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;프로젝트&lt;/th&gt;
&lt;th&gt;Claude 세션&lt;/th&gt;
&lt;th&gt;Codex 세션&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;embedding-test (RAG)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coin-autopilot&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aura&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blog&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;atlassian-cli, hiworks-report&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Claude는 글쓰기/문서/메타작업, Codex는 실제 코드 실행. 이걸 의식적으로 정한 적이 한 번도 없는데도 분기가 깔끔하게 갈려 있었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;운영 방식 — 3단 루프&lt;/h2&gt;
&lt;p&gt;데이터에서 반복적으로 보이는 흐름은 결국 이 3단계로 압축된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 판단(Diagnose)  — &quot;수정하지 말고 분석만 해줘 / 너의 생각은?&quot;
2. 실행(Execute)   — &quot;오케이 진행해줘 / 그렇게 해줘&quot;
3. 검증(Verify)    — &quot;다시 체크해줘 / 원본이랑 비교해줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 루프가 내 시그니처에 가까웠다. 1번(판단) 없이 바로 실행으로 가거나 3번(검증) 없이 결과를 그냥 수용하는 패턴도 흔한데, 내 데이터에는 둘 다 꽤 일관되게 찍혀 있었다. 그리고 이 루프 안에 권한을 단계적으로 푸는 게이트가 하나 더 깔려 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. &quot;한번 분석해줘&quot; / &quot;체크만해줘&quot;          ← 읽기 권한
2. &quot;너의 생각은 어때? / 동의할까?&quot;         ← 의견 권한
3. &quot;오케이 진행해줘 / 그렇게 해줘&quot;          ← 실행 권한
4. &quot;커밋하고 푸쉬해줘&quot;                      ← 커밋 권한
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 4단 게이트가 거의 모든 중요한 의사결정에서 반복된다. 평소엔 의식하지 못했는데, 데이터로 보니 이게 hallucination 방어선이자 잘못된 방향으로 빠르게 달리는 걸 막는 안전장치였다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7가지 핵심 강점&lt;/h2&gt;
&lt;h3&gt;(1) 모드 분리 능력&lt;/h3&gt;
&lt;p&gt;토론과 구현을 명시적으로 분리해서 통보한다. &lt;em&gt;&quot;아직 수정하지 말고 나랑 토론을 하자&quot;&lt;/em&gt; 같은 문장이 데이터에 꽤 자주 박혀 있었다. AI를 굴려본 시간이 만든 운영 감각인 것 같다.&lt;/p&gt;
&lt;h3&gt;(2) 결정 외주, 책임 보유&lt;/h3&gt;
&lt;p&gt;AI에게 &lt;em&gt;&quot;딱 정해줘&quot;&lt;/em&gt;, *&quot;판단해서 알려줘&quot;*라고 자주 시킨다. 결정 &lt;em&gt;과정&lt;/em&gt;은 외주하지만, 결정 &lt;em&gt;책임&lt;/em&gt;은 내가 진다. 그래서 잘못된 방향이 나오면 &lt;em&gt;&quot;다 날리고 git 초기화&quot;&lt;/em&gt;. 매몰비용 0.&lt;/p&gt;
&lt;h3&gt;(3) 컨텍스트 밀도 운영&lt;/h3&gt;
&lt;p&gt;자연어 설명보다 실물 자료(파일, 로그, 이미지, 통화 스크립트, 다른 AI 답변)를 던진다. 입력 토큰은 평균의 1/3인데 컨텍스트는 평균의 2배. AI가 거짓말하기 어려운 환경을 &lt;strong&gt;체질적으로&lt;/strong&gt; 만든다.&lt;/p&gt;
&lt;h3&gt;(4) 회복력 — 매몰비용 0&lt;/h3&gt;
&lt;p&gt;방향이 틀렸다고 판단되면 즉시 리셋. &lt;em&gt;&quot;모두 버려&quot;&lt;/em&gt;, &lt;em&gt;&quot;git 상태로 초기화&quot;&lt;/em&gt;. 도구를 잘 쓰는 마인드라기보단, 시스템을 굴려본 시간이 만든 습관에 가까운 듯하다.&lt;/p&gt;
&lt;h3&gt;(5) 의인화된 관계 형성&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&quot;너였으면 어떻게 할래?&quot;&lt;/em&gt;, &lt;em&gt;&quot;나 혼자 하기에는 더 힘든 상황이자나&quot;&lt;/em&gt; — AI에게 내 처지를 공유한다. 비합리적인 의인화가 아니라, 내 제약을 알려주면 답이 내 상황에 맞게 좁혀진다. &lt;strong&gt;의인화가 효율을 만든다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(6) 자연 발생적 이중 검증&lt;/h3&gt;
&lt;p&gt;Claude가 만든 plan을 Codex가 실제 실행. Claude의 거짓말이 Codex 실행 단계에서 잡히는 구조가 의도하지 않게 작동 중이었다. &quot;AI 거짓말을 잘 못 느낀다&quot;는 감각의 절반은 사실 이 구조 덕분이다.&lt;/p&gt;
&lt;h3&gt;(7) 토큰 경제 운영&lt;/h3&gt;
&lt;p&gt;Max 구독 200달러로 환산하면 약 810달러 상당의 가치를 쓰고 있었다(약 4×). Codex까지 합치면 1인 운영자가 굴리기엔 꽤 효율적인 편이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;다음 단계 — 더 잘할 수 있는 부분들&lt;/h2&gt;
&lt;p&gt;약점이 아니라 &lt;strong&gt;다음 레벨로 넘어갈 때 챙길 만한 것들&lt;/strong&gt;로 정리했다.&lt;/p&gt;
&lt;h3&gt;(1) AI의 &quot;자기 보고&quot;까지 검증 루프에 넣기&lt;/h3&gt;
&lt;p&gt;결과물 검증은 강하다. 다만 AI가 &lt;em&gt;&quot;138 pass / 0 fail&quot;&lt;/em&gt;, &lt;em&gt;&quot;6개 컬렉션 다 확인했어&quot;&lt;/em&gt; 같이 자기 행동을 보고할 때, 그 보고 자체를 한 번 더 들여다보면 검증 루프가 더 단단해진다.&lt;/p&gt;
&lt;p&gt;보완 문장은 한 줄이면 충분하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;실제로 실행한 명령과 핵심 출력도 같이 보여줘.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 프로젝트 메모리로 인지 부담 옮기기&lt;/h3&gt;
&lt;p&gt;22개 프로젝트(Claude) + 15개(Codex). 토큰은 잘 절약되고 있는데, 프로젝트 간 컨텍스트 전환 비용을 내 머리가 부담하고 있다. 메모리 시스템에 프로젝트별 상태(현재 목표, 마지막 결정, 다음 액션)를 저장해두면 가장 큰 ROI가 나올 영역. 토큰 효율을 인지 효율까지 확장하는 단계다.&lt;/p&gt;
&lt;h3&gt;(3) Second Opinion을 의식적으로 설계&lt;/h3&gt;
&lt;p&gt;Gemini 같은 다른 AI 답변을 가져와 교차검증하는 패턴은 1인 운영자에게 강력한 보완 장치다. 다만 모든 판단에 다 쓰면 시간 비용이 누적되니, &lt;em&gt;어떤 결정에 second opinion을 붙일지&lt;/em&gt;만 미리 정해두면 강점이 그대로 유지되면서 비용은 줄어든다 (예: 아키텍처/보안/투자 결정에만).&lt;/p&gt;
&lt;h3&gt;(4) 도구 간 핸드오프 명문화&lt;/h3&gt;
&lt;p&gt;Claude에서 정리한 plan을 Codex로 옮길 때 요약이 살짝 손실된다. 두 AI 사이에 내 머리가 끼어 있어서 — 나만 안다. plan을 옮길 때 작은 템플릿 한 줄(목표 / 제약 / 검증 기준)만 정해두면, 매니저로서의 운영 비용이 더 줄어든다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;어떻게 분석했나 — 직접 해볼 수 있는 방법&lt;/h2&gt;
&lt;p&gt;이 글의 모든 숫자는 내 로컬 파일에서 나왔다. 외부 서비스가 아니라 누구나 자기 컴퓨터에서 똑같이 돌려볼 수 있는 데이터다.&lt;/p&gt;
&lt;h3&gt;데이터가 있는 곳&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;~/.claude/projects/&amp;lt;프로젝트별 폴더&amp;gt;/&amp;lt;세션ID&amp;gt;.jsonl
~/.codex/sessions/&amp;lt;연도&amp;gt;/...
~/.codex/history.jsonl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude Code는 프로젝트 폴더마다 세션이 &lt;code&gt;.jsonl&lt;/code&gt;로 한 줄에 한 이벤트씩 쌓인다. 사용자 프롬프트, 어시스턴트 응답, 토큰 사용량(&lt;code&gt;usage&lt;/code&gt;), 캐시 read/write, 모델 이름까지 다 들어 있다. Codex는 &lt;code&gt;~/.codex/sessions/&lt;/code&gt;에 비슷한 구조로 쌓이고 &lt;code&gt;history.jsonl&lt;/code&gt;에 사용자 입력 히스토리가 누적된다.&lt;/p&gt;
&lt;h3&gt;추출할 핵심 필드&lt;/h3&gt;
&lt;p&gt;세션 JSONL을 한 줄씩 파싱해서 아래 항목만 뽑으면 충분하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;사용자 프롬프트 텍스트&lt;/strong&gt;: &lt;code&gt;type == &quot;user&quot;&lt;/code&gt; 메시지의 본문&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;토큰 사용량&lt;/strong&gt;: 어시스턴트 응답의 &lt;code&gt;usage&lt;/code&gt; (input / output / cache_creation / cache_read)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;타임스탬프 / 세션 ID / 프로젝트 경로&lt;/strong&gt;: 시계열·프로젝트 분포용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;모델 이름&lt;/strong&gt;: 단가 환산용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;핵심 지표 계산식&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;캐시 적중률      = cache_read / (cache_read + cache_write)
출력/입력 비율   = output / input(직접 타이핑분만, 캐시 제외)
세션당 컨텍스트  = (input + cache_read) / 응답 수
프롬프트 평균길이 = sum(len(prompt)) / 프롬프트 수
어휘 빈도        = &quot;진행해&quot;, &quot;해줘&quot;, &quot;토론&quot; 등 키워드 카운트 / 전체 프롬프트 수
비용 환산        = 모델별 단가 × (input/output/cache 각각)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;캐시 적중률과 출력/입력 비율 두 개만 봐도 본인의 사용 스타일이 뚜렷하게 드러난다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;참고: 본문에 나온 &lt;em&gt;&quot;평균&quot;&lt;/em&gt; 비교치(70~80%, 1.5~2배 등)는 별도의 공식 데이터셋에서 뽑은 게 아니라, 분석을 맡긴 AI가 일반적인 사용 패턴을 기준으로 추정해준 값이다. 절대 기준이 아니라 &lt;em&gt;&quot;내가 어느 쪽으로 쏠려 있나&quot;&lt;/em&gt; 를 보는 참조선으로 봐주시면 좋겠다. 본인 숫자 자체는 위 계산식으로 정확히 재현 가능하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;내가 실제로 던진 프롬프트&lt;/h3&gt;
&lt;p&gt;미리 잡아둔 4단계 플로우 같은 건 없었다. 홈 디렉토리에서(&lt;code&gt;cd ~&lt;/code&gt;) Claude Code를 그냥 띄워놓고, 떠오르는 대로 짧게 던졌다. 세션 히스토리에 남은 실제 프롬프트는 14개 정도였고, 분석을 시작한 첫 마디는 이거였다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;클로드 코드를 보면 히스토리를 가지고 있자나 각 폴더별로...
그 히스토리를 보면 나랑 대화를 나누는 것들이 있고..
그것들을 전체다 분석해서 나의 프롬프팅 성향과 방법 등을 분석 해볼수 있어?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;답이 나오면 다음을 던졌다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;좀더 자세히 분석해줘.
AI를 대하는 자세라든가..
나는 AI의 거짓말을 많이 못느낌점..
다른 사람들보다 토큰을 많이 안쓰는점 기타 등등
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;나는 다른 사람에 비해 토큰 사용량이 어떻게되?
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;통합적으로 내가 AI를 다루는 총평을 만들어줘.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Codex 데이터도 같이 보고 싶어졌을 때는 이렇게 물었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;여기에서 실제 코덱스의 히스토리도 같이 볼수 있나??
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마지막에는 결과물을 파일로 떨어뜨렸다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이제 클로코드에서 총평을 한것과 클로드코드와 코덱스를 합쳐서
총평을 한것을 문서로 만들어줘 .md 파일로 해서..
@Documents/ 안에 만들면되.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 — 이게 마음에 드는 부분인데 — 결과를 한 번 더 의심하는 프롬프트도 끼어 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;실제 전체 다 히스토리를 다 보고 애기한거 맞지?
실제 전체를 보고 결론을 내려줘!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;본문에서 &lt;em&gt;&quot;AI 자기 보고를 한 번 더 들여다보면 검증 루프가 더 단단해진다&quot;&lt;/em&gt; 고 적었는데, 분석 과정에서도 무의식적으로 이걸 하고 있었다는 게 데이터에서 다시 드러났다. 회고하면서 내가 가장 놀란 부분이다.&lt;/p&gt;
&lt;h3&gt;한 줄 요약&lt;/h3&gt;
&lt;p&gt;특별한 플로우를 세팅한 게 아니다. 홈에서 Claude Code 띄우고, 짧은 자연어 14줄을 던진 게 전부다. 그 결과물을 읽고, 마음에 드는 프레임은 받아들이고, 의심스러운 부분은 &lt;em&gt;&quot;전체 다 보고 한 거 맞지?&quot;&lt;/em&gt; 로 다시 물었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;분석조차 자기가 평소에 쓰는 운영 방식 그대로 굴리는 것&lt;/strong&gt; — 그게 데이터에서 자기를 발견하는 가장 정직한 방법이었다. 그리고 그 운영 방식 자체가 이 글의 본문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;멀리서 본 그림&lt;/h2&gt;
&lt;p&gt;여기까지 정리하고 보니 내 작업 방식이 한 장의 그림으로 보였다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 머릿속에서 가설 발생
        ↓
2. Claude한테 짧게 토론 — &quot;이 방향 어때?&quot;
        ↓
3. 합의 → plan 정리
        ↓
4. Codex한테 plan 던짐 — &quot;Implement the plan.&quot;
        ↓
5. Codex 실행 결과 확인
        ↓
6. 이상하면 → 리셋 / 다시 Claude로 돌아가 토론
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CTO + 시니어 엔지니어 + PM 세 자리짜리 미니 팀이 돌아가는 모양에 가까웠다. 나 혼자서, 두 AI를 빌려서.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;마지막 한 줄&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;프롬프트를 잘 쓰는 것과, AI를 굴려서 일이 굴러가게 만드는 건 다른 일인 것 같다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;데이터를 까보기 전에는 &lt;em&gt;&quot;AI를 잘 활용하는 편&quot;&lt;/em&gt; 정도로 막연하게 생각했다. 까보고 나니 표현이 살짝 바뀌었다 — &lt;em&gt;AI 팀을 굴리는 매니저에 가깝게 일하고 있었구나.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;특별한 의도로 그렇게 만든 게 아니라, 64일 동안 6,232번을 두드리면서 자연스럽게 자리 잡은 운영 방식이라는 게 가장 흥미로운 부분이었다. 자기 데이터를 한 번씩 까보는 건 누구한테나 권할 만한 일인 것 같다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (10) — Query Prep 마무리: 무엇을 남기고 무엇을 버렸나</title><link>https://blog.dongjun.win/posts/legal-ai-search-10-query-prep-wrap-up/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-10-query-prep-wrap-up/</guid><description>법률 RAG의 query preparation 계층에서 유지한 구조화 및 라우팅 전략과 버린 재작성 및 분해 실험을 최종 정리합니다.</description><pubDate>Tue, 28 Apr 2026 23:21:33 GMT</pubDate><content:encoded>&lt;h2&gt;전처리 파이프라인을 &quot;마무리&quot;한다는 것&lt;/h2&gt;
&lt;p&gt;RAG 파이프라인에서 전처리(query preparation)는 사용자 질문과 검색 엔진 사이의 번역 계층이다. 질문을 그대로 벡터 검색에 넣는 것과, 질문을 구조화하고 어떤 소스를 열지 먼저 정하는 것은 검색 품질에서 체감할 수 있는 차이를 만든다.&lt;/p&gt;
&lt;p&gt;이 프로젝트에서는 법률 QA를 다루고 있고, 검색 대상이 조문, 판례, 유권해석, 행정심판 등 8개 소스 레인에 걸쳐 있다. 그만큼 전처리 단계가 감당해야 할 범위가 넓었다. 처음에는 세 축을 세웠다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;prerewriter&lt;/strong&gt;: 질문을 구조화하고 검색 힌트를 생성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;source-router&lt;/strong&gt;: 어떤 소스 레인을 활성화할지 결정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;filter&lt;/strong&gt;: payload 기준으로 검색 범위를 추가로 좁힘&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;세 축 모두를 운영 가능한 상태로 올리는 것이 원래 목표였다. 하지만 결론적으로 두 축만 남기고 하나는 보류했다. 이 글은 그 결정의 과정과 근거, 그리고 &quot;마무리&quot;라는 것이 실제로 무엇을 의미하는지를 정리한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;최종 운영 구조: prerewriter + source-router&lt;/h2&gt;
&lt;p&gt;현재 query-prep의 운영 구조는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;질문
-&amp;gt; prerewriter
-&amp;gt; source-router
-&amp;gt; query-prep handoff
-&amp;gt; retrieval
-&amp;gt; merge / rerank
-&amp;gt; answer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;prerewriter&lt;/strong&gt;는 raw 질문을 대체하지 않으면서, retrieval에 필요한 구조화된 힌트를 만든다. queryType, 벡터 검색용 searchQuery와 subQueries, 키워드, 그래프 검색용 법률명 등이 여기서 나온다. 원래 질문을 버리지 않는다는 점이 중요한데, 전처리가 잘못될 경우에도 원본이 살아 있으므로 fallback이 가능하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;source-router&lt;/strong&gt;는 8개 소스 레인 중 어떤 것을 활성화할지 정한다. recall-priority 기준까지 반영한 실험을 거쳐 v1_6이 최종 선택됐다. 예를 들어 &quot;국가공무원법상 징계 관련 판례&quot;라는 질문이 들어오면, 조문 레인과 판례 레인을 동시에 열되 해석례나 위원회 결정은 열지 않는 식의 판단을 한다.&lt;/p&gt;
&lt;p&gt;이 두 축의 조합만으로도 검색 단계에 넘길 계획(retrieval plan)은 충분히 만들어진다. 중요한 것은 downstream이 prerewriter와 router의 내부 구현을 알 필요가 없다는 점이다. 다음 단계는 &lt;code&gt;buildQueryPrepHandoff&lt;/code&gt;라는 하나의 진입점만 보면 된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Filter를 보류한 이유&lt;/h2&gt;
&lt;p&gt;세 번째 축인 filter는 보류했다. &quot;아직 안 만들었다&quot;가 아니라 &quot;만들 수 있지만 지금은 빼는 것이 낫다&quot;는 판단이었다. 이유는 크게 세 가지다.&lt;/p&gt;
&lt;h3&gt;1. 실제 payload와 설계가 맞지 않았다&lt;/h3&gt;
&lt;p&gt;filter의 원래 역할은 Qdrant payload filter로 이어지는 것이었다. 처음 설계할 때는 &lt;code&gt;lawNames&lt;/code&gt;, &lt;code&gt;regions&lt;/code&gt;, &lt;code&gt;institutions&lt;/code&gt; 같은 공통 필드를 모든 소스에 걸쳐 사용할 수 있으리라 가정했다.&lt;/p&gt;
&lt;p&gt;하지만 실제 payload를 까보니 현실은 달랐다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;조문(&lt;code&gt;law_articles&lt;/code&gt;)은 &lt;code&gt;lawName&lt;/code&gt; 필드가 있어 자연스럽게 filter 가능&lt;/li&gt;
&lt;li&gt;조례(&lt;code&gt;ordinance_articles&lt;/code&gt;)는 지역이 중요하지만 payload에 &lt;code&gt;region&lt;/code&gt; 필드 자체가 없음&lt;/li&gt;
&lt;li&gt;판례(&lt;code&gt;precedents&lt;/code&gt;)는 &lt;code&gt;courtName&lt;/code&gt;, &lt;code&gt;caseType&lt;/code&gt;, &lt;code&gt;referenceLaws&lt;/code&gt;가 더 자연스러운 filter 후보&lt;/li&gt;
&lt;li&gt;유권해석, 행정심판, 헌재결정 등은 &lt;code&gt;title&lt;/code&gt;과 &lt;code&gt;summary&lt;/code&gt; 정도만 있어 filter를 걸 근거가 부족&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;8개 레인에 공통 스키마를 씌우려 했지만, 실제로는 소스마다 쓸 수 있는 필드가 완전히 다른 상황이었다. 추상 스키마를 먼저 만들고 payload를 나중에 보는 순서가 거꾸로였던 셈이다.&lt;/p&gt;
&lt;h3&gt;2. 잘못된 hard filter는 recall을 직접 떨어뜨린다&lt;/h3&gt;
&lt;p&gt;법률 검색에서 filter는 양날의 검이다. 정확한 filter는 noise를 줄여주지만, 잘못된 hard filter는 정답 문서를 아예 검색 결과에서 빼버린다. 특히 판례, 유권해석, 행정심판처럼 메타데이터가 빈약한 소스에서 hard filter를 거는 것은 retrieval miss를 직접 만드는 행위다.&lt;/p&gt;
&lt;p&gt;2025년 이후의 RAG 파이프라인 설계에서도 이 점은 공통적으로 언급된다. query expansion이나 다중 paraphrase를 통해 검색 범위를 넓히는 접근이 일반적인 추세이고, filter로 범위를 좁히는 것은 충분한 recall이 확보된 이후에 정밀하게 적용하는 것이 권장된다. 잘못된 전처리가 RAG 실패의 주요 원인이라는 분석도 여러 연구에서 반복적으로 나온다.&lt;/p&gt;
&lt;h3&gt;3. router가 이미 1차 필터 역할을 하고 있다&lt;/h3&gt;
&lt;p&gt;source-router가 레인을 선택하는 것 자체가 넓은 의미의 filtering이다. 8개 레인 전부를 여는 것이 아니라, 질문에 맞는 레인만 활성화하므로 불필요한 소스에서의 noise는 이미 상당 부분 걸러진다. 여기에 payload filter까지 추가하면 이득보다 위험이 더 크다고 봤다.&lt;/p&gt;
&lt;p&gt;결론적으로, filter를 보류한 것은 &quot;filter가 불필요하다&quot;는 판단이 아니라 &quot;지금 상태에서 안전하게 붙일 수 있는 기반이 갖춰지지 않았다&quot;는 판단이다. 나중에 다시 시작한다면 &lt;code&gt;law_articles&lt;/code&gt;의 &lt;code&gt;lawName&lt;/code&gt; filter부터, 레인별로 하나씩 시작하는 것이 맞다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;인계 기준 설계: 모듈형 handoff&lt;/h2&gt;
&lt;p&gt;query-prep을 마무리하면서 가장 신경 쓴 부분은 downstream과의 경계를 어떻게 그을 것인가였다. 문서로만 &quot;이 단계는 끝났다&quot;고 적어두면, 다음 단계에서 결국 내부 구조를 다시 들여다보게 된다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;buildQueryPrepHandoff&lt;/code&gt;라는 함수를 실제 진입점으로 만들었다. 이 함수는 내부적으로 prerewriter와 source-router를 실행하고, 그 결과를 하나의 handoff 객체로 합쳐서 반환한다.&lt;/p&gt;
&lt;p&gt;downstream이 받는 계약은 이것이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;queryType&lt;/code&gt;: 질문 유형&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vector.searchQuery&lt;/code&gt;, &lt;code&gt;vector.subQueries&lt;/code&gt;, &lt;code&gt;vector.keywords&lt;/code&gt;: 벡터 검색 힌트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graph.keywords&lt;/code&gt;, &lt;code&gt;graph.lawNames&lt;/code&gt;: 그래프 검색 힌트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sourceHints&lt;/code&gt;: 활성화할 소스 레인 목록&lt;/li&gt;
&lt;li&gt;&lt;code&gt;confidence&lt;/code&gt;: 전처리 신뢰도&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;filterPlan&lt;/code&gt;은 계약에 optional로 존재하지만 기본적으로 포함하지 않는다.&lt;/p&gt;
&lt;p&gt;이 구조의 핵심은 모듈 merge가 아니라 출력 계약 merge라는 점이다. prerewriter와 router는 내부적으로 여전히 분리되어 있고, 각각의 버전을 독립적으로 교체할 수 있다. 하지만 downstream은 그 내부 구조를 알 필요 없이 handoff 하나만 보면 된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;&quot;완료&quot;의 기준은 무엇인가&lt;/h2&gt;
&lt;p&gt;소프트웨어에서 &quot;완료&quot;라는 단어는 항상 조심스럽다. 특히 실험 기반 프로젝트에서는 더 그렇다. 여기서 query-prep의 &quot;완료&quot;는 다음을 의미한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;완료인 것:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;prerewriter와 source-router의 운영 버전 선정&lt;/li&gt;
&lt;li&gt;downstream에 넘길 출력 계약 고정&lt;/li&gt;
&lt;li&gt;handoff 모듈의 코드 구현&lt;/li&gt;
&lt;li&gt;filter 보류 결정과 그 근거 문서화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;완료가 아닌 것:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;filter를 영구적으로 폐기한 것&lt;/li&gt;
&lt;li&gt;prerewriter나 router를 다시는 건드리지 않겠다는 것&lt;/li&gt;
&lt;li&gt;전처리 파이프라인 전체의 최적화가 끝난 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, &quot;이 단계에서 더 실험하는 것보다 다음 단계로 넘어가는 것이 전체 시스템에 더 이롭다&quot;는 판단이 완료의 기준이었다. query-prep 내부를 계속 다듬는 것보다, retrieval과 answer까지의 end-to-end 흐름을 먼저 검증하는 편이 병목을 더 빠르게 찾을 수 있다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;교훈: 완벽보다 운영 가능한 구조&lt;/h2&gt;
&lt;p&gt;이 과정에서 몇 가지 배운 것을 정리한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, payload를 먼저 보고 설계해야 한다.&lt;/strong&gt; filter 설계를 추상 스키마에서 시작한 것이 가장 큰 실수였다. &quot;어떤 필드를 추출할지&quot;보다 &quot;실제로 어떤 필드가 존재하고 filter로 쓸 수 있는지&quot;를 먼저 봤어야 했다. 설계가 코드보다 앞서가면, 나중에 코드가 설계를 못 따라간다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 빼는 것도 결정이다.&lt;/strong&gt; filter를 보류한 것은 소극적인 선택처럼 보일 수 있다. 하지만 실제로는 &quot;recall risk를 감수하면서 불완전한 filter를 유지하는 것&quot;과 &quot;filter 없이 넓게 회수하는 것&quot; 사이의 능동적 선택이었다. 특히 법률 도메인에서 검색 누락은 답변 품질에 치명적이므로, recall 확보가 precision보다 우선이라는 판단에는 지금도 변함이 없다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, 인계 가능한 상태가 완료의 진짜 기준이다.&lt;/strong&gt; 문서만 있고 코드가 없으면 다음 단계에서 다시 내부를 파야 한다. 반대로 코드만 있고 계약이 명확하지 않으면 통합할 때 혼란이 생긴다. handoff 모듈과 출력 계약 문서를 함께 만든 것이 이번 마무리에서 가장 유용한 작업이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;넷째, 실험 프로젝트에서의 &quot;완료&quot;는 snapshot이다.&lt;/strong&gt; 지금 시점에서 가장 합리적인 고정점을 찍은 것이지, 영구적인 결론을 내린 것이 아니다. filter는 source별 payload enrichment가 진행되면 다시 열릴 것이고, prerewriter나 router도 downstream 검증 결과에 따라 조정될 수 있다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;다음은 어디로&lt;/h2&gt;
&lt;p&gt;query-prep을 마무리한 이상, 다음은 이 결과를 실제로 쓰는 쪽이다. retrieval에 handoff를 연결하고, merge와 rerank를 거쳐 answer까지 이어지는 end-to-end 흐름을 검증해야 한다.&lt;/p&gt;
&lt;p&gt;전처리를 오래 다듬는 것보다, 전체 파이프라인을 한 번이라도 끝까지 돌려보는 것이 지금 시점에서는 더 가치 있다. 부분 최적화에 매몰되면 전체 시스템의 병목을 놓치기 쉽다.&lt;/p&gt;
&lt;p&gt;결국 query-prep은 &quot;질문 구조화 + source lane 결정&quot;이라는 역할로 정리됐다. 크지 않은 역할처럼 보일 수 있지만, 이 두 가지가 안정적으로 동작한다는 확신이 있어야 그 뒤의 모든 단계가 의미를 갖는다. 기초가 흔들리면 그 위에 무엇을 쌓아도 불안하다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Sources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://lakefs.io/blog/what-is-rag-pipeline/&quot;&gt;RAG Pipeline: Example, Tools &amp;amp; How to Build It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.kapa.ai/blog/how-to-build-a-rag-pipeline-from-scratch-in-2026&quot;&gt;How to Build a RAG Pipeline from Scratch in 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ragflow.io/blog/rag-review-2025-from-rag-to-context&quot;&gt;From RAG to Context - A 2025 year-end review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.dhiwise.com/post/build-rag-pipeline-guide&quot;&gt;Complete Guide to Building a Robust RAG Pipeline 2025&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (9) — Source Router: 8개 컬렉션을 지능적으로 라우팅하기</title><link>https://blog.dongjun.win/posts/legal-ai-search-09-source-router-design/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-09-source-router-design/</guid><description>법령, 판례, 해석례 등 성격이 다른 8개 Qdrant 컬렉션을 질문에 맞게 선택하는 Source Router의 설계와 실험을 정리합니다.</description><pubDate>Sun, 26 Apr 2026 23:11:09 GMT</pubDate><content:encoded>&lt;p&gt;법률 QA 시스템을 만들면서 가장 먼저 부딪힌 현실이 있다. 우리가 다루는 법률 데이터는 하나의 벡터 DB에 넣고 검색하면 끝나는 구조가 아니라는 것이다. 법령 조문, 판례, 공식 법령해석, 부처 실무 해석, 행정심판 재결례, 헌재 결정, 위원회 결정, 지자체 조례까지 --- 성격이 완전히 다른 8개 컬렉션, 총 300만 건 이상의 문서가 Qdrant에 올라가 있다.&lt;/p&gt;
&lt;p&gt;이 글에서는 사용자 질문 하나가 들어왔을 때 어떤 컬렉션을 열고, 어떤 컬렉션은 닫아야 하는지를 판단하는 &lt;strong&gt;Source Router&lt;/strong&gt;의 설계 과정과 최종 버전 선정까지의 여정을 정리한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;하나의 벡터 DB가 아닌 8개 컬렉션&lt;/h2&gt;
&lt;p&gt;우리 시스템의 8개 컬렉션은 각각 역할이 다르다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컬렉션&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;law_articles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;법령 조문&lt;/td&gt;
&lt;td&gt;답변의 기본 근거 축&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ordinance_articles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;지자체 조례/규칙&lt;/td&gt;
&lt;td&gt;지역/인허가 질문 보강&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;precedents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;판례&lt;/td&gt;
&lt;td&gt;실제 분쟁 적용과 판단 경향&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;legal_interpretations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;공식 법령해석&lt;/td&gt;
&lt;td&gt;조문 문언의 공식 해석&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ministry_interpretations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;부처 실무 해석&lt;/td&gt;
&lt;td&gt;행정 실무 적용과 민원 회신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;administrative_trials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;행정심판 재결례&lt;/td&gt;
&lt;td&gt;처분/불복/구제 절차&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;constitutional_decisions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;헌재 결정&lt;/td&gt;
&lt;td&gt;위헌성/기본권/헌법소원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;committee_decisions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;위원회/권익위 결정&lt;/td&gt;
&lt;td&gt;위원회 판단/민원 해결&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;같은 질문이라도 &lt;code&gt;law_articles&lt;/code&gt;는 주근거이고, &lt;code&gt;precedents&lt;/code&gt;는 보조근거다. 이렇게 성격이 다른 문서를 raw score 하나로 일렬 정렬하면 답변 구조가 쉽게 흔들린다. 법령 조문이 답변의 뼈대가 되어야 하는데, 의미적 유사도가 높은 판례가 상위권을 독점해버리는 식이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;라우팅이 필요한 이유&lt;/h2&gt;
&lt;p&gt;RAG 시스템에서 query routing은 이미 널리 알려진 패턴이다. Towards Data Science의 글이나 최근 arxiv에 올라온 RAGRouter 논문에서도 확인할 수 있듯이, 복수의 데이터 소스를 가진 RAG 시스템에서는 사용자 질문의 의도를 분석해 적합한 데이터 소스로 라우팅하는 것이 핵심이다. 모든 소스를 동일 강도로 검색하면 노이즈가 늘고, 검색 비용도 올라간다.&lt;/p&gt;
&lt;p&gt;우리 시스템에서 라우팅이 특히 중요한 이유는 세 가지였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 컬렉션 간 점수 비교가 불가능하다.&lt;/strong&gt; 법령 조문과 판례의 임베딩 유사도 점수는 같은 의미가 아니다. 문서 길이, 표현 방식, 구조가 완전히 다르기 때문이다. 따라서 단순 점수 정렬이 아니라 lane 정책 중심의 병합이 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 질문 유형에 따라 필요한 컬렉션이 다르다.&lt;/strong&gt; &quot;근로기준법상 해고 절차가 어떻게 되나요?&quot;라는 질문에는 법령 조문과 판례가 중요하지만, &quot;서울시 주차장 조례&quot;라는 질문에는 조례 컬렉션이 핵심이다. 헌법소원에 대한 질문에는 헌재 결정이 열려야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, 비용과 latency 문제다.&lt;/strong&gt; 질문 하나에 8개 컬렉션을 전부 깊게 검색하면 불필요한 비용이 발생한다. 실제로 대부분의 질문은 2~4개 레인이면 충분하다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Source Router 설계 과정&lt;/h2&gt;
&lt;h3&gt;Lane 구조 설계&lt;/h3&gt;
&lt;p&gt;가장 먼저 8개 컬렉션을 세 가지 등급으로 분류했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Anchor Lane&lt;/strong&gt; --- &lt;code&gt;law_articles&lt;/code&gt;는 항상 검색하고, 가장 깊게 검색한다. 법률 QA에서 법령 조문은 답변의 중심 근거다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always-on Support Lane&lt;/strong&gt; --- &lt;code&gt;precedents&lt;/code&gt;, &lt;code&gt;legal_interpretations&lt;/code&gt;, &lt;code&gt;ministry_interpretations&lt;/code&gt;는 대부분의 질문에서 도움이 될 가능성이 높다. 항상 켜되, 기본 검색량은 작게 유지하고 질문 힌트가 강할 때만 더 깊게 검색한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Triggered Lane&lt;/strong&gt; --- &lt;code&gt;ordinance_articles&lt;/code&gt;, &lt;code&gt;administrative_trials&lt;/code&gt;, &lt;code&gt;constitutional_decisions&lt;/code&gt;, &lt;code&gt;committee_decisions&lt;/code&gt;는 특정 질문 유형에서만 의미가 있다. 기본은 off이고, 질문에 직접적인 단서가 있을 때만 확장한다.&lt;/p&gt;
&lt;p&gt;이 구조를 기반으로, Source Router는 질문을 받아 각 컬렉션의 활성 상태(&lt;code&gt;off&lt;/code&gt;, &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;expand&lt;/code&gt;)를 결정하는 역할을 맡게 되었다.&lt;/p&gt;
&lt;h3&gt;Prompt-only 접근&lt;/h3&gt;
&lt;p&gt;Source Router는 별도의 분류 모델을 학습시키지 않고, LLM 프롬프트만으로 구현했다. &lt;code&gt;gemini-3.1-flash-lite-preview&lt;/code&gt; 모델에 질문을 넣으면 각 컬렉션의 활성화 수준과 confidence를 JSON으로 돌려주는 구조다. 이 접근을 택한 이유는 빠른 실험 반복이 가능하고, 법률 도메인의 미묘한 판단을 규칙 기반으로 커버하기 어렵기 때문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;버전별 평가와 선택 근거&lt;/h2&gt;
&lt;p&gt;48개 golden dataset 문항을 기준으로 v1부터 v1_6까지 총 7개 버전을 실험했다. 각 버전은 이전 버전의 실패 패턴을 분석한 뒤 프롬프트를 개선하는 방식으로 진행되었다.&lt;/p&gt;
&lt;h3&gt;전체 결과 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Exact Score&lt;/th&gt;
&lt;th&gt;Recall Score&lt;/th&gt;
&lt;th&gt;Critical Miss&lt;/th&gt;
&lt;th&gt;Core FP&lt;/th&gt;
&lt;th&gt;Perfect&lt;/th&gt;
&lt;th&gt;판단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;66.7%&lt;/td&gt;
&lt;td&gt;86.8%&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;0/48&lt;/td&gt;
&lt;td&gt;기준선, 과발화 심각&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;90.2%&lt;/td&gt;
&lt;td&gt;90.2%&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;27/48&lt;/td&gt;
&lt;td&gt;과발화 크게 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;94.0%&lt;/td&gt;
&lt;td&gt;92.5%&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;31/48&lt;/td&gt;
&lt;td&gt;triggered lane 개선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;96.4%&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;37/48&lt;/td&gt;
&lt;td&gt;실사용 후보 진입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;96.4%&lt;/td&gt;
&lt;td&gt;93.3%&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;37/48&lt;/td&gt;
&lt;td&gt;exact 동점, recall 약간 후퇴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;92.3%&lt;/td&gt;
&lt;td&gt;94.8%&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;25/48&lt;/td&gt;
&lt;td&gt;recall 실험, 너무 많이 열림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v1_6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;96.4%&lt;/td&gt;
&lt;td&gt;96.0%&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;37/48&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;최종 선택&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;각 버전의 역할&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;v1 -&amp;gt; v1_1&lt;/strong&gt;: 초기 버전은 &lt;code&gt;legal_interpretations&lt;/code&gt;, &lt;code&gt;ministry_interpretations&lt;/code&gt;, &lt;code&gt;precedents&lt;/code&gt;를 지나치게 자주 확장하는 과발화 문제가 심각했다. v1_1에서 source-specific 단서가 없으면 닫는 방향으로 전환하면서 core 과발화를 크게 줄였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;v1_1 -&amp;gt; v1_2&lt;/strong&gt;: &lt;code&gt;위원회&lt;/code&gt;, &lt;code&gt;심판청구&lt;/code&gt; 같은 단어의 오인식을 줄이고, direct source cue가 있는 경우 &lt;code&gt;expand&lt;/code&gt;를 더 적극적으로 올렸다. triggered lane pass가 87.5%까지 상승했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;v1_2 -&amp;gt; v1_3&lt;/strong&gt;: &lt;code&gt;precedents&lt;/code&gt;를 더 강하게 억제하고, 행정심판/헌재/위원회를 각각 독립 source로 읽도록 유도했다. 96.4%로 실사용 후보 수준에 도달했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;v1_4&lt;/strong&gt;: v1_3과 동일 최고점. prompt-only 최적화가 사실상 plateau에 도달했음을 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;v1_5&lt;/strong&gt;: recall을 끌어올리기 위해 support lane을 공격적으로 열었다. critical miss는 5건으로 줄었지만, core false positive가 22건으로 급증하며 exact score가 92.3%로 떨어졌다. 방향은 맞았지만 균형이 깨졌다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;v1_6&lt;/strong&gt;: v1_5의 recall 개선은 일부 유지하면서, core expansion을 다시 direct-request 중심으로 조였다. exact 최고점 구간을 회복하면서 recall도 96.0%로 가장 높았다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Recall 우선 기준의 의미&lt;/h2&gt;
&lt;p&gt;최종 선택에서 가장 중요했던 기준 전환이 있다. 단순히 exact-match 총점만 보는 것이 아니라, &lt;strong&gt;recall-priority&lt;/strong&gt;를 함께 보기로 한 것이다.&lt;/p&gt;
&lt;p&gt;이유는 명확하다. 법률 QA에서 불필요한 source를 조금 더 여는 것과, 중요한 source를 놓쳐서 답변 근거를 아예 못 찾는 것은 심각도가 다르다. 판례가 핵심인 질문에서 판례 컬렉션이 닫혀 있으면 아무리 법령 조문을 잘 찾아도 부실한 답변이 된다. 반면 판례를 약간 과하게 열었더라도 후단의 merge와 rerank에서 걸러낼 수 있다.&lt;/p&gt;
&lt;p&gt;이것은 RAG 시스템 전반에서 적용되는 원칙이기도 하다. 검색 단계에서는 precision보다 recall이 우선이다. 놓친 문서는 후단에서 복구할 수 없지만, 과하게 가져온 문서는 후단에서 걸러낼 수 있다. Aurelio Labs의 semantic-router나 LangChain 기반 routing 구현들도 결국 &quot;적합한 소스를 빠짐없이 커버하는 것&quot;을 최우선으로 둔다.&lt;/p&gt;
&lt;p&gt;Recall-priority score는 이 원칙을 반영한 점수 체계다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;열어야 하는 lane을 안 연 경우를 더 크게 감점한다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expand&lt;/code&gt;가 필요한데 &lt;code&gt;support&lt;/code&gt;로만 준 경우도 약한 miss로 집계한다&lt;/li&gt;
&lt;li&gt;false positive는 상대적으로 덜 무겁게 본다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 기준으로 보면 v1_6는 exact-match 최고점 구간을 유지하면서 critical lane miss가 4건으로 가장 적었다. 놓치지 않으면서도 과하게 열지 않는 균형점이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;남은 과제와 한계&lt;/h2&gt;
&lt;p&gt;v1_6를 최종 선택으로 고정했지만 완벽하지는 않다. 세 가지 잔존 리스크가 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;confidence calibration&lt;/strong&gt;: 현재 confidence 출력이 거의 항상 &lt;code&gt;high&lt;/code&gt;다. 라우터가 확신이 낮을 때 이를 후단에 알릴 수 있어야 하는데, 아직 그 역할을 못 하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;precedents expand 편향&lt;/strong&gt;: 남은 오차의 대부분이 판례 컬렉션을 약간 과하게 여는 데 집중되어 있다. source 종류를 크게 잘못 읽는 문제는 줄었지만, 보조 판례 lane을 공격적으로 여는 경향이 남아 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;prerewriter hint 정합성&lt;/strong&gt;: 일부 문항에서 &lt;code&gt;graphLawNames&lt;/code&gt;가 질문 주제와 맞지 않게 섞이는 현상이 있다. 이것은 source-router 자체보다 상위 단계인 prerewriter의 품질 문제다.&lt;/p&gt;
&lt;p&gt;이 문제들은 prompt를 더 만지는 것보다 시스템의 다른 단계에서 해결하는 것이 더 효율적이라고 판단했다. confidence calibration 보정, prerewriter hint 정합성 점검, 필요하면 얇은 post-router guardrail을 추가하는 것이 다음 우선순위다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;Source Router 설계에서 얻은 교훈을 정리하면 이렇다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이질적인 근거를 같은 점수축으로 섞지 않는다.&lt;/strong&gt; 8개 컬렉션을 flat search로 한 줄 정렬하는 대신, lane 역할을 나누고 source-aware merge를 하는 구조가 맞다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;검색 단계에서는 recall이 우선이다.&lt;/strong&gt; 놓친 문서는 후단에서 복구할 수 없다. 과하게 가져온 문서는 걸러낼 수 있다. 이 비대칭이 recall-priority 기준의 핵심이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;prompt-only 최적화에도 plateau가 있다.&lt;/strong&gt; v1부터 v1_6까지 6번의 반복으로 66.7%에서 96.4%까지 올렸지만, 그 이후에는 프롬프트를 더 만지는 것보다 시스템의 다른 병목을 해결하는 것이 더 생산적이다. 언제 멈추고 넘어갈지 판단하는 것도 실험 설계의 일부다.&lt;/p&gt;
&lt;p&gt;Source Router는 전체 파이프라인의 한 조각일 뿐이다. 하지만 이 조각이 잘못 판단하면 아무리 좋은 검색기와 답변 생성기가 있어도 소용없다. 300만 건의 법률 문서 앞에서, 어떤 문을 열지 결정하는 것이 결국 첫 번째 품질 관문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Sources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://towardsdatascience.com/routing-in-rag-driven-applications-a685460a7220/&quot;&gt;Routing in RAG Driven Applications - Towards Data Science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2505.23052&quot;&gt;RAGRouter: Learning to Route Queries to Multiple Retrieval-Augmented Language Models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://app.daily.dev/posts/dynamic-routing-in-rag-directing-user-queries-to-the-right-vector-store-with-open-source-models-wi50bfyo3&quot;&gt;Dynamic Routing in RAG: Directing User Queries to the Right Vector Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://towardsdatascience.com/rags-with-query-routing-5552e4e41c54/&quot;&gt;How to Build Helpful RAGs with Query Routing - Towards Data Science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/aurelio-labs/semantic-router&quot;&gt;Aurelio Labs semantic-router - GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-gb/answers/questions/2239952/optimizing-rag-dynamic-query-routing-for-multi-sou&quot;&gt;Optimizing RAG: Dynamic Query Routing for Multi-Source Answer Generation - Microsoft Q&amp;amp;A&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (8) — 1차 아키텍처 확정: 실험에서 운영으로</title><link>https://blog.dongjun.win/posts/legal-ai-search-08-architecture-to-production/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-08-architecture-to-production/</guid><description>한 달간의 법률 RAG 실험을 운영 아키텍처로 전환하며 선택한 검색 파이프라인, 구성 요소와 설계 결정을 정리합니다.</description><pubDate>Tue, 21 Apr 2026 04:54:45 GMT</pubDate><content:encoded>&lt;p&gt;실험이 끝나는 순간은 생각보다 조용하다. 극적인 성능 점프가 아니라, &quot;더 이상 구조를 바꿔도 의미 있는 차이가 나지 않는다&quot;는 판단이 쌓이면서 자연스럽게 온다. 법률 검색 서비스의 RAG 파이프라인을 약 한 달간 실험한 끝에, 나는 1차 아키텍처를 확정하고 운영 전환 준비에 들어갔다. 이 글은 그 과정에서 내린 결정들과 그 이유를 정리한 기록이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;최종 아키텍처: 여섯 단계의 파이프라인&lt;/h2&gt;
&lt;p&gt;확정된 구조는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;질문
-&amp;gt; PreRewriter (질문 변환)
-&amp;gt; Vector 검색 / Graph 검색 병렬 수행
-&amp;gt; Hybrid Merge
-&amp;gt; 조건부 Rerank (multi_issue만)
-&amp;gt; Answer 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일반적인 RAG 시스템이 query -&amp;gt; retrieve -&amp;gt; generate의 세 단계로 설명되는 것과 비교하면, 단계가 더 세분화되어 있다. 업계에서도 프로덕션 RAG 시스템은 단순한 세 단계 구조를 넘어서, 쿼리 변환(query rewriting), 하이브리드 검색(hybrid retrieval), 리랭킹(reranking) 같은 중간 레이어를 독립적으로 관측하고 교체할 수 있는 모듈형 구조로 진화하고 있다. 내가 도달한 구조도 결국 같은 방향이었다.&lt;/p&gt;
&lt;p&gt;각 단계를 왜 이렇게 나눴는지 설명한다.&lt;/p&gt;
&lt;h3&gt;PreRewriter: 질문을 검색용 표현으로 변환&lt;/h3&gt;
&lt;p&gt;사용자의 자연어 질문을 그대로 벡터 검색에 넣으면 잘 되는 경우도 있지만, 복합 질문에서는 한계가 뚜렷했다. PreRewriter는 질문을 &quot;법적 결론&quot;이 아니라 &quot;검색을 잘하기 위한 표현&quot;으로 바꾸는 역할을 한다.&lt;/p&gt;
&lt;p&gt;출력은 단일 구조다. 질문 유형(single, multi_issue, calculation, procedure)을 분류하고, 벡터 검색용 대표 문장과 서브쿼리, 그래프 검색용 키워드와 법률명을 함께 생성한다. 하나의 질문에서 벡터와 그래프가 각각 다른 표현을 받는 셈이다.&lt;/p&gt;
&lt;p&gt;다만 운영에서는 PreRewriter의 위치를 &quot;기본값&quot;이 아니라 &quot;조건부 보조&quot;로 잡기로 했다. 실험 결과, 원문 질문(raw query)을 그대로 쓰는 것이 가장 안정적인 baseline이었고, PreRewriter가 항상 baseline을 넘지는 못했기 때문이다. raw query를 완전히 치환하는 것은 금지하고, 병렬 후보 생성용으로 활용하는 방향이 안전하다.&lt;/p&gt;
&lt;h3&gt;벡터 검색과 그래프 검색의 병렬 구조&lt;/h3&gt;
&lt;p&gt;검색은 두 개의 독립된 경로(lane)로 동시에 수행된다.&lt;/p&gt;
&lt;p&gt;벡터 검색은 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt; 임베딩 모델 기반의 dense + sparse 하이브리드 방식이다. 질문의 의미와 유사한 조문을 넓게 회수하는 역할이고, 1차 anchor를 잡는 기본축이다. 프로덕션 RAG에서도 벡터 검색과 BM25 같은 sparse 검색을 병렬로 돌리고 결과를 합치는 하이브리드 접근이 recall을 높이는 표준적인 방법으로 자리 잡고 있다.&lt;/p&gt;
&lt;p&gt;그래프 검색은 Neo4j 기반이다. 조문 간 의미 관계를 Concept 노드와 typed edge(NEXT_STEP, REQUIRES, CALCULATION_INPUT, LIMITS 등)로 표현해서, 벡터 검색이 놓치는 보조 조문을 회수한다. 예를 들어 전세 보증금 관련 질문이 들어오면, 벡터는 대항력 조문을 잡고, 그래프는 우선변제권이나 임차권등기명령처럼 함께 필요한 조문들을 끌어온다.&lt;/p&gt;
&lt;p&gt;중요한 것은 그래프를 &quot;벡터의 후처리&quot;가 아니라 &quot;독립적인 retrieval lane&quot;으로 취급했다는 점이다. 벡터가 anchor를 잡고, 그래프가 그 anchor를 보강하는 구조다.&lt;/p&gt;
&lt;h3&gt;Hybrid Merge: recall을 해치지 않는 합치기&lt;/h3&gt;
&lt;p&gt;두 lane의 결과를 병합할 때 가장 조심한 원칙은 recall 보존이었다. 특정 lane 하나가 나머지를 압도하지 않도록, weighted RRF(Reciprocal Rank Fusion) 계열 방식에 graph reserve를 결합했다. 그래프가 찾아온 보조 조문이 tail에서 완전히 사라지지 않게 하는 것이 핵심이었다.&lt;/p&gt;
&lt;p&gt;이 단계는 실험 과정에서 과적합 위험이 가장 컸다. 특정 질문 하나를 살리려고 수치를 조정하면 다른 질문에서 깨지는 패턴이 반복됐기 때문에, 특정 문제 해결용 튜닝 대신 일반화 가능한 구조 정책만 채택했다.&lt;/p&gt;
&lt;h3&gt;조건부 Rerank: 필요한 곳에만 쓴다&lt;/h3&gt;
&lt;p&gt;Rerank를 모든 질문에 적용하는 것이 아니라, &lt;code&gt;multi_issue&lt;/code&gt; 유형에만 적용하기로 확정했다. 이 결정의 근거는 명확했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;single, scenario, tax 유형: retrieval만으로 이미 충분히 안정적. rerank를 걸어도 이득이 거의 없고 latency만 늘어남&lt;/li&gt;
&lt;li&gt;multi_issue 유형: 여러 쟁점의 facet coverage를 균형 있게 맞추는 후단 판단이 필요. rerank로 top10, top20 순위를 당기는 효과가 뚜렷함&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;조건부 rerank라는 선택은 비용 대비 효과를 극대화하는 실용적 판단이었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;프롬프트 설계 결정&lt;/h2&gt;
&lt;p&gt;RAG 시스템에서 프롬프트는 결국 파이프라인의 각 단계가 제 역할을 하도록 만드는 인터페이스다. 이번에 확정한 프롬프트 설계에서 가장 중요했던 원칙 두 가지가 있다.&lt;/p&gt;
&lt;p&gt;첫째, PreRewriter 프롬프트의 &quot;절대 금지&quot; 규칙이다. 법적 판단이나 결론을 내리지 않고, 질문에 없는 구체적 조문번호나 확정적 결론을 추가하지 않는다. 법률명도 질문에 직접 언급되었거나 키워드로 유일하게 특정되는 경우에만 출력한다. 이 제약이 없으면 PreRewriter가 &quot;추측&quot;을 하기 시작하고, 검색 품질이 오히려 떨어진다.&lt;/p&gt;
&lt;p&gt;둘째, Answer 생성에서 retrieval용 topK와 answer용 usedReferenceIds를 분리한 것이다. topK는 넓은 후보군이고, usedReferenceIds는 실제로 답변을 뒷받침하는 최소 근거다. answer 모델이 자기가 실제로 쓴 근거 ID를 명시적으로 돌려주게 함으로써, &quot;검색은 됐지만 답변에 안 쓴 조문&quot;과 &quot;실제로 근거가 된 조문&quot;을 구분할 수 있게 했다.&lt;/p&gt;
&lt;p&gt;모델 선택도 역할별로 분리했다. retrieval과 PreRewriter에는 &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;, answer 생성에는 &lt;code&gt;gemini-2.5-flash&lt;/code&gt;를 사용한다. answer 문장 품질과 보고서형 응답 구조의 안정성이 더 높은 모델이 필요했기 때문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;평가 기준 확정&lt;/h2&gt;
&lt;p&gt;아키텍처를 확정하려면 &quot;무엇이 더 나은가&quot;를 판단할 기준이 먼저 있어야 한다. 최종 평가 기준은 질문 유형별 topK recall이었다.&lt;/p&gt;
&lt;p&gt;확정 시점의 주요 결과를 보면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;direct, scenario: K10부터 K50까지 전 문항 정답 회수 (20/20)&lt;/li&gt;
&lt;li&gt;tax: K10부터 K50까지 전 문항 회수 (17/17)&lt;/li&gt;
&lt;li&gt;multi_issue: K10에서 24/31, K40에서 전 문항 회수 (31/31)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;multi_issue에서 K10과 K40 사이의 격차가 가장 컸다. 이것이 rerank를 multi_issue에만 적용하기로 한 실증적 근거이기도 하다. single 유형은 top10만으로도 충분하지만, multi_issue는 더 넓은 후보에서 추려야 한다.&lt;/p&gt;
&lt;p&gt;한 가지 중요했던 에피소드는 benchmark 정의 자체를 수정한 경우다. multi-2 문항에서 원래 정답으로 잡았던 민법 제766조(소멸시효)를 제756조(사용자책임)로 정정했다. 질문의 직접 쟁점과 정답 정의가 불일치하는 문제를 바로잡은 것이지, 성능 수치를 올리기 위한 조작이 아니었다. 평가 기준을 스스로 검증하고 정정하는 것도 실험 프로세스의 일부다.&lt;/p&gt;
&lt;p&gt;holdout 세트에서는 domain 수준 일반화가 확인되었으나(21/21), 조문 단위 full recall 평가는 main benchmark만큼 정밀하지 않았다. 이것은 운영 전환 이후의 과제로 남겨두었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;운영 전환 체크포인트&lt;/h2&gt;
&lt;p&gt;아키텍처가 확정되었다고 해서 바로 운영에 넣을 수 있는 것은 아니다. 실험 코드와 운영 코드 사이에는 drift가 있고, 이를 메우는 작업이 필요하다.&lt;/p&gt;
&lt;p&gt;내가 정리한 운영 전환 체크포인트는 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;검색 인프라 정비.&lt;/strong&gt; 실험에서는 8개 컬렉션(law_articles, ordinance_articles, precedents, legal_interpretations, ministry_interpretations, administrative_trials, constitutional_decisions, committee_decisions)을 한 덩어리로 다뤘지만, 운영에서는 source별 병렬 lane 파이프라인으로 분리해야 한다. 조문은 직접 근거, 판례와 해석례는 보강 근거로 역할이 다르기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;컬렉션 네이밍 분리.&lt;/strong&gt; 연구용 &lt;code&gt;eval_*&lt;/code&gt; 이름을 운영용으로 정리해야 한다. 연구 코드와 운영 코드가 같은 컬렉션을 바라보면 사고가 난다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Qdrant 결과를 최종 원문으로 믿지 않기.&lt;/strong&gt; 대용량 문서는 chunk로 분할되어 저장되므로, Qdrant payload는 &quot;검색용 대표 텍스트&quot;이지 &quot;최종 표시용 원문&quot;이 아니다. 검색 결과의 sourceCollection과 documentId를 기준으로 원문 저장소에서 다시 읽는 흐름이 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;chunking의 검색 의미론 이해.&lt;/strong&gt; 긴 문서는 여러 point로 쪼개어 저장되고, 검색 시에는 documentId 기준으로 collapse한다. 판례처럼 긴 문서가 많은 source에서는 &quot;전체 문서가 골고루 맞는지&quot;보다 &quot;어떤 chunk 하나가 강하게 맞는지&quot;에 더 민감해진다. 이 특성을 전제로 랭킹을 봐야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Top-K를 너무 일찍 줄이지 않기.&lt;/strong&gt; K20과 K50 사이 차이가 실제로 컸다. 운영 초기에는 1차 회수를 넉넉하게 가져가고, 후단 merge와 selection에서 정리하는 방향이 안전하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sourceType 기반 공통 reference 스키마 도입.&lt;/strong&gt; 조문, 판례, 해석례, 결정례를 한 answer 파이프라인에서 다루려면, 검색 결과를 referenceId, sourceType, sourceCollection, documentId, title, displayText, score 같은 공통 구조로 통일해야 한다.&lt;/p&gt;
&lt;p&gt;구현 우선순위는 source별 fan-out 검색기부터 시작해서, lane별 retrieval unit 정리, 공통 reference 스키마 도입, 원문 재조회 흐름 구축, 조문 anchor + 보조 source merge 규칙 확정, 조건부 rerank 연결, 마지막으로 graph lane 연결 순서로 잡았다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1차 마무리 회고&lt;/h2&gt;
&lt;p&gt;한 달간의 실험을 마무리하며 느낀 것들이 몇 가지 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;구조 선택이 모델 선택보다 중요했다.&lt;/strong&gt; 임베딩 모델을 바꾸는 것보다, 벡터와 그래프를 병렬 lane으로 분리하고 hybrid merge 정책을 잡는 것이 성능에 더 큰 영향을 줬다. 업계 연구에서도 chunking 전략이 임베딩 모델 선택보다 retrieval 정확도에 더 큰 제약이 된다는 결과가 있는데, 내 경험도 비슷했다. 개별 컴포넌트의 품질보다 컴포넌트 간 연결 방식이 전체 성능을 결정한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;항상 좋은&quot; 전략은 없었다.&lt;/strong&gt; PreRewriter가 대표적이다. 구조적으로 의미 있는 시도였지만, 모든 질문에서 항상 이득을 주지는 않았다. rerank도 마찬가지다. 전체에 걸면 latency만 늘고, multi_issue에만 걸면 효과적이었다. 결국 &quot;언제 쓸 것인가&quot;를 결정하는 것이 &quot;무엇을 쓸 것인가&quot;만큼 중요했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실험 코드와 운영 코드의 drift는 불가피하다.&lt;/strong&gt; 실험 중에는 빠르게 검증하기 위해 코드를 자주 바꾸고, 결과적으로 &quot;최종 선택&quot;과 &quot;현재 코드 상태&quot;가 일치하지 않는 구간이 생긴다. 재실행했을 때 rerank latency가 0으로 찍히는 것을 보고, 코드가 곧 사양이 아니라는 점을 확인했다. 그래서 이 문서가 단순 실험 기록이 아니라 구현 기준 사양 문서로서의 역할을 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;남은 것은 연구가 아니라 구현이다.&lt;/strong&gt; 아키텍처, 모델, 프롬프트, 평가 기준이 모두 확정되었다. 이제 해야 할 일은 이 사양을 안정적인 운영 코드로 옮기는 것이다. PreRewriter 결과를 서비스 요청 경로에 연결하고, 벡터/그래프 병렬 retrieval을 서비스 로직으로 분리하고, hybrid merge와 조건부 rerank를 연결하고, answer 생성과 reference selection을 붙이는 일이 남았다.&lt;/p&gt;
&lt;p&gt;1차 아키텍처 확정은 끝이 아니라 운영이라는 다음 단계의 시작이다. 실험에서 확인한 것들이 실제 서비스에서도 동일하게 작동하는지, 그것을 증명하는 과정이 이제부터 시작된다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (7) — Graph RAG 도입기: Neo4j로 조문 간 의미 관계 구축</title><link>https://blog.dongjun.win/posts/legal-ai-search-07-graph-rag-neo4j/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-07-graph-rag-neo4j/</guid><description>벡터 검색이 놓치는 관련 조문을 연결하기 위해 Neo4j로 법률 의미 관계 그래프를 구축하고 Graph RAG를 적용한 과정을 정리합니다.</description><pubDate>Sat, 18 Apr 2026 05:56:41 GMT</pubDate><content:encoded>&lt;p&gt;법률 QA 시스템을 만들면서 벡터 검색의 근본적인 한계에 부딪혔다. 사용자가 &quot;부당해고 당했는데 어떻게 하나요?&quot;라고 물으면 근로기준법 28조(구제신청)는 잘 찾는데, 함께 알아야 하는 23조(해고제한)와 26조(해고예고)는 Top-50에도 들어오지 않았다. 벡터 유사도만으로는 풀 수 없는 문제였고, 그래프 기반 확장이 필요했다. 이 글은 Neo4j를 도입해 법률 조문 간 의미 관계 그래프를 구축하고, 실제로 놓친 조문을 회수하기까지의 과정을 정리한 것이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 벡터 검색의 한계를 넘어서&lt;/h2&gt;
&lt;p&gt;벡터 검색은 질문과 조문의 텍스트 유사도에 의존한다. 질문 표면에 키워드가 드러나는 조문은 잘 찾지만, 논리적으로 연결되어 있으면서 텍스트상 닮지 않은 조문은 놓친다.&lt;/p&gt;
&lt;p&gt;구체적인 miss 패턴을 분석해 보니 5가지 유형으로 수렴했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관계 유형&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;절차 체인&lt;/td&gt;
&lt;td&gt;같은 절차의 단계들&lt;/td&gt;
&lt;td&gt;해고제한 - 해고예고 - 구제신청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;계산 체인&lt;/td&gt;
&lt;td&gt;같은 계산의 구성요소&lt;/td&gt;
&lt;td&gt;양도소득 범위 - 공제 - 세율&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전제조건&lt;/td&gt;
&lt;td&gt;A가 성립하려면 B가 필요&lt;/td&gt;
&lt;td&gt;갱신청구권 - 대항력&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동일 원인의 다른 효과&lt;/td&gt;
&lt;td&gt;같은 원인에서 파생되는 별개 결과&lt;/td&gt;
&lt;td&gt;계약해제 - 원상회복 vs 손해배상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기간/제한&lt;/td&gt;
&lt;td&gt;권리의 존속 기간이나 제한 조건&lt;/td&gt;
&lt;td&gt;불법행위 - 소멸시효&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;복수 정답 11문항을 기준으로 벡터 검색이 놓치는 조문 14개를 추적했는데, 전부 이 5가지 관계 중 하나에 해당했다. 텍스트 유사도가 아니라 의미적 관계를 잡아야 하는 문제였다.&lt;/p&gt;
&lt;p&gt;핵심 판단은 이것이었다. 그래프는 1차 검색기가 아니라 2차 확장기다. 벡터 검색으로 anchor 조문을 확보한 뒤, 그 anchor에서 그래프를 따라 숨은 보조 조문을 회수하는 구조가 맞다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 왜 Neo4j인가&lt;/h2&gt;
&lt;p&gt;이미 MongoDB를 문서 저장소로 쓰고 있었기 때문에, 처음에는 MongoDB의 &lt;code&gt;$graphLookup&lt;/code&gt;을 검토했다. 하지만 금방 한계가 드러났다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;$graphLookup&lt;/code&gt;은 단일 컬렉션 내 재귀 탐색만 가능하다. 우리가 필요한 건 조문 -&amp;gt; 개념 -&amp;gt; 조문이라는 크로스 노드 타입 탐색이었다. typed edge를 표현하려면 별도 컬렉션과 복잡한 &lt;code&gt;$lookup&lt;/code&gt; 파이프라인 체인이 필요하고, 멀티홉 쿼리는 파이프라인 지옥이 된다.&lt;/p&gt;
&lt;p&gt;Neo4j를 선택한 이유는 명확했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;typed relationship이 1등 시민이다.&lt;/strong&gt; 관계에 타입과 속성을 네이티브로 부여할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cypher 쿼리 언어의 표현력.&lt;/strong&gt; &quot;이 조문이 속한 Concept의 다른 모든 조문을 가져와라&quot;를 한 줄로 쓸 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;멀티홉 탐색이 빠르다.&lt;/strong&gt; 관계 수에 비례하는 O(관계 수) 탐색이라 별도 인덱스 없이도 충분하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;벡터 인덱스 내장.&lt;/strong&gt; Neo4j 5.11 이후로 같은 DB 안에서 vector + graph 검색이 가능하다(당장 쓰지는 않았지만 확장성 확보).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;역할 분리도 깔끔했다. MongoDB는 원본 문서 저장소, Qdrant는 벡터 검색, Neo4j는 의미 관계 그래프 전용. 각 DB가 잘하는 일을 맡기는 구조다.&lt;/p&gt;
&lt;p&gt;참고로 GraphRAG 분야에서 Neo4j는 이미 사실상 표준 위치를 차지하고 있다. Neo4j가 공식으로 제공하는 &lt;a href=&quot;https://github.com/neo4j/neo4j-graphrag-python&quot;&gt;GraphRAG Python 패키지&lt;/a&gt;도 있고, Qdrant와 Neo4j를 결합한 &lt;a href=&quot;https://qdrant.tech/documentation/examples/graphrag-qdrant-neo4j/&quot;&gt;하이브리드 검색 패턴&lt;/a&gt;도 공식 문서에 소개되어 있다. 법률 도메인에서도 &lt;a href=&quot;https://neo4j.com/blog/developer/from-legal-documents-to-knowledge-graphs/&quot;&gt;법률 문서에서 지식 그래프를 구축하는 접근&lt;/a&gt;이나 &lt;a href=&quot;https://arxiv.org/html/2505.00039v2/&quot;&gt;법률 규범의 계층적/시간적 구조를 Graph RAG로 다루는 연구&lt;/a&gt;가 활발하게 진행되고 있어서, 방향성에 대한 확신을 가질 수 있었다.&lt;/p&gt;
&lt;p&gt;설치는 Docker로 간단하게 진행했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run -d --name neo4j \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/&amp;lt;your-password&amp;gt; \
  -e NEO4J_PLUGINS=&apos;[&quot;apoc&quot;]&apos; \
  -v neo4j-data:/data -v neo4j-logs:/logs \
  neo4j:5-community
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;APOC 플러그인을 함께 넣은 이유는 JSON 파싱이나 배치 처리에 유용하기 때문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 그래프 스키마 설계: Article - Concept - Article&lt;/h2&gt;
&lt;p&gt;스키마 설계의 핵심 아이디어는 Concept이라는 중간 노드를 두는 것이다.&lt;/p&gt;
&lt;h3&gt;노드 타입&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;(:Article {id, lawId, lawName, articleNumber, title})
(:Concept {id, name, type, description, lawName})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Article은 개별 법률 조문이고, Concept은 &quot;해고 절차&quot;, &quot;종합소득세 계산&quot;, &quot;임대차 보호&quot; 같은 법률 개념이다. Concept의 type은 &lt;code&gt;procedure&lt;/code&gt;, &lt;code&gt;calculation&lt;/code&gt;, &lt;code&gt;right&lt;/code&gt;, &lt;code&gt;obligation&lt;/code&gt;, &lt;code&gt;definition&lt;/code&gt;, &lt;code&gt;penalty&lt;/code&gt; 중 하나를 갖는다.&lt;/p&gt;
&lt;h3&gt;엣지 타입&lt;/h3&gt;
&lt;p&gt;조문과 개념 사이의 관계:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(Article)-[:PART_OF {role}]-&amp;gt;(Concept)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;role에는 &quot;요건&quot;, &quot;기간&quot;, &quot;효과&quot;, &quot;세율&quot;, &quot;공제&quot;, &quot;정의&quot;, &quot;절차&quot;, &quot;예외&quot; 등이 들어간다.&lt;/p&gt;
&lt;p&gt;조문 간 직접 관계:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(Article)-[:NEXT_STEP]-&amp;gt;(Article)         // 절차 순서
(Article)-[:REQUIRES]-&amp;gt;(Article)          // 전제조건
(Article)-[:CALCULATION_INPUT]-&amp;gt;(Article) // 계산 흐름
(Article)-[:LIMITS]-&amp;gt;(Article)            // 기간/제한
(Article)-[:EXCEPTION_OF]-&amp;gt;(Article)      // 예외
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Concept이 핵심인 이유&lt;/h3&gt;
&lt;p&gt;벡터 검색이 28조(구제신청)를 찾으면, 이 구조에서는 다음과 같이 확장된다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;28조 --PART_OF--&amp;gt; &quot;해고 절차&quot; Concept &amp;lt;--PART_OF-- 23조(해고제한)
                                      &amp;lt;--PART_OF-- 26조(해고예고)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Concept 하나를 경유하는 1홉 탐색으로 같은 그룹의 모든 조문이 자동 연결된다. 조문 간 직접 관계만으로는 모든 쌍을 일일이 정의해야 하지만, Concept 노드를 두면 그룹 멤버십 하나로 N:N 연결이 만들어진다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. LLM 기반 관계 추출&lt;/h2&gt;
&lt;p&gt;관계를 만드는 방법으로 co-citation(판례에서 함께 인용된 조문 쌍)과 LLM 추출 두 가지를 검토했다.&lt;/p&gt;
&lt;p&gt;co-citation은 이미 데이터가 있어서 바로 쓸 수 있다는 장점이 있었지만, 우리가 풀려는 문제와 맞지 않았다. miss 패턴의 본질은 &quot;판례에서 같이 인용되었느냐&quot;가 아니라 &quot;논리적으로 같은 절차나 계산에 속하느냐&quot;였다. 예를 들어 근로기준법 28조와 23조는 판례 공출현 빈도가 높지만, 28조와 26조(해고예고)는 상대적으로 낮다. 그런데 논리적으로 26조도 해고 절차의 핵심 구성요소다. 통계적 상관이 아니라 의미적 관계가 필요했다.&lt;/p&gt;
&lt;p&gt;LLM 추출의 핵심 설계는 이렇다. 법률 전체 조문 목록(조번호 + 제목 + 본문)을 한번에 LLM에게 주고, Concept 그룹핑 + 각 조문의 role + 조문 간 직접 관계를 출력하게 한다. 조문을 하나씩 보는 게 아니라 전체 맥락을 주는 것이 정확한 관계 추출의 전제조건이다.&lt;/p&gt;
&lt;h3&gt;프롬프트 튜닝: 1차 실패와 2차 성공&lt;/h3&gt;
&lt;p&gt;1차 시도에서는 Concept이 36개 나왔다. 조문 42개 대비 거의 1:1이라 그룹핑의 의미가 없었고, miss 회수에 실패했다.&lt;/p&gt;
&lt;p&gt;2차 시도에서 다음 규칙을 프롬프트에 추가했다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;일반인의 관점에서 함께 알아야 하는 조문을 묶어라&quot;&lt;/li&gt;
&lt;li&gt;Concept 수를 조문수/10 ~ 조문수/5로 제한&lt;/li&gt;
&lt;li&gt;&quot;적극적으로 중복 배정하라&quot; (하나의 조문이 여러 Concept에 소속 가능)&lt;/li&gt;
&lt;li&gt;조문 수가 150개를 초과하면 요약 모드(제목 + 첫 200자만 전달)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&quot;일반인의 관점&quot;이라는 지시가 특히 효과적이었다. 법률 전문가 관점에서는 조문 하나하나가 독립적 의미를 갖지만, 일반인 관점에서는 &quot;해고당하면 알아야 할 것들&quot;처럼 실용적 단위로 묶인다. 이 관점이 miss 회수에 정확히 맞았다.&lt;/p&gt;
&lt;h3&gt;대형 법률 처리&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;법률&lt;/th&gt;
&lt;th&gt;조문 수&lt;/th&gt;
&lt;th&gt;사용 모델&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;주택임대차보호법&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;gemini-2.5-flash-lite&lt;/td&gt;
&lt;td&gt;문제 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;근로기준법&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;gemini-2.5-flash-lite&lt;/td&gt;
&lt;td&gt;문제 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;국세기본법&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;lite 모델 JSON 깨짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;법인세법&lt;/td&gt;
&lt;td&gt;247&lt;/td&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;요약 모드 + JSON 복구 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소득세법&lt;/td&gt;
&lt;td&gt;382&lt;/td&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;요약 모드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;민법&lt;/td&gt;
&lt;td&gt;1,307&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;편별 분할 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;조문 수가 늘어나면서 두 가지 문제가 발생했다. 하나는 LLM의 JSON 출력이 깨지는 것이고, 다른 하나는 컨텍스트 윈도우 한계다. 167조 이상부터는 flash-lite에서 flash로 모델을 올렸고, 247조 이상부터는 조문 본문을 요약 모드로 전달했다. 민법(1,307조)은 편(채권편, 물권편 등)별로 분할해서 별도 처리했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 6개 법률 적재와 검증&lt;/h2&gt;
&lt;h3&gt;적재 결과&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;법률&lt;/th&gt;
&lt;th&gt;조문&lt;/th&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Membership&lt;/th&gt;
&lt;th&gt;Relations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;주택임대차보호법&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;근로기준법&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;국세기본법&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;법인세법&lt;/td&gt;
&lt;td&gt;247&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;202&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소득세법&lt;/td&gt;
&lt;td&gt;382&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;합계&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;983&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;117&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;971&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;584&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;983개 조문에서 117개 Concept이 추출되었고, 971개의 멤버십(조문-Concept 연결)과 584개의 직접 관계가 만들어졌다.&lt;/p&gt;
&lt;p&gt;이후 민법, 형법, 부가가치세법을 추가 적재하면서 정합성 수정도 함께 진행했다. Concept.id를 법률별 namespace로 변경하고, cross-law PART_OF를 제거하고, orphan 노드를 정리했다. 특히 대형 법률에서 fallback(그래프에 제대로 연결되지 못한 조문) 비율을 줄이는 작업이 중요했는데, 법인세법의 fallback을 27%에서 0%로, 부가가치세법을 17%에서 0%로 개선했다.&lt;/p&gt;
&lt;h3&gt;Miss 회수 검증&lt;/h3&gt;
&lt;p&gt;벡터 검색이 놓친 14개 조문에 대해 1홉 Concept 경유 탐색을 테스트한 결과:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1홉 회수: 8/14 (57%)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2홉 포함: 9/14 (64%)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;실패: 5/14&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실패한 5건은 모두 소득세법의 &quot;소득 - 공제 - 세율&quot; 계산 체인이었다. 소득세법의 Concept이 45개로 너무 세분화되어 &quot;이자소득&quot;과 &quot;세율&quot;이 별개 Concept으로 분리된 것이 원인이었다. 이 부분은 프롬프트에 &quot;소득 - 공제 - 세율은 같은 계산 체인&quot;이라는 힌트를 추가하면 개선 가능하다.&lt;/p&gt;
&lt;p&gt;성공한 케이스를 보면, 이 접근의 유효성이 분명했다. 예를 들어 &quot;부당해고&quot; 질문에서 벡터가 28조(구제신청)만 찾았을 때, &quot;해고 및 고용 보장&quot; Concept을 경유해 23조(해고제한)와 26조(해고예고)를 모두 회수했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 결과와 교훈&lt;/h2&gt;
&lt;p&gt;최종적으로 검색 파이프라인은 다음 구조로 확정되었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;질문
-&amp;gt; 프리라이터 (질문 재작성)
-&amp;gt; 벡터 검색 / Neo4j 그래프 확장 (병렬)
-&amp;gt; 하이브리드 병합
-&amp;gt; multi_issue 질문일 때만 LLM rerank
-&amp;gt; 답변 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;벤치마크 기준으로 K10에서 전체 정답 회수를 달성했고, 더 이상 임베딩 모델이나 provider 비교를 계속할 필요가 없는 수준이 되었다.&lt;/p&gt;
&lt;h3&gt;돌아보며 정리하는 교훈&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;그래프는 만능이 아니다.&lt;/strong&gt; 모든 질문에 그래프 확장을 태우면 노이즈가 늘어난다. 질문 유형(절차형, 계산형, 권리형, 단순 조회형)에 따라 확장 정책을 달리하는 것이 중요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concept의 적정 수가 성패를 가른다.&lt;/strong&gt; 1차 시도에서 조문과 1:1로 나온 Concept은 의미가 없었다. &quot;일반인 관점의 실용적 그룹&quot;이라는 프롬프트 지시가 적절한 추상화 수준을 만들어냈다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LLM 추출은 co-citation보다 직접적이다.&lt;/strong&gt; 법률은 이미 구조화된 텍스트이기 때문에 LLM이 전체 조문 목록만 보고도 논리적 그룹핑이 가능하다. 통계적 상관보다 의미적 관계가 필요한 도메인에서는 LLM 추출이 더 효과적이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;대형 법률은 별도 전략이 필요하다.&lt;/strong&gt; 조문 수가 150개를 넘으면 모델 등급을 올리고, 250개를 넘으면 요약 모드를 적용하고, 1,000개를 넘으면 편별 분할이 필요하다. 이 경계값을 미리 알았다면 시행착오를 줄일 수 있었을 것이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;역할 분리가 깔끔한 시스템을 만든다.&lt;/strong&gt; MongoDB(원본 저장), Qdrant(벡터 검색), Neo4j(의미 관계 확장)라는 세 DB의 역할이 명확하게 나뉘면서 각 구성 요소를 독립적으로 개선할 수 있게 되었다.&lt;/p&gt;
&lt;p&gt;벡터 검색만으로 충분하지 않다는 걸 인정하고, 그래프라는 다른 축을 추가한 것이 이 프로젝트에서 가장 큰 전환점이었다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (6) — 하이브리드 검색과 쿼리 분해 실험기</title><link>https://blog.dongjun.win/posts/legal-ai-search-06-hybrid-search-query-decomposition/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-06-hybrid-search-query-decomposition/</guid><description>복수 정답 법률 질문에서 벡터 및 키워드 하이브리드 검색과 쿼리 분해를 적용한 과정과 성능 변화를 정리합니다.</description><pubDate>Thu, 16 Apr 2026 00:26:07 GMT</pubDate><content:encoded>&lt;p&gt;벡터 검색만으로 단답형 질문은 거의 100% recall을 달성했다. 그런데 &quot;부당해고 구제절차와 관련 판례&quot;처럼 정답이 여러 개인 복수정답 질문에서는 벡터 검색이 한계를 드러냈다. 정답 조문 중 일부만 Top-10에 들어오고, 나머지는 빠지는 문제가 반복됐다. 자연스럽게 다음 질문이 떠올랐다. 벡터만으로 안 되면 키워드를 섞으면 어떨까?&lt;/p&gt;
&lt;h2&gt;업계의 하이브리드 검색 흐름&lt;/h2&gt;
&lt;p&gt;하이브리드 검색은 RAG 파이프라인에서 이미 표준에 가까운 접근법이 됐다. Dense retrieval(벡터 검색)은 의미적 유사성을 잘 잡지만, 법령 번호나 조문명 같은 정확한 식별자를 놓치는 경우가 있다. 반대로 BM25 같은 sparse retrieval은 키워드 매칭에 강하지만 의미적 확장이 안 된다. 2025~2026년 시점에서 Pinecone, Weaviate, Qdrant 등 주요 벡터 데이터베이스는 모두 하이브리드 검색을 지원하고 있고, 실무에서도 둘을 병합하는 것이 단독 사용보다 일관적으로 좋은 성능을 보인다는 결과가 축적되어 있다.&lt;/p&gt;
&lt;p&gt;다만 나의 경우는 일반적인 BM25 + 벡터 조합이 아니었다. 법률 도메인의 특성상, 문서 간 인용 관계(citation graph)를 활용하는 그래프 검색을 키워드 검색 대신 사용했다.&lt;/p&gt;
&lt;h2&gt;document_refs: 인용 그래프를 검색에 활용하기&lt;/h2&gt;
&lt;h3&gt;647,309개의 인용 관계&lt;/h3&gt;
&lt;p&gt;법률 문서는 서로를 인용한다. 판례는 근거 조문을 인용하고, 해석례는 관련 판례를 참조한다. 이 인용 관계를 &lt;code&gt;document_refs&lt;/code&gt;라는 엣지 컬렉션으로 구축했다. 총 647,309건의 관계가 담겼다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관계 유형&lt;/th&gt;
&lt;th&gt;건수&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;국세판례 -&amp;gt; 법조문&lt;/td&gt;
&lt;td&gt;178,289&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해석례 -&amp;gt; 법조문&lt;/td&gt;
&lt;td&gt;177,060&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;판례 -&amp;gt; 법조문&lt;/td&gt;
&lt;td&gt;128,696&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;판례 -&amp;gt; 판례&lt;/td&gt;
&lt;td&gt;105,238&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;국세판례 -&amp;gt; 판례&lt;/td&gt;
&lt;td&gt;52,676&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해석례 -&amp;gt; 판례&lt;/td&gt;
&lt;td&gt;5,350&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;그래프 검색의 원리: Co-citation&lt;/h3&gt;
&lt;p&gt;그래프 검색의 핵심 아이디어는 co-citation이다. 사용자 질문에서 특정 조문(예: 근로기준법 제27조)이 언급되면, 그 조문을 인용한 모든 문서를 먼저 찾고, 그 문서들이 함께 인용한 다른 조문을 집계한다. &quot;이 조문과 자주 함께 인용되는 조문&quot;을 찾는 셈이다.&lt;/p&gt;
&lt;p&gt;기존에는 판례 컬렉션 하나에서 &lt;code&gt;_parsedLawRefs&lt;/code&gt; 필드를 &lt;code&gt;$unwind&lt;/code&gt;해서 공출현을 찾았는데, &lt;code&gt;document_refs&lt;/code&gt; 도입 후에는 판례, 해석례, 국세판례 전체를 아우르는 2단계 조회로 바뀌었다. 검색 범위가 훨씬 넓어진 것이다.&lt;/p&gt;
&lt;h2&gt;하이브리드 파이프라인과 RRF 병합&lt;/h2&gt;
&lt;p&gt;파이프라인 구조는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;사용자 질문 -&amp;gt; 프리라이터(Gemini Flash Lite) -&amp;gt; 벡터검색 + 그래프검색(병렬) -&amp;gt; RRF 병합 -&amp;gt; Top-10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;벡터 검색과 그래프 검색을 병렬로 실행한 뒤, RRF(Reciprocal Rank Fusion)로 두 순위 목록을 하나로 합친다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;점수(문서) = 1/(k + 벡터순위) + 1/(k + 그래프순위)    (k=60)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;양쪽 모두에 등장한 문서가 높은 점수를 받는 구조다. 업계에서도 RRF는 하이브리드 검색의 표준 병합 전략으로 널리 쓰이고 있다.&lt;/p&gt;
&lt;h3&gt;프리라이터 설계: 세 가지 접근법&lt;/h3&gt;
&lt;p&gt;벡터 검색과 그래프 검색은 입력 형식이 다르다. 벡터는 자연어 쿼리가, 그래프는 법령명과 키워드가 필요하다. 이 차이를 프리라이터(prerewriter)로 해결하려 했고, 세 가지 방식을 실험했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;하이브리드&lt;/th&gt;
&lt;th&gt;벡터만&lt;/th&gt;
&lt;th&gt;차이&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A. 통합 프롬프트 1개&lt;/td&gt;
&lt;td&gt;17/31 (55%)&lt;/td&gt;
&lt;td&gt;15/31 (48%)&lt;/td&gt;
&lt;td&gt;+2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. 합친 프롬프트 (벡터+그래프 원칙 통합)&lt;/td&gt;
&lt;td&gt;15/31 (48%)&lt;/td&gt;
&lt;td&gt;12/31 (39%)&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C. 분리 (벡터용 + 그래프용 각각)&lt;/td&gt;
&lt;td&gt;16/31 (52%)&lt;/td&gt;
&lt;td&gt;14/31 (45%)&lt;/td&gt;
&lt;td&gt;+2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;방식 B가 그래프 기여도(+3)는 가장 높았지만, 벡터 성능 자체가 12/31로 크게 떨어졌다. 벡터 검색용 searchQuery 표현을 그래프 쪽 요구사항에 맞추다 보니 벡터 품질이 훼손된 것이다.&lt;/p&gt;
&lt;p&gt;가장 중요한 발견은 &lt;strong&gt;벡터 검색이 searchQuery 표현에 극도로 민감하다&lt;/strong&gt;는 점이었다. 프롬프트를 조금만 바꿔도 성능이 12~15 사이에서 크게 흔들렸다. 반면 그래프 검색은 일관적으로 +1~3의 안정적인 기여를 보여줬다.&lt;/p&gt;
&lt;h2&gt;전체 68개 질문 평가 결과&lt;/h2&gt;
&lt;p&gt;프리라이터 분리 방식(C)으로 전체 68개 질문을 평가했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;하이브리드: 74/88 (84%) vs 벡터만: 71/88 (81%)  -&amp;gt; +3
Full Recall@10: 59/68 vs 58/68
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;그룹&lt;/th&gt;
&lt;th&gt;질문 수&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;단답 기본 (Q1~Q20)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;전부 정답&lt;/td&gt;
&lt;td&gt;벡터만으로 충분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시나리오 (Q101~Q120)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;전부 정답&lt;/td&gt;
&lt;td&gt;벡터만으로 충분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;세법 (Q201~Q217)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;전부 정답&lt;/td&gt;
&lt;td&gt;벡터만으로 충분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복수정답 (Q301~Q311)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;MISS 발생&lt;/td&gt;
&lt;td&gt;그래프 +3 개선&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;단답형, 시나리오형, 세법 질문은 벡터 검색만으로 100%였다.&lt;/strong&gt; 문제는 오직 복수정답 케이스에서만 발생했다. 예를 들어 Q5(부당해고)는 벡터만으로는 정답 3개 중 1개만 찾았는데, 그래프 검색 덕분에 3/3 ALL을 달성했다. Q9(양도소득)에서는 그래프가 소득세법 104조를 매번 찾아줬다.&lt;/p&gt;
&lt;p&gt;하지만 RRF 병합의 부작용도 있었다. Q8, Q10 같은 케이스에서는 그래프의 엉뚱한 결과가 벡터가 정확히 찾은 문서를 밀어내는 현상이 관찰됐다.&lt;/p&gt;
&lt;h2&gt;쿼리 분해 실험: 기대와 현실&lt;/h2&gt;
&lt;p&gt;복수정답 문제를 더 근본적으로 풀기 위해 쿼리 분해(Query Decomposition)를 시도했다. 아이디어는 단순하다. 복수 주제 질문을 서브질문으로 쪼개고, 각각 독립적으로 검색한 뒤 결과를 병합하는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;법인세 얼마? 접대비 비용처리?&quot;
  -&amp;gt; 서브1: &quot;법인세 과세표준과 세율&quot; -&amp;gt; Top-5 검색
  -&amp;gt; 서브2: &quot;접대비 비용처리 한도&quot; -&amp;gt; Top-5 검색
  -&amp;gt; RRF 병합 -&amp;gt; 최종 Top-10
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;치팅 실험 vs 클린 실험&lt;/h3&gt;
&lt;p&gt;먼저 few-shot 예시에 평가 질문과 유사한 예시를 넣어 &quot;치팅 실험&quot;을 했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;치팅: 하이브리드 20/31 (65%) vs 벡터만 19/31 (61%)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Q11(소득세 계산)이 1/4에서 4/4로 극적으로 개선됐다. 하지만 이건 오버피팅이다. 평가 질문과 무관한 few-shot으로 바꾼 &quot;클린 실험&quot; 결과는 달랐다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;클린: 하이브리드 15/31 (48%) vs 벡터만 16/31 (52%)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 단일 쿼리 방식(17/31)보다 오히려 성능이 하락했다.&lt;/p&gt;
&lt;h3&gt;왜 실패했나&lt;/h3&gt;
&lt;p&gt;실패 원인은 세 가지였다. 첫째, LLM이 분해를 안 하는 경우가 있었다. Q11은 4개 주제를 담고 있는데 LLM이 단일 질문으로 판단해버렸다. 둘째, 분해된 쿼리의 표현이 벡터 검색에 맞지 않았다. &quot;담장 파손&quot;이라는 구체적 표현이 &quot;불법행위&quot;라는 추상적 법률 용어로 바뀌면서 검색 품질이 떨어졌다. 셋째, few-shot 예시 의존도가 너무 높아서 일반화가 불가능했다.&lt;/p&gt;
&lt;p&gt;근본적인 문제는 명확했다. &lt;strong&gt;프롬프트 튜닝은 테스트셋 오버피팅일 뿐, 실제 서비스에서의 일반화를 보장할 수 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;실험에서 배운 것&lt;/h2&gt;
&lt;p&gt;이 실험들에서 얻은 교훈을 정리하면 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;벡터 검색은 단일 정답 질문에 이미 충분하다.&lt;/strong&gt; 57개 단답형/시나리오형 질문에서 100% recall을 달성했다. 문제 영역을 정확히 식별하는 것이 중요하다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;그래프 검색은 안정적이지만 제한적이다.&lt;/strong&gt; 복수정답 케이스에서 일관적으로 +1~3의 기여를 했지만, 그 이상의 극적인 개선은 없었다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;RRF 병합은 양날의 검이다.&lt;/strong&gt; 두 검색 결과를 합치는 과정에서 오히려 정답이 밀려나는 부작용이 있었다. 가중치 조정이나 다른 병합 전략이 필요하다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;쿼리 분해는 (적어도 이 시점에서는) 효과가 없었다.&lt;/strong&gt; few-shot 의존도가 높고, LLM의 분해 판단이 불안정하며, 분해된 쿼리가 벡터 검색에 최적화되지 않았다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;프리라이터 표현이 벡터 성능을 좌우한다.&lt;/strong&gt; 이것이 가장 실용적인 발견이었다. 검색 아키텍처를 복잡하게 만드는 것보다, 벡터 검색에 들어가는 쿼리 표현 자체를 개선하는 것이 더 효과적일 수 있다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 실험 이후 방향은 두 가지로 좁혀졌다. 하나는 검색 후 리랭킹(post-retrieval reranking)으로 Top-30 후보를 뽑은 뒤 LLM으로 정밀 순위를 매기는 것, 다른 하나는 프리라이터의 쿼리 표현 품질 자체를 높이는 것이었다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (5) — Query Rewriting: 프롬프트 진화와 subQuery 실험</title><link>https://blog.dongjun.win/posts/legal-ai-search-05-query-rewriting-prompt-evolution/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-05-query-rewriting-prompt-evolution/</guid><description>법률 QA 검색 품질을 높이기 위해 query rewriting 프롬프트를 개선하고 subQuery 분해를 실험한 결과와 한계를 정리합니다.</description><pubDate>Tue, 14 Apr 2026 01:07:27 GMT</pubDate><content:encoded>&lt;p&gt;법률 QA 시스템의 검색 품질을 끌어올리기 위해 query rewriting 프롬프트를 반복 개선하고, sub-query decomposition까지 도입해 본 실험 기록이다. 결론부터 말하면, 프롬프트 개선은 효과가 있었지만 한계가 명확했고, sub-query 전략은 기대만큼의 돌파구가 되지 못했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;배경: V2까지의 상황&lt;/h2&gt;
&lt;p&gt;이전 글에서 다뤘듯이, prerewriter V2는 Gemini 2.5 Flash-Lite 모델 기준으로 raw query 대비 오히려 성능이 떨어지는 경우가 많았다. 쿼리를 다시 써주는 것이 항상 좋은 것은 아니라는 교훈을 얻은 셈이다. 그래서 V3에서는 프롬프트 자체를 근본적으로 재설계했다.&lt;/p&gt;
&lt;p&gt;한편 당시 최고 baseline은 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt; 임베딩 모델에 raw query를 그대로 넣는 조합이었다. Recall@50 기준 30/31, Full Recall@50 기준 10/11. prerewriter가 이 baseline을 넘지 못하면 존재 의의가 없는 상황이었다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고&lt;/strong&gt;: 이 30/31 수치는 초기 평가 기준 기준이다. 이후 multi-2 문항의 정답 정의를 재검토하면서(민법 766조 → 756조) benchmark를 수정했고, graph 검색과 결합하여 최종 31/31을 달성했다. 자세한 내용은 이후 &quot;1차 아키텍처 확정: 실험에서 운영으로&quot; 편에서 다룬다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;V3: 프롬프트 개선의 효과&lt;/h2&gt;
&lt;p&gt;V3 프롬프트는 2026년 3월 9일에 &lt;code&gt;prerewriter-unified-v3.ts&lt;/code&gt;로 구현해서 평가했다. 모델은 동일하게 Gemini 2.5 Flash-Lite, 임베딩 5종 전체에 대해 복수정답 질문셋으로 측정했다.&lt;/p&gt;
&lt;h3&gt;핵심 결과 (Recall@50 / Full Recall@50)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;임베딩 모델&lt;/th&gt;
&lt;th&gt;raw&lt;/th&gt;
&lt;th&gt;V3 prerewrite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bge-m3&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-0.6b&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29/31, 9/11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-4b&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31, 10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29/31, 9/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;26/31, 6/11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28/31, 8/11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-4b&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;27/31, 7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;V2 대비 확실한 개선이 있었다. V2는 raw보다 성능을 깎아먹는 경우가 잦았는데, V3는 최소한 그런 참사는 없었다. 특히 두 가지 임베딩에서 의미 있는 향상을 확인했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pplx-embed-v1-0.6b&lt;/code&gt;: 28/31에서 29/31로, Full Recall도 8/11에서 9/11로 상승&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pplx-embed-context-v1-0.6b&lt;/code&gt;: 26/31에서 28/31로, Full Recall은 6/11에서 8/11로 대폭 상승&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만 주의할 점이 있었다. 최고 성능 임베딩인 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;에서는 오히려 V3가 raw보다 약간 낮았다. 그리고 평균 latency가 +1.3~1.4초 증가했다. 검색 한 건당 1.5초가 추가되는 것은 운영 환경에서 무시할 수 없는 비용이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;V3의 판단&lt;/strong&gt;: 프롬프트 방향은 맞다. V2보다 확실히 낫다. 그러나 전체 최고 baseline(pplx-embed-v1-4b raw)을 대체하기에는 아직 부족하다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;V4: sub-query decomposition 도입&lt;/h2&gt;
&lt;p&gt;V3에서 단일 쿼리 rewriting의 한계를 체감한 뒤, 다음 시도로 sub-query decomposition을 도입했다. 이것이 V4다.&lt;/p&gt;
&lt;h3&gt;sub-query decomposition이란&lt;/h3&gt;
&lt;p&gt;RAG 시스템에서 널리 연구되는 기법으로, 복잡한 질문을 더 단순한 하위 질문들로 분해한 뒤 각각에 대해 검색을 수행하고 결과를 병합하는 방식이다. 최근 연구들에 따르면, multi-hop 질문에서 관련 사실이 여러 문서에 분산되어 있을 때 표준 RAG가 충분한 정보를 검색하지 못하는 문제를 해결하기 위해 고안되었다. &lt;a href=&quot;https://arxiv.org/html/2507.00355v1&quot;&gt;Question Decomposition for Retrieval-Augmented Generation (ACL 2025)&lt;/a&gt; 연구에서는 질문 분해와 reranking을 결합했을 때 MRR@10 기준 +36.7%, 답변 F1 기준 +11.6%의 개선을 보고하기도 했다.&lt;/p&gt;
&lt;p&gt;법률 QA에서도 이 접근이 유효할 것이라는 가설이 있었다. &quot;임대차 계약 해지 시 보증금 반환 절차와 기한은?&quot;처럼 여러 축(해지 절차, 보증금 반환, 기한)을 동시에 묻는 질문이 많기 때문이다. 각 축별로 검색하면 놓치는 조문이 줄지 않을까.&lt;/p&gt;
&lt;h3&gt;V4 설계&lt;/h3&gt;
&lt;p&gt;V4 프롬프트(&lt;code&gt;prerewriter-unified-v4.ts&lt;/code&gt;)는 기존 단일 searchQuery 외에 구조화된 출력을 도입했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;queryType&lt;/code&gt;: 질문 유형 분류&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vector.subQueries&lt;/code&gt;: 하위 질문 목록&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vector.keywords&lt;/code&gt;: 검색 키워드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graph.keywords&lt;/code&gt;: 그래프 검색용 키워드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graph.lawNames&lt;/code&gt;: 관련 법률명&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;검색 시에는 메인 searchQuery와 각 subQuery를 개별 검색한 뒤 RRF(Reciprocal Rank Fusion)로 병합했다. 그래프 검색은 이번 실험에서 켜지 않았고, vector 경로만 평가했다.&lt;/p&gt;
&lt;h3&gt;결과 (Recall@50 / Full Recall@50)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;임베딩 모델&lt;/th&gt;
&lt;th&gt;raw&lt;/th&gt;
&lt;th&gt;V3&lt;/th&gt;
&lt;th&gt;V4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bge-m3&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;27/31, 7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-0.6b&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;29/31, 9/11&lt;/td&gt;
&lt;td&gt;29/31, 9/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-4b&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31, 10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29/31, 9/11&lt;/td&gt;
&lt;td&gt;29/31, 9/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;26/31, 6/11&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;27/31, 7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-4b&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;td&gt;27/31, 7/11&lt;/td&gt;
&lt;td&gt;28/31, 8/11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;sub-query를 실제로 검색 병합에 넣어봤지만, V3 대비 전반적으로 나아지지 않았다. 최고치가 같은 경우도 있었지만 Recall@10 같은 초기 정밀도 지표에서는 오히려 크게 악화됐다.&lt;/p&gt;
&lt;p&gt;특히 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;의 Recall@10이 V3의 21/31에서 V4의 14/31로 떨어졌다. sub-query들이 오히려 노이즈를 끌어들여 상위 랭킹을 흐트러뜨린 것이다. Latency도 ~2.2초까지 올라가 V3의 ~1.5초보다 더 나빠졌다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;세 버전 비교: 무엇을 배웠나&lt;/h2&gt;
&lt;p&gt;V2에서 V4까지의 여정을 요약하면 이렇다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;버전&lt;/th&gt;
&lt;th&gt;접근&lt;/th&gt;
&lt;th&gt;최고 Recall@50&lt;/th&gt;
&lt;th&gt;최고 Full@50&lt;/th&gt;
&lt;th&gt;Latency 추가&lt;/th&gt;
&lt;th&gt;판정&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw (baseline)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;30/31&lt;/td&gt;
&lt;td&gt;10/11&lt;/td&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;최고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V2&lt;/td&gt;
&lt;td&gt;기본 rewriting&lt;/td&gt;
&lt;td&gt;raw 이하&lt;/td&gt;
&lt;td&gt;raw 이하&lt;/td&gt;
&lt;td&gt;+1.2s&lt;/td&gt;
&lt;td&gt;실패&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V3&lt;/td&gt;
&lt;td&gt;프롬프트 재설계&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;+1.4s&lt;/td&gt;
&lt;td&gt;부분 개선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V4&lt;/td&gt;
&lt;td&gt;sub-query + RRF 병합&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;+2.2s&lt;/td&gt;
&lt;td&gt;개선 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;핵심 발견은 세 가지다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 프롬프트 품질은 중요하다.&lt;/strong&gt; V2에서 V3로의 개선은 프롬프트 설계만으로도 retrieval 품질을 의미 있게 올릴 수 있음을 보여줬다. &quot;잘 쓴&quot; rewriting은 약한 임베딩 모델의 성능을 끌어올리는 효과가 있었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, sub-query decomposition은 만능이 아니다.&lt;/strong&gt; 학술 연구에서 보고된 큰 폭의 개선이 우리 도메인에서는 재현되지 않았다. 분해된 하위 질문들이 오히려 노이즈를 증폭시키는 현상이 관찰됐다. 이전에 clean 실험에서도 few-shot 치팅 조건에서만 좋아 보이고 일반화 조건에서는 하락했던 이력이 있었는데, V4에서도 같은 패턴이 반복됐다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, prerewriter 단독으로는 retrieval miss를 해결할 수 없다.&lt;/strong&gt; 어떤 프롬프트를 써도 &lt;code&gt;pplx-embed-v1-4b raw&lt;/code&gt;의 30/31, 10/11을 넘지 못했다. 문제의 본질이 &quot;쿼리를 얼마나 잘 다시 쓰느냐&quot;가 아니라 &quot;Top-50 안에 아예 들어오지 못하는 조문을 어떻게 회수하느냐&quot;에 있다는 결론에 도달했다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;한계 인식과 다음 가설&lt;/h2&gt;
&lt;p&gt;이 실험들을 거치며 팀 내 토론에서 도출한 핵심 인식은 이렇다: &lt;strong&gt;현재 병목은 selection miss가 아니라 retrieval miss다.&lt;/strong&gt; selection miss(Top-50 안에 있는데 최종 답변에서 빠지는 것)는 이미 상당 부분 완화됐고, 진짜 문제는 Top-50 안에 아예 들어오지 않는 조문이었다.&lt;/p&gt;
&lt;p&gt;이 관점에서 네 가지 다음 가설을 세웠다.&lt;/p&gt;
&lt;h3&gt;1. 임베딩 모델 재평가 (우선순위 1위)&lt;/h3&gt;
&lt;p&gt;retrieval quality를 직접 건드리는 가장 근본적인 접근이다. 기존 임베딩 평가가 단일정답 위주였기 때문에, 복수정답 Recall@20/50과 holdout domain noise 기준으로 다시 평가하면 더 나은 모델이 있을 수 있다. 만약 K20에서 현재 K50 수준 recall이 나오면 노이즈 감소, selection miss 감소, retrieval miss 완화까지 기대할 수 있다.&lt;/p&gt;
&lt;h3&gt;2. retrieval-stage 웹검색 보조 (우선순위 2위)&lt;/h3&gt;
&lt;p&gt;웹검색을 답변 단계가 아니라 검색 단계의 병렬 레인으로 사용하는 것이다. 과거 실험에서 retrieval-stage web augment가 Recall@50을 29/31에서 30/31로, Full Recall을 9/11에서 10/11로 올린 적이 있었다. 웹에서 법률명, 절차명, 숨은 facet 같은 힌트를 먼저 얻고 이를 내부 DB 문서로 다시 매핑/검증하는 구조다.&lt;/p&gt;
&lt;h3&gt;3. 원문 유지형 query 분할 (우선순위 3위)&lt;/h3&gt;
&lt;p&gt;sub-query decomposition 자체를 버리는 것은 아니다. 다만 원문 query를 대체하는 방식이 아니라, 원문 검색 결과에 추가 후보를 확장하는 보조 수단으로만 한정해야 한다는 교훈을 얻었다. V4의 실패는 &quot;분해 결과로 원문을 대체한&quot; 설계에서 비롯된 측면이 크다.&lt;/p&gt;
&lt;h3&gt;4. Graph DB (Neo4j) 도입 (우선순위 4위)&lt;/h3&gt;
&lt;p&gt;장기적으로는 검토 가치가 있지만, 지금 당장 정확도 개선 카드로 보기는 어렵다. 현재 문제는 저장소가 MongoDB인지 Neo4j인지가 아니라, 그래프가 &quot;조문과 개념&quot;, &quot;조문과 절차&quot;, &quot;조문과 기간/요건/효과&quot; 같은 의미 관계를 알고 있는지의 문제이기 때문이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;돌아보며&lt;/h2&gt;
&lt;p&gt;prerewriter V3, V4 실험을 통해 query rewriting의 가능성과 한계를 동시에 확인했다. 프롬프트 개선은 분명히 효과가 있었고, sub-query decomposition이라는 학술적으로 검증된 기법도 직접 시도해 봤다. 그러나 우리 도메인에서는 기대한 만큼의 돌파구가 되지 못했다.&lt;/p&gt;
&lt;p&gt;가장 큰 수확은 &quot;문제의 본질이 어디에 있는지&quot;를 명확히 한 것이다. prerewriter를 아무리 고도화해도 임베딩 모델의 retrieval quality라는 천장을 넘을 수 없다. 다음 단계는 그 천장 자체를 올리는 작업이 되어야 한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;참고 자료:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/html/2507.00355v1&quot;&gt;Question Decomposition for Retrieval-Augmented Generation (ACL 2025)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2510.18633&quot;&gt;Query Decomposition for RAG: Balancing Exploration-Exploitation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/html/2404.00610v1&quot;&gt;RQ-RAG: Learning to Refine Queries for Retrieval Augmented Generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://haystack.deepset.ai/blog/query-decomposition&quot;&gt;Advanced RAG: Query Decomposition &amp;amp; Reasoning - Haystack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/NirDiamant/RAG_Techniques&quot;&gt;RAG Techniques - GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (4) — Query Rewriting: Prerewriter 도입과 모델 비교</title><link>https://blog.dongjun.win/posts/legal-ai-search-04-query-rewriting-prerewriter/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-04-query-rewriting-prerewriter/</guid><description>법률 RAG 검색어를 개선하는 Prerewriter를 도입하고 여러 LLM의 쿼리 재작성 품질, 속도와 비용을 비교합니다.</description><pubDate>Sat, 11 Apr 2026 11:18:39 GMT</pubDate><content:encoded>&lt;h2&gt;벡터 검색 성능을 올리는 가장 쉬운 방법&lt;/h2&gt;
&lt;p&gt;RAG 파이프라인에서 retrieval 성능이 안 나올 때 가장 먼저 떠오르는 선택지는 보통 두 가지다. 임베딩 모델을 바꾸거나, 쿼리를 바꾸거나. 임베딩 모델 비교는 이미 별도로 진행했고, 이번에는 후자를 건드릴 차례였다.&lt;/p&gt;
&lt;p&gt;업계에서는 이 접근을 보통 &lt;strong&gt;query rewriting&lt;/strong&gt;이라고 부른다. 사용자의 원문 질문을 검색에 더 유리한 형태로 변환하는 것이다. Microsoft의 RAG 기법 정리 문서에서는 query rewriting을 &quot;pre-retrieval&quot; 단계의 핵심 기법으로 분류하고 있고, 최근 연구(arxiv 2501.07391)에서도 query expansion과 rewriting이 retrieval 품질에 미치는 영향을 체계적으로 조사하고 있다. HyDE(Hypothetical Document Embedding)처럼 가상 문서를 생성해서 검색하는 방법도 있고, Step-Back Prompting처럼 질문을 더 일반적인 형태로 바꾸는 접근도 있다.&lt;/p&gt;
&lt;p&gt;내가 만든 파이프라인에서는 이 단계를 &lt;strong&gt;prerewriter&lt;/strong&gt;라고 부른다. 검색 전에 쿼리를 다시 쓴다는 뜻 그대로다. 이번 글에서는 prerewriter V2의 설계 의도와, 네 가지 LLM 모델로 비교 실험한 결과를 정리한다.&lt;/p&gt;
&lt;h2&gt;Prerewriter V2 설계 의도&lt;/h2&gt;
&lt;p&gt;Prerewriter V1은 단순한 rephrasing이었다. V2에서 바꾼 핵심 설계는 크게 세 가지다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 복수 정답을 더 강하게 고려한다.&lt;/strong&gt; 법률 QA에서는 하나의 질문이 여러 조문, 여러 법률에 걸쳐 답을 가지는 경우가 많다. &quot;상속 포기 절차와 그 효과는?&quot;이라는 질문 하나에도 민법의 여러 조문이 관련된다. Prerewriter V2는 하나의 질문 안에서 여러 축--사람, 기관, 관계, 권리, 책임, 절차, 금액, 기간, 숫자--을 보존하도록 프롬프트를 설계했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 출력을 용도별로 분리한다.&lt;/strong&gt; 단일 프롬프트 안에서 벡터 검색용과 그래프 검색용 출력을 함께 생성한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;vector.searchQuery&lt;/code&gt;: 벡터 검색에 넣을 재작성된 쿼리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vector.keywords&lt;/code&gt;: 벡터 검색 보조 키워드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graph.keywords&lt;/code&gt;: 그래프 탐색용 키워드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graph.lawNames&lt;/code&gt;: 그래프 탐색에 쓸 법률명&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만, 이번 실험에서는 &lt;strong&gt;vector 출력만 사용&lt;/strong&gt;했다. 실제 검색은 벡터 DB의 hybrid(dense + sparse) 검색만 돌렸고, 그래프 검색은 사용하지 않았다. 그래프 검색 쪽은 별도 실험으로 분리할 예정이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, 기존 코드와 실험을 건드리지 않는다.&lt;/strong&gt; 새 prerewriter만 별도로 추가하고, baseline(raw, 즉 원문 그대로 검색)과의 비교를 항상 유지했다.&lt;/p&gt;
&lt;h2&gt;실험 설계&lt;/h2&gt;
&lt;p&gt;질문셋은 전체 84개를 사용했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;카테고리&lt;/th&gt;
&lt;th&gt;문항 수&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;direct&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scenario&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tax&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multi&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;holdout&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;임베딩 모델은 5종 전체를 돌렸다: &lt;code&gt;bge-m3&lt;/code&gt;, &lt;code&gt;pplx-embed-v1-0.6b&lt;/code&gt;, &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;, &lt;code&gt;pplx-embed-context-v1-0.6b&lt;/code&gt;, &lt;code&gt;pplx-embed-context-v1-4b&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Prerewriter 모델은 두 차례에 걸쳐 총 4종을 비교했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1차 실험&lt;/strong&gt;: &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;, &lt;code&gt;gpt-5-mini&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2차 실험&lt;/strong&gt;: &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, &lt;code&gt;gpt-4.1-mini&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;평가 지표는 복수 정답 recall을 중심으로 봤다. &lt;code&gt;@K&lt;/code&gt;는 상위 K개 청크 안에 정답 조문이 몇 개 포함되었는지, &lt;code&gt;F@K&lt;/code&gt;는 복수 정답이 모두 포함된 질문(&quot;full match&quot;)이 몇 개인지를 뜻한다. 예를 들어 multi 카테고리에서 &lt;code&gt;@50 = 30/31&lt;/code&gt;이라면, 상위 50개 청크 안에 전체 31개 정답 중 30개가 포함되었다는 의미다. &lt;code&gt;F@50 = 10/11&lt;/code&gt;이면 11개 multi 질문 중 10개가 모든 정답을 상위 50개 안에서 찾았다는 뜻이다.&lt;/p&gt;
&lt;h2&gt;1차 실험: Gemini Flash Lite vs GPT-5 mini&lt;/h2&gt;
&lt;h3&gt;결과 요약 (multi 카테고리, 복수 정답 기준)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;임베딩&lt;/th&gt;
&lt;th&gt;모드&lt;/th&gt;
&lt;th&gt;@10&lt;/th&gt;
&lt;th&gt;@30&lt;/th&gt;
&lt;th&gt;@50&lt;/th&gt;
&lt;th&gt;F@50&lt;/th&gt;
&lt;th&gt;평균 지연&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;19/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;324ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;gemini-flash-lite&lt;/td&gt;
&lt;td&gt;19/31&lt;/td&gt;
&lt;td&gt;25/31&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;1,755ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;gpt-5-mini&lt;/td&gt;
&lt;td&gt;21/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5,387ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-0.6b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;17/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;323ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-0.6b&lt;/td&gt;
&lt;td&gt;gpt-5-mini&lt;/td&gt;
&lt;td&gt;16/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;5,383ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;16/31&lt;/td&gt;
&lt;td&gt;24/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;326ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;gpt-5-mini&lt;/td&gt;
&lt;td&gt;17/31&lt;/td&gt;
&lt;td&gt;27/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;5,394ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;해석&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Gemini 2.5 Flash Lite는 전반적으로 실패였다.&lt;/strong&gt; Raw보다 분명히 떨어지는 경우가 많았고, 특히 &lt;code&gt;bge-m3&lt;/code&gt;와 &lt;code&gt;pplx-v1-4b&lt;/code&gt;에서 손해가 컸다. 쿼리를 재작성했는데 오히려 원문보다 검색 품질이 나빠진 것이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GPT-5 mini는 일부 임베딩에서 실제로 개선을 보였다.&lt;/strong&gt; 주목할 만한 변화를 정리하면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pplx-embed-v1-0.6b&lt;/code&gt;: @50이 28/31에서 29/31로, F@50이 8/11에서 9/11로 상승&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pplx-embed-context-v1-0.6b&lt;/code&gt;: @50이 26/31에서 29/31로, F@50이 6/11에서 9/11로 상승. 이건 꽤 큰 점프다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pplx-embed-context-v1-4b&lt;/code&gt;: @50이 28/31에서 29/31로, F@50이 8/11에서 9/11로 상승&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만 최고 성능 조합인 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;에서는 @50 기준 30/31로 raw와 동일했다. @30, @40에서는 개선이 있었지만 천장을 뚫지는 못했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;가장 큰 문제는 속도였다.&lt;/strong&gt; GPT-5 mini prerewriter를 붙이면 multi 기준 5.2초에서 5.5초가 걸렸다. Raw는 0.16초에서 0.33초다. Retrieval 단계 하나에 5초를 더 쓰는 건 운영 환경에서 받아들이기 어렵다.&lt;/p&gt;
&lt;h2&gt;2차 실험: Gemini Flash vs GPT-4.1 mini&lt;/h2&gt;
&lt;p&gt;1차에서 flash-lite가 너무 약했기 때문에 상위 모델인 &lt;code&gt;gemini-2.5-flash&lt;/code&gt;를 추가했고, GPT 쪽에서는 비용/속도 절충을 위해 &lt;code&gt;gpt-4.1-mini&lt;/code&gt;를 넣었다.&lt;/p&gt;
&lt;h3&gt;결과 요약 (multi 카테고리, 복수 정답 기준)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;임베딩&lt;/th&gt;
&lt;th&gt;모드&lt;/th&gt;
&lt;th&gt;@10&lt;/th&gt;
&lt;th&gt;@30&lt;/th&gt;
&lt;th&gt;@50&lt;/th&gt;
&lt;th&gt;F@50&lt;/th&gt;
&lt;th&gt;평균 지연&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;19/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;376ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;gemini-flash&lt;/td&gt;
&lt;td&gt;18/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;3,613ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;gpt-4.1-mini&lt;/td&gt;
&lt;td&gt;20/31&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;2,894ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-0.6b&lt;/td&gt;
&lt;td&gt;gemini-flash&lt;/td&gt;
&lt;td&gt;19/31&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;3,601ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-context-v1-4b&lt;/td&gt;
&lt;td&gt;gpt-4.1-mini&lt;/td&gt;
&lt;td&gt;17/31&lt;/td&gt;
&lt;td&gt;24/31&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;2,835ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;해석&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Gemini 2.5 Flash는 Flash Lite보다 확실히 나았다.&lt;/strong&gt; 특히 &lt;code&gt;pplx-v1-0.6b&lt;/code&gt;에서 @20, @30이 꽤 올라갔다. 하지만 raw를 확실히 뒤집는 수준은 아니었다. 지연도 3.5초에서 3.6초대로 여전히 높았다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GPT-4.1 mini는 GPT-5 mini보다 빨랐지만, 성능은 약했다.&lt;/strong&gt; 지연이 2.7초에서 2.9초로 GPT-5 mini 대비 절반 가까이 줄었는데, 그만큼 rewriting 품질도 떨어진 것으로 보인다. 일부 임베딩에서 @40, @50은 괜찮았지만, 전체 최고 조합을 만들지는 못했다.&lt;/p&gt;
&lt;h2&gt;네 모델을 종합하면&lt;/h2&gt;
&lt;p&gt;모든 실험을 관통하는 결론은 명확했다.&lt;/p&gt;
&lt;p&gt;**순수 retrieval 최고 성능은 여전히 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt; + raw(원문 그대로)**였다. Multi @50 = 30/31, F@50 = 10/11. 어떤 prerewriter를 붙여도 이 조합을 일관되게 넘지 못했다.&lt;/p&gt;
&lt;p&gt;네 모델의 포지션을 정리하면 이렇다:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;성능 개선&lt;/th&gt;
&lt;th&gt;지연&lt;/th&gt;
&lt;th&gt;종합 판단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash-lite&lt;/td&gt;
&lt;td&gt;raw보다 하락&lt;/td&gt;
&lt;td&gt;~1.7s&lt;/td&gt;
&lt;td&gt;탈락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;flash-lite보다 개선, raw 미달&lt;/td&gt;
&lt;td&gt;~3.5s&lt;/td&gt;
&lt;td&gt;보류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-4.1-mini&lt;/td&gt;
&lt;td&gt;부분 개선, gpt-5-mini 미달&lt;/td&gt;
&lt;td&gt;~2.8s&lt;/td&gt;
&lt;td&gt;보류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5-mini&lt;/td&gt;
&lt;td&gt;일부 임베딩에서 유의미 개선&lt;/td&gt;
&lt;td&gt;~5.3s&lt;/td&gt;
&lt;td&gt;연구 후보&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;속도 대비 품질이 가장 좋았던 모델은 gpt-5-mini&lt;/strong&gt;다. 특히 중소형 임베딩(&lt;code&gt;pplx-embed-v1-0.6b&lt;/code&gt;, &lt;code&gt;pplx-embed-context-v1-0.6b&lt;/code&gt;)과 조합했을 때 F@50 기준 6/11에서 9/11로 올라가는 등 눈에 띄는 개선이 있었다. 하지만 가장 강한 임베딩(&lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;)과 조합하면 이미 raw가 충분히 좋아서 prerewriter의 추가 가치가 제한적이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gemini 계열은 이 프롬프트 설계와 궁합이 안 맞았다.&lt;/strong&gt; Flash Lite는 아예 역효과였고, Flash도 기대만큼은 아니었다. 모델 자체의 문제인지 프롬프트 최적화 부족인지는 이 실험만으로는 단정할 수 없다.&lt;/p&gt;
&lt;h2&gt;이 실험에서 확인한 것&lt;/h2&gt;
&lt;p&gt;Query rewriting이 RAG에서 유효한 기법이라는 건 업계 전반의 합의다. 하지만 &quot;항상 좋아진다&quot;는 보장은 없다. 이번 실험에서 확인한 결론은 세 가지다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 강한 임베딩 + 원문이 약한 임베딩 + rewriting을 이긴다.&lt;/strong&gt; &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt; raw가 어떤 prerewriter 조합보다 좋거나 동등했다. 임베딩 모델의 기본 역량이 충분하면 쿼리를 손대지 않는 것이 더 나을 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Rewriting은 약한 임베딩을 보완하는 데 더 효과적이다.&lt;/strong&gt; GPT-5 mini prerewriter가 가장 큰 효과를 보인 건 0.6b급 소형 임베딩과의 조합이었다. 임베딩 모델의 표현력이 부족한 부분을 쿼리 쪽에서 보완한 결과다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 속도 비용을 무시할 수 없다.&lt;/strong&gt; Prerewriter를 붙이면 retrieval 지연이 10배에서 16배로 뛴다. 후속 단계(answer generation, verification)의 지연까지 합치면 전체 파이프라인 응답 시간에 미치는 영향이 상당하다. 운영 환경에서는 이 trade-off를 무시할 수 없다.&lt;/p&gt;
&lt;p&gt;결론적으로, prerewriter는 운영 기본값으로 채택하기에는 아직 설득력이 부족했다. 하지만 &quot;쿼리를 바꾸면 검색이 달라진다&quot;는 가능성 자체는 확인했고, 특히 소형 임베딩 환경이나 복수 정답 recall이 중요한 시나리오에서는 여전히 연구할 가치가 있다. 다음 단계에서는 source routing과 결합하거나, prerewriter의 프롬프트 자체를 더 정교하게 다듬는 방향을 검토할 예정이다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (3) — 복수 정답 문제와 LLM Selector 모델 비교</title><link>https://blog.dongjun.win/posts/legal-ai-search-03-llm-selector-benchmark/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-03-llm-selector-benchmark/</guid><description>검색 결과에 정답 근거가 있어도 최종 선택에서 누락되는 복수 정답 문제와 LLM Selector 모델별 성능을 비교합니다.</description><pubDate>Tue, 07 Apr 2026 23:31:17 GMT</pubDate><content:encoded>&lt;h2&gt;검색 결과에서 정답을 &quot;선택&quot;하는 것도 문제다&lt;/h2&gt;
&lt;p&gt;법률 QA 시스템에서 검색(retrieval) 품질은 기본 전제다. 검색이 어느 정도 궤도에 오르자, 다음 병목이 드러났다. Top-50 검색 결과 안에 정답 근거가 들어 있는데도 최종 답변에서 빠지는 경우가 생긴 것이다.&lt;/p&gt;
&lt;p&gt;예를 들어 &quot;택배 배송 중 물건이 파손되었을 때 누구에게 책임을 물을 수 있는가?&quot;라는 질문에 대해, 검색 결과에는 민법 제756조(사용자책임)가 포함되어 있었다. 그런데 LLM이 답변을 생성하면서 이 조문을 근거로 선택하지 않았다. 50개 후보 중에서 어떤 것이 진짜 근거인지 &quot;골라내는&quot; 단계, 즉 selector가 별도로 필요했다.&lt;/p&gt;
&lt;p&gt;이 글은 selector 구조를 설계하고, 여러 LLM 모델을 비교 실험한 과정을 정리한 기록이다.&lt;/p&gt;
&lt;h2&gt;두 가지 접근법: Single Selector vs Citation Selector&lt;/h2&gt;
&lt;p&gt;처음에는 selection planner라는 구조를 시도했다. 검색 결과 전체를 보고 어떤 조문이 왜 필요한지를 장문으로 설명하게 하는 방식이었다. 방향은 맞았지만, 출력이 길고 파싱이 불안정했다. 그래서 출력을 직접 근거, 보조 근거, 누락 포인트 세 필드로 줄인 citation selector로 전환했다.&lt;/p&gt;
&lt;p&gt;citation selector는 안정적이었다. 전체 11문항을 파싱 오류 없이 완주했고, Selection Recall 27/31(87.1%)을 기록했다. 하지만 여전히 selection miss가 남았다. 이를 해결하기 위해 두 가지 구조를 실험했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;2-pass selector&lt;/strong&gt;: coverage pass와 completion pass를 나눠서 2회 호출. 1차에서 넓게 훑고, 2차에서 빠진 것을 보완한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;single selector&lt;/strong&gt;: 1회 호출로 선택과 보완을 동시에 처리. 속도를 줄이는 대신 품질 손실이 있을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;2-pass selector는 Gemini 3 Flash Preview 기준으로 Selection Recall을 29/31(93.5%)까지 끌어올렸다. Q2(사용자책임)와 Q11(이자소득)의 selection miss가 모두 해결되었다. 다만 질문당 2회 호출이라 latency가 19~39초 수준으로 늘어났다.&lt;/p&gt;
&lt;h2&gt;모델별 비교: 속도와 품질의 트레이드오프&lt;/h2&gt;
&lt;p&gt;selector 구조가 잡히자 다음 질문은 자연스럽게 &quot;어떤 모델이 가장 나은가&quot;였다. OpenAI의 GPT-5 mini, GPT-5 nano와 Google의 Gemini 3 Flash Preview, Gemini 2.5 Flash Lite를 비교했다.&lt;/p&gt;
&lt;h3&gt;2-pass selector 비교 (Q2, Q11 기준)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;Selection Recall&lt;/th&gt;
&lt;th&gt;pass1 평균 latency&lt;/th&gt;
&lt;th&gt;pass2 평균 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 mini&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;4,399ms&lt;/td&gt;
&lt;td&gt;4,956ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 nano&lt;/td&gt;
&lt;td&gt;4/6&lt;/td&gt;
&lt;td&gt;2,568ms&lt;/td&gt;
&lt;td&gt;3,207ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash Lite&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview&lt;/td&gt;
&lt;td&gt;6/6 (전체 11문항 29/31)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;GPT-5 mini는 Q2와 Q11을 모두 해결했지만, GPT-5 nano는 Q11에서 무너졌다. 속도는 빠르지만 품질 손실이 컸다.&lt;/p&gt;
&lt;h3&gt;single selector 비교 (Q2, Q11 기준)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;Selection Recall&lt;/th&gt;
&lt;th&gt;평균 selector latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;12,011ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash Lite&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;1,659ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 mini&lt;/td&gt;
&lt;td&gt;4/6&lt;/td&gt;
&lt;td&gt;3,173ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 nano&lt;/td&gt;
&lt;td&gt;3/6&lt;/td&gt;
&lt;td&gt;2,297ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;GPT-5 mini는 2-pass에서 6/6이었지만 single selector에서는 4/6으로 떨어졌다. 반면 Gemini 2.5 Flash Lite는 single selector에서도 5/6을 유지하면서 latency가 1.6초로 가장 빨랐다.&lt;/p&gt;
&lt;h3&gt;holdout 16문항 일반화 검증 (single selector + answer)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;도메인 커버&lt;/th&gt;
&lt;th&gt;평균 총 시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash Lite&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;4.5~5.0초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 mini&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;8.8~11.6초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 nano&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;td&gt;5.5~9.6초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;16.8~25.3초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Gemini 2.5 Flash Lite가 속도와 품질의 균형에서 가장 현실적인 결과를 보였다. 5초 이내에 답변이 나오면서 holdout 도메인도 모두 커버했다.&lt;/p&gt;
&lt;h2&gt;All-in-One: selector와 answer를 합치면?&lt;/h2&gt;
&lt;p&gt;selector와 answer를 분리하면 호출이 최소 2회다. 이걸 1회로 합칠 수 있다면? &quot;근거 선택 + 최종 답변 생성&quot;을 한 번에 처리하는 all-in-one 방식을 실험했다.&lt;/p&gt;
&lt;h3&gt;Core 11문항 결과&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;Selection Recall&lt;/th&gt;
&lt;th&gt;Full Recall&lt;/th&gt;
&lt;th&gt;평균 생성 시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview (no-web)&lt;/td&gt;
&lt;td&gt;28/31 (90.3%)&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;14.7초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (no-web)&lt;/td&gt;
&lt;td&gt;29/31 (93.5%)&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;11.7초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (web)&lt;/td&gt;
&lt;td&gt;27/31 (87.1%)&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;td&gt;129.1초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;GPT-5.4 no-web이 가장 좋았다. 2-pass selector의 최고 성적(29/31, 9/11)과 동일한 recall을 1회 호출로 달성했고, 평균 11.7초였다. 남은 miss는 Q6, Q7뿐이었는데, 이 둘은 애초에 Top-50 검색 결과에 정답이 없는 retrieval miss였다.&lt;/p&gt;
&lt;h3&gt;Holdout 16문항 결과&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;도메인 커버&lt;/th&gt;
&lt;th&gt;평균 생성 시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (no-web)&lt;/td&gt;
&lt;td&gt;14/16&lt;/td&gt;
&lt;td&gt;9.1초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview (no-web)&lt;/td&gt;
&lt;td&gt;14/16&lt;/td&gt;
&lt;td&gt;12.7초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash Preview (web)&lt;/td&gt;
&lt;td&gt;14/16&lt;/td&gt;
&lt;td&gt;13.8초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (web)&lt;/td&gt;
&lt;td&gt;14/16&lt;/td&gt;
&lt;td&gt;114.9초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Holdout에서도 GPT-5.4 no-web이 속도와 품질 모두에서 가장 나은 균형을 보였다.&lt;/p&gt;
&lt;h2&gt;웹 검색 보조의 효과: 기대와 다른 결과&lt;/h2&gt;
&lt;p&gt;웹 검색을 붙이면 retrieval miss를 보완할 수 있을 거라 기대했다. 결과는 정반대였다.&lt;/p&gt;
&lt;p&gt;GPT-5.4에 웹 검색을 붙이자 Core 성능이 오히려 떨어졌다. Selection Recall이 93.5%에서 87.1%로 하락했고, latency는 11.7초에서 129.1초로 11배 증가했다. Q2와 Q11에서 새로운 miss가 발생했다. Gemini 3 Flash Preview의 웹 검색 버전은 Q5에서 반복적으로 timeout이 발생해 전체 결과를 안정적으로 수집하지도 못했다.&lt;/p&gt;
&lt;p&gt;Holdout에서도 웹 검색 유무에 관계없이 도메인 커버는 14/16으로 동일했다. 웹 검색이 retrieval miss를 자동으로 메우지 않았다.&lt;/p&gt;
&lt;h2&gt;GPT-5.4 Thinking과 Perplexity Sonar 추가 실험&lt;/h2&gt;
&lt;p&gt;추가로 두 가지 변형을 더 시도했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GPT-5.4 reasoning high&lt;/strong&gt;: 품질은 유지되었다(Core Q2, Q11 모두 해결). 하지만 평균 생성 시간이 116.1초로 치솟았다. 기존 no-web(11.7초) 대비 품질 이득은 거의 없으면서 latency만 10배 증가했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Perplexity Sonar (web)&lt;/strong&gt;: Core 평균 9.4초, Holdout 평균 6.2초로 속도는 빨랐다. 하지만 Q2에서 selection miss가 남았다. 빠른 web-assist 후보로는 가치가 있지만, hard case 품질은 GPT-5.4 no-web에 못 미쳤다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;Core (Q2, Q11)&lt;/th&gt;
&lt;th&gt;Holdout (H2, H8, H16)&lt;/th&gt;
&lt;th&gt;평균 생성 시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 no-web&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;11.7초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 reasoning high&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;116.1초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity Sonar web&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;6.2~9.4초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;결론: Selector 실험에서 확인한 것&lt;/h2&gt;
&lt;p&gt;이 실험의 결론을 정리한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;구조가 모델보다 먼저다.&lt;/strong&gt; 2-pass selector는 약한 모델(Gemini 2.5 Flash Lite)에서도 selection miss를 줄여줬다. 반면 single selector는 강한 모델(GPT-5 mini)에서도 품질이 떨어졌다. 호출 구조를 어떻게 설계하느냐가 모델 선택보다 영향이 컸다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;강한 모델은 구조를 단순화할 수 있다.&lt;/strong&gt; GPT-5.4는 all-in-one 1회 호출로 2-pass selector의 최고 성적을 재현했다. 모델이 충분히 강하면 복잡한 다단계 구조 없이도 같은 품질을 낼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;웹 검색은 만능이 아니다.&lt;/strong&gt; 검색 보조를 붙인다고 retrieval miss가 해결되지 않았다. 오히려 latency만 크게 늘고 기존에 잘 되던 것까지 흔들렸다. 웹 검색은 별도 경로로 분리해서, 정말 필요한 경우에만 선택적으로 태워야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실전 기본값은 단순하게.&lt;/strong&gt; 최종적으로 실전 기본값 후보는 GPT-5.4 no-web all-in-one이 되었다. Core 29/31(93.5%), Holdout 14/16, 평균 11초 내외. 남은 miss는 selector가 아니라 retrieval 축에서 풀어야 할 문제였다.&lt;/p&gt;
&lt;p&gt;selector는 RAG 파이프라인에서 흔히 간과되는 단계다. 검색만 잘 되면 된다고 생각하기 쉽지만, 50개 후보에서 진짜 근거를 골라내는 일은 그 자체로 독립적인 문제다. 그리고 그 문제를 푸는 방법은 모델을 바꾸는 것만이 아니라, 호출 구조를 설계하는 것이다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (2) — 임베딩 모델 5종 벤치마크: 법률 도메인 실전 비교</title><link>https://blog.dongjun.win/posts/legal-ai-search-02-embedding-model-benchmark/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-02-embedding-model-benchmark/</guid><description>한국 법률 조문 검색을 기준으로 5개 임베딩 모델의 검색 품질, 속도와 비용을 비교해 모델 선택 과정을 정리합니다.</description><pubDate>Mon, 06 Apr 2026 06:29:58 GMT</pubDate><content:encoded>&lt;p&gt;법률 RAG 시스템에서 가장 먼저 결정해야 하는 것은 &quot;어떤 임베딩 모델을 쓸 것인가&quot;다. MTEB 리더보드 점수가 높다고 해서 우리 도메인에서도 잘 동작하리라는 보장은 없다. 한국 법률 조문이라는 특수한 코퍼스 위에서, 실제 질문셋으로 직접 비교하는 것이 유일한 방법이다.&lt;/p&gt;
&lt;p&gt;이 글에서는 임베딩 모델 5종을 동일 조건에서 평가한 과정과 결과를 공유한다. 모델 선택 하나가 retrieval 성능의 천장을 결정한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;평가 대상: 임베딩 모델 5종&lt;/h2&gt;
&lt;p&gt;평가에 사용한 모델은 다음 5종이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;파라미터&lt;/th&gt;
&lt;th&gt;벡터 차원&lt;/th&gt;
&lt;th&gt;개발사&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;568M&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;BAAI&lt;/td&gt;
&lt;td&gt;다국어, dense/sparse/multi-vector 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-0.6b&lt;/td&gt;
&lt;td&gt;0.6B&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;경량 임베딩, INT8 네이티브&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-4b&lt;/td&gt;
&lt;td&gt;4B&lt;/td&gt;
&lt;td&gt;2560&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;대형 임베딩, MTEB 최상위권&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;0.6B&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;문서 문맥 인식 경량 모델&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-4b&lt;/td&gt;
&lt;td&gt;4B&lt;/td&gt;
&lt;td&gt;2560&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;문서 문맥 인식 대형 모델&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;BGE-M3는 BAAI에서 공개한 다국어 임베딩 모델로, XLM-RoBERTa 기반이며 8192 토큰까지 처리할 수 있다. dense, sparse, multi-vector 세 가지 retrieval 방식을 동시에 지원하는 것이 특징이다.&lt;/p&gt;
&lt;p&gt;Perplexity의 pplx-embed 시리즈는 2종 x 2사이즈, 총 4개 모델로 구성된다. &lt;code&gt;pplx-embed-v1&lt;/code&gt;은 표준 dense retrieval용이고, &lt;code&gt;pplx-embed-context-v1&lt;/code&gt;은 문서 수준 문맥을 반영하여 청크를 임베딩하는 contextual embedding 모델이다. context 모델은 인덱싱 시에만 사용하고, 쿼리 임베딩에는 표준 v1을 사용하는 비대칭 구조를 갖는다. Qwen3 기반으로 학습되었으며, quantization-aware training을 통해 INT8 임베딩을 네이티브로 생성한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;평가 환경&lt;/h2&gt;
&lt;h3&gt;코퍼스&lt;/h3&gt;
&lt;p&gt;한국 주요 기본법(민법, 형법, 상법, 민사소송법, 형사소송법, 헌법, 행정소송법 등)에서 추출한 &lt;strong&gt;약 2,300여 개 조문&lt;/strong&gt;을 코퍼스로 사용했다. 5개 모델 모두 정확히 동일한 문서 세트로 벡터 DB 컬렉션을 구성했다. 데이터 정합화 작업을 거쳐, 모든 컬렉션의 문서 수와 구조가 일치하는 것을 사전 검증했다.&lt;/p&gt;
&lt;h3&gt;질문셋&lt;/h3&gt;
&lt;p&gt;총 84개 질문을 다섯 유형으로 나누어 평가했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문 유형&lt;/th&gt;
&lt;th&gt;문항 수&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;단일정답 직접 질문&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;특정 조문을 직접 묻는 질문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;단일정답 시나리오 질문&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;실생활 시나리오로 우회하여 묻는 질문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;단일정답 세법 질문&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;세법 도메인 특화 질문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복수정답 질문&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;여러 조문이 정답인 질문 (정답 조문 31개)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holdout 질문&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;도메인 커버리지 확인용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;복수정답 질문이 가장 중요한 평가 축이다. 단일정답은 대부분의 모델이 쉽게 맞추지만, 여러 조문을 빠짐없이 찾아와야 하는 복수정답에서 모델 간 차이가 극명하게 드러났다.&lt;/p&gt;
&lt;h3&gt;비교 조건&lt;/h3&gt;
&lt;p&gt;두 가지 모드로 비교했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;raw&lt;/strong&gt;: 사용자 질문 원문 그대로 검색. vector hybrid (dense + BM42 sparse) 사용.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;prerewrite&lt;/strong&gt;: LLM 프리라이터로 질문을 변환한 뒤 검색. vector + graph 검색을 RRF로 병합.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;추가로, graph 검색을 완전히 제외한 &lt;strong&gt;vector-only&lt;/strong&gt; 조건도 별도 실험했다.&lt;/p&gt;
&lt;h3&gt;메트릭&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;@K recall&lt;/strong&gt;: K개 결과 안에 정답 조문이 몇 개 포함되었는가&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full match&lt;/strong&gt;: 복수정답 질문에서, 한 질문의 정답 조문을 모두 찾았는가 (11문항 중 몇 문항 완전 적중)&lt;/li&gt;
&lt;li&gt;K = 10, 20, 50으로 측정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;결과 1: Hybrid 검색 (dense + BM42 + graph)&lt;/h2&gt;
&lt;p&gt;복수정답 @50 기준이 모델 선택의 핵심 지표였다.&lt;/p&gt;
&lt;h3&gt;복수정답 @50 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;Raw Recall&lt;/th&gt;
&lt;th&gt;Raw Full&lt;/th&gt;
&lt;th&gt;Raw 속도&lt;/th&gt;
&lt;th&gt;Pre Recall&lt;/th&gt;
&lt;th&gt;Pre Full&lt;/th&gt;
&lt;th&gt;Pre 속도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;103ms&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;1,536ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-v1-0.6b&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;322ms&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;1,700ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pplx-embed-v1-4b&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;319ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,708ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-0.6b&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;323ms&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;1,712ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-embed-context-v1-4b&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;332ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,714ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;가 raw 모드에서 이미 30/31 recall, 10/11 full match를 달성했다. 프리라이터를 붙여도 결과가 동일했다. 즉 이 모델은 질문 원문만으로도 충분히 강력한 retrieval을 보여준 것이다.&lt;/p&gt;
&lt;p&gt;반면 BGE-M3와 0.6b 모델들은 @50에서 28/31에 머물렀고, 프리라이터를 적용해야 29/31까지 올라갔다.&lt;/p&gt;
&lt;h3&gt;단일정답 요약&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문 유형&lt;/th&gt;
&lt;th&gt;핵심 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;직접 질문 20&lt;/td&gt;
&lt;td&gt;전 모델 raw @10 = 20/20. 프리라이터 적용 시 오히려 @10이 19/20으로 하락하는 경우 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시나리오 질문 20&lt;/td&gt;
&lt;td&gt;4b 모델 2종은 raw @10 = 20/20. BGE-M3 raw @10 = 18/20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;세법 질문 17&lt;/td&gt;
&lt;td&gt;BGE-M3만 raw @10 = 16/17, 나머지 4종은 17/17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;단일정답에서는 대부분의 모델이 높은 성능을 보였지만, 시나리오 질문과 세법 질문에서 4b 모델의 우위가 확인되었다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;결과 2: Vector-Only 검색 (graph 제외)&lt;/h2&gt;
&lt;p&gt;graph 검색 없이 순수 벡터 검색만으로 평가한 결과도 동일한 결론을 가리켰다.&lt;/p&gt;
&lt;h3&gt;복수정답 Vector-Only 비교 (주요 K 값)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;@20&lt;/th&gt;
&lt;th&gt;F@20&lt;/th&gt;
&lt;th&gt;@30&lt;/th&gt;
&lt;th&gt;F@30&lt;/th&gt;
&lt;th&gt;@50&lt;/th&gt;
&lt;th&gt;F@50&lt;/th&gt;
&lt;th&gt;속도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;21/31&lt;/td&gt;
&lt;td&gt;4/11&lt;/td&gt;
&lt;td&gt;23/31&lt;/td&gt;
&lt;td&gt;5/11&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;113ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;pre&lt;/td&gt;
&lt;td&gt;21/31&lt;/td&gt;
&lt;td&gt;5/11&lt;/td&gt;
&lt;td&gt;22/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;27/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;1,291ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-0.6b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;24/31&lt;/td&gt;
&lt;td&gt;5/11&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;336ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pplx-v1-4b&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;raw&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;323ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-v1-4b&lt;/td&gt;
&lt;td&gt;pre&lt;/td&gt;
&lt;td&gt;24/31&lt;/td&gt;
&lt;td&gt;5/11&lt;/td&gt;
&lt;td&gt;27/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;td&gt;29/31&lt;/td&gt;
&lt;td&gt;9/11&lt;/td&gt;
&lt;td&gt;1,501ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-ctx-v1-0.6b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;23/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;24/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;6/11&lt;/td&gt;
&lt;td&gt;319ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pplx-ctx-v1-4b&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;td&gt;27/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;td&gt;326ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;여기서 주목할 점이 두 가지 있다.&lt;/p&gt;
&lt;p&gt;첫째, &lt;strong&gt;vector-only에서도 &lt;code&gt;pplx-embed-v1-4b raw&lt;/code&gt;가 @50 = 30/31로 최고 성능&lt;/strong&gt;이었다. graph 검색 없이도 이 모델의 벡터 품질 자체가 우수하다는 뜻이다.&lt;/p&gt;
&lt;p&gt;둘째, &lt;strong&gt;프리라이터가 vector-only에서는 오히려 성능을 깎는 경우가 있었다&lt;/strong&gt;. &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;는 프리라이터 적용 시 30/31에서 29/31로 하락했고, BGE-M3도 28/31에서 27/31로 떨어졌다. 프리라이터가 graph 검색과 결합될 때는 도움이 되지만, 벡터 단독 검색에서는 오히려 원래 질문의 의미를 왜곡할 수 있다는 신호였다.&lt;/p&gt;
&lt;h3&gt;K값에 따른 성능 변화&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pplx-embed-v1-4b raw&lt;/code&gt;의 복수정답 recall 변화를 K값별로 보면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;K&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;Full Match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;19/31&lt;/td&gt;
&lt;td&gt;2/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;25/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;26/31&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;28/31&lt;/td&gt;
&lt;td&gt;8/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;30/31&lt;/td&gt;
&lt;td&gt;10/11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;K20과 K50 사이에 정보 손실이 상당히 크다. K20에서 25/31이던 recall이 K50에서 30/31까지 올라간다. &quot;프리라이터로 K를 줄일 수 있다&quot;는 가설은 이번 실험에서 지지되지 않았고, 복수정답 시나리오에서는 충분한 K가 확보되어야 한다는 결론에 이르렀다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;속도 비교&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;Raw 평균&lt;/th&gt;
&lt;th&gt;Prerewrite 평균&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;~103ms&lt;/td&gt;
&lt;td&gt;~1,536ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity 계열 4종&lt;/td&gt;
&lt;td&gt;~309-342ms&lt;/td&gt;
&lt;td&gt;~1,400-1,714ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;BGE-M3가 raw 기준 약 3배 빠르다. 다만 prerewrite를 적용하면 LLM 호출 비용이 지배적이 되면서 모델 간 속도 차이가 줄어든다. raw 모드에서 Perplexity 계열은 약 320ms 수준으로, 실서비스에서 충분히 사용 가능한 범위다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;최종 선택: pplx-embed-v1-4b&lt;/h2&gt;
&lt;p&gt;종합적으로 &lt;code&gt;pplx-embed-v1-4b&lt;/code&gt;를 retrieval 기본 모델로 선택했다. 이유는 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 복수정답 retrieval 최고 성능&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;가장 까다로운 복수정답 @50에서 30/31 recall, 10/11 full match. hybrid든 vector-only든 동일하게 최상위였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 프리라이터 없이도 강력한 성능&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;다른 모델들은 프리라이터를 붙여야 성능이 올라갔지만, 이 모델은 raw 질문만으로 이미 최고 수준에 도달했다. 이는 파이프라인을 단순하게 유지할 수 있다는 실질적인 장점이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 단일정답에서도 회귀 없음&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;직접 질문, 시나리오 질문, 세법 질문 모두에서 최상위 또는 동률. 복수정답에서 강하다고 해서 단일정답이 약해지는 트레이드오프가 없었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 허용 가능한 지연 시간&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;raw 기준 약 320ms. BGE-M3보다는 느리지만, 법률 QA 서비스의 응답 시간 예산 안에 충분히 들어온다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;이 실험에서 확인한 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;벤치마크 점수와 도메인 성능은 다르다.&lt;/strong&gt; MTEB 리더보드에서의 순위가 한국 법률 도메인에서의 순위를 보장하지 않는다. 직접 평가 외에 지름길은 없다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;데이터 정합화가 공정한 비교의 전제 조건이다.&lt;/strong&gt; 5개 컬렉션의 문서 수가 불일치하는 상태에서는 비교 자체가 무의미하다. 2,336개로 맞추는 작업이 평가보다 먼저 수행되어야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;프리라이터는 만능이 아니다.&lt;/strong&gt; &quot;질문을 다듬으면 무조건 좋아진다&quot;는 직관과 달리, 특정 모델/조건에서는 오히려 성능이 하락했다. 특히 vector-only 검색에서 이 현상이 두드러졌다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;복수정답이 진짜 변별력이다.&lt;/strong&gt; 단일정답은 대부분의 모델이 쉽게 맞추므로 모델을 구분하기 어렵다. 여러 조문을 동시에 찾아야 하는 복수정답 시나리오가 실질적인 벤치마크다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;K값은 넉넉하게.&lt;/strong&gt; 복수정답에서 K20과 K50 사이의 정보 손실이 크다. K50을 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;법률 RAG의 핵심 파이프라인에서, 임베딩 모델 선택은 모든 후속 단계의 성능 상한을 결정하는 첫 번째 관문이다. 이 단계에서의 비교 실험은 생략할 수 없다.&lt;/p&gt;
</content:encoded></item><item><title>법률 AI 검색 실험기 (1) — 벡터 검색이 실패하는 이유</title><link>https://blog.dongjun.win/posts/legal-ai-search-01-why-vector-search-fails/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/legal-ai-search-01-why-vector-search-fails/</guid><description>법률 RAG에서 단순 벡터 검색이 복수 근거와 조문 관계를 놓치는 원인을 실제 실험 결과와 함께 분석합니다.</description><pubDate>Mon, 06 Apr 2026 06:25:56 GMT</pubDate><content:encoded>&lt;h2&gt;도입: 법률 QA를 만들면서 마주한 첫 번째 벽&lt;/h2&gt;
&lt;p&gt;법률 질의응답 시스템을 만드는 일은, 처음에는 RAG(Retrieval-Augmented Generation)의 교과서적 응용처럼 보였습니다. 법 조문을 임베딩해서 벡터 DB에 넣고, 사용자 질문과 유사한 조문을 검색한 뒤, LLM이 답변을 생성하면 되니까요.&lt;/p&gt;
&lt;p&gt;실제로 단일 정답 질문 -- &quot;주택임대차보호법상 대항력은 언제 취득하나요?&quot; 같은 -- 에는 이 방식이 잘 작동했습니다. 해당 조문과 질문의 텍스트 유사도가 높기 때문입니다.&lt;/p&gt;
&lt;p&gt;문제는 현실의 법률 질문이 그렇게 단순하지 않다는 데서 시작됩니다. &quot;부당해고 당했는데 어떻게 하나요?&quot;라는 질문에 정확히 답하려면 근로기준법 23조(해고제한), 26조(해고예고), 28조(구제신청) 세 조문이 모두 필요합니다. 그런데 벡터 검색은 보통 28조(구제신청)만 찾고, 나머지 두 조문은 Top-50에도 들어오지 않았습니다.&lt;/p&gt;
&lt;p&gt;이 글은 저희가 법률 QA 시스템을 만들면서 벡터 검색의 구조적 한계를 발견하고, 그 원인을 분석한 과정을 다룹니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;문제 정의: 복수 정답 질문이란&lt;/h2&gt;
&lt;p&gt;법률 상담에서 사용자가 던지는 질문은 대부분 하나의 조문으로 답할 수 없습니다. 하나의 상황에 여러 법률 조항이 얽혀 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;저희는 이런 질문을 &quot;복수 정답 질문&quot;이라고 정의하고, 정답이 2~4개 조문인 11개 평가 문항을 설계했습니다. 예를 들면 이런 것들입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;부당해고 당했는데 어떻게 하나요?&quot; -- 정답: 해고제한(23조), 해고예고(26조), 구제신청(28조)&lt;/li&gt;
&lt;li&gt;&quot;세금을 과다하게 부과한 것 같아요&quot; -- 정답: 경정청구(45조의2), 불복(55조), 청구기간(61조)&lt;/li&gt;
&lt;li&gt;&quot;양도차익이 4억인데 세금이 얼마나?&quot; -- 정답: 양도소득 범위(94조), 장기보유공제(95조), 세율(104조)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 질문들의 공통점은, 사용자가 명시적으로 언급하지 않은 조문이 정답에 포함되어 있다는 점입니다. &quot;부당해고 어떻게 하나요&quot;라는 질문에는 &quot;해고예고&quot;라는 단어가 없고, &quot;세금이 과다하다&quot;는 말에는 &quot;심판청구&quot;라는 단어가 없습니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;실제 데이터: 복수 정답 11문항 평가 결과&lt;/h2&gt;
&lt;p&gt;저희는 여러 임베딩 모델을 비교 평가했습니다. 핵심 지표는 두 가지입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Article Recall@K&lt;/strong&gt;: 전체 정답 조문 31개 중 Top-K 안에 들어온 수&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Question Full Recall@K&lt;/strong&gt;: 11개 질문 중 모든 정답 조문이 Top-K 안에 들어온 질문 수&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;초기 평가에서 가장 성능이 좋았던 bge-m3 모델의 결과입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;@5&lt;/th&gt;
&lt;th&gt;@10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Article Recall&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;14/31 (45%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Question Full Recall&lt;/td&gt;
&lt;td&gt;0/11&lt;/td&gt;
&lt;td&gt;1/11 (9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Top-10 기준으로 정답 조문의 절반도 찾지 못했고, 11개 질문 중 모든 정답을 다 찾은 문항은 단 1개뿐이었습니다.&lt;/p&gt;
&lt;p&gt;이후 임베딩 모델을 교체하고 Top-K를 50까지 확대한 최고 baseline(pplx-embed-v1-4b)에서는 상황이 많이 개선되었습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;@50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Article Recall&lt;/td&gt;
&lt;td&gt;30/31 (97%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Question Full Recall&lt;/td&gt;
&lt;td&gt;10/11 (91%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고&lt;/strong&gt;: 이 수치는 초기 평가 기준 기준이다. 이후 multi-2 문항의 정답 정의를 재검토하면서(민법 766조 → 756조) benchmark를 수정했고, 최종적으로는 graph 검색과 결합하여 31/31, 11/11을 달성했다. 이 과정은 이후 &quot;1차 아키텍처 확정: 실험에서 운영으로&quot; 편에서 자세히 다룬다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Top-50까지 확대하니 대부분의 조문을 회수할 수 있었습니다. 하지만 여전히 놓치는 조문이 있었고, 더 중요한 것은 Top-50이라는 범위 자체가 실용적이지 않다는 점입니다. 50개 조문을 LLM에 넘기는 것은 비용과 레이턴시 면에서 부담이 크고, 노이즈가 많아지면 LLM의 선별 정확도도 떨어집니다.&lt;/p&gt;
&lt;p&gt;End-to-end 기준 최고 성능은 &lt;code&gt;gpt-5.4 no-web all-in-one&lt;/code&gt; 조합으로, Selection Recall 29/31, Full Recall 9/11을 기록했습니다. 하지만 이 수치도 결국 retrieval 단계에서 후보군에 포함되지 않은 조문은 아무리 좋은 LLM을 써도 찾을 수 없다는 한계를 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;핵심 병목은 LLM 선별(selection)이 아니라 검색(retrieval) 자체&lt;/strong&gt;였습니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;벡터 검색은 왜 실패하는가&lt;/h2&gt;
&lt;p&gt;벡터 검색의 원리는 &quot;텍스트가 의미적으로 비슷하면 벡터 공간에서 가까이 위치한다&quot;는 전제에 기반합니다. 이 전제는 많은 경우에 유효하지만, 법률 도메인에서는 구조적으로 맞지 않는 상황이 자주 발생합니다.&lt;/p&gt;
&lt;h3&gt;의미적 유사도와 논리적 관계의 괴리&lt;/h3&gt;
&lt;p&gt;질문별로 벡터 검색이 놓친 조문을 분석하면 명확한 패턴이 보입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문&lt;/th&gt;
&lt;th&gt;찾은 조문&lt;/th&gt;
&lt;th&gt;놓친 조문&lt;/th&gt;
&lt;th&gt;놓친 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;계약해제 원상회복+손배&lt;/td&gt;
&lt;td&gt;548조(해제효과)&lt;/td&gt;
&lt;td&gt;551조(해제와 손배)&lt;/td&gt;
&lt;td&gt;같은 제도의 다른 효과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;불법행위+사용자배상&lt;/td&gt;
&lt;td&gt;750조(불법행위)&lt;/td&gt;
&lt;td&gt;756조(사용자책임)&lt;/td&gt;
&lt;td&gt;같은 불법행위의 책임 귀속&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대항력+소액임차인&lt;/td&gt;
&lt;td&gt;8조(소액임차인)&lt;/td&gt;
&lt;td&gt;3조(대항력)&lt;/td&gt;
&lt;td&gt;전제조건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해고사유+예고+구제&lt;/td&gt;
&lt;td&gt;28조(구제신청)&lt;/td&gt;
&lt;td&gt;23조, 26조&lt;/td&gt;
&lt;td&gt;절차 체인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;경정청구+이의+심판&lt;/td&gt;
&lt;td&gt;45의2(경정청구)&lt;/td&gt;
&lt;td&gt;55조, 61조&lt;/td&gt;
&lt;td&gt;불복 절차 체인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;양도소득+공제+세율&lt;/td&gt;
&lt;td&gt;95조(장기보유공제)&lt;/td&gt;
&lt;td&gt;94조, 104조&lt;/td&gt;
&lt;td&gt;계산 체인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;벡터 검색이 놓치는 조문들은 질문과 &lt;strong&gt;텍스트가 안 닮았지만 논리적으로는 반드시 필요한&lt;/strong&gt; 조문들입니다. &quot;부당해고 당했는데 어떻게 하나요&quot;라는 질문과 &quot;해고예고&quot; 조문 사이에는 텍스트 유사도가 낮습니다. 하지만 해고 절차를 이해하는 사람이라면 이 둘이 연결되어 있다는 걸 압니다.&lt;/p&gt;
&lt;p&gt;임베딩 모델은 아무리 좋아도 텍스트를 고차원 벡터로 압축하는 과정에서 이런 논리적 관계 정보를 잃어버립니다. 이것은 모델의 성능 문제가 아니라 bi-encoder 아키텍처의 구조적 한계입니다.&lt;/p&gt;
&lt;h3&gt;다섯 가지 miss 패턴&lt;/h3&gt;
&lt;p&gt;놓친 조문들을 관통하는 관계 유형을 정리하면 다섯 가지로 분류됩니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;절차 체인(PROCEDURE_CHAIN)&lt;/strong&gt;: 같은 법적 절차의 단계들. 해고 제한 -&amp;gt; 해고 예고 -&amp;gt; 구제 신청처럼, 하나의 절차를 구성하는 조문들이 흩어져 있는 경우.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;계산 체인(CALCULATION_CHAIN)&lt;/strong&gt;: 같은 세금이나 금액 계산의 구성요소. 소득 정의 -&amp;gt; 공제 -&amp;gt; 세율처럼, 하나의 계산 흐름에 속하지만 각각 별도의 조문인 경우.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;전제조건(PREREQUISITE)&lt;/strong&gt;: A 권리가 성립하려면 B 조건이 필요한 경우. 갱신청구권을 행사하려면 먼저 대항력을 갖추어야 하는 것처럼.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;같은 원인의 다른 효과(EFFECT_OF)&lt;/strong&gt;: 계약 해제라는 같은 원인에서 원상회복과 손해배상이라는 다른 효과가 나오는 경우.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;기간/제한(LIMITATION)&lt;/strong&gt;: 권리에 딸린 기간 제한. 임차권의 존속기간이나 갱신청구 기한처럼.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 다섯 패턴은 모두 &lt;strong&gt;&quot;텍스트로는 안 닮았지만 논리적으로 연결된&quot;&lt;/strong&gt; 관계입니다. 벡터 검색이 원리적으로 포착하기 어려운 종류의 관계이며, 이것이 법률 도메인에서 벡터 검색만으로는 충분하지 않은 근본적인 이유입니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;업계에서도 같은 문제를 겪고 있다&lt;/h2&gt;
&lt;p&gt;이 문제는 저희만 겪는 것이 아닙니다. 최근 RAG 연구에서 벡터 검색의 멀티홉 추론 한계는 핵심 연구 주제로 부상하고 있습니다.&lt;/p&gt;
&lt;p&gt;2025년 ACM TKDD에 게재된 멀티홉 QA 연구에서는 기존의 반복적 검색(iterative retrieval) 방법이 검색 횟수가 늘어날수록 원래 추론 경로에서 벗어나는 &quot;쿼리 드리프트&quot; 문제를 보고했습니다. 저희가 쿼리 분해(query decomposition)를 시도했을 때 경험한 것과 정확히 같은 현상입니다 -- 분해된 서브쿼리가 원래 의미에서 드리프트하면서 단일 정답 문항의 성능이 20/20에서 16/20으로 급락했습니다.&lt;/p&gt;
&lt;p&gt;2025년 발표된 HopRAG 논문(arXiv:2502.12442)은 벡터 유사도 기반 검색이 논리적 관계를 포착하지 못하는 한계를 지적하며, 그래프 기반 검색으로 멀티홉 추론을 지원하는 방법을 제안했습니다. 또한 인도의 법률 AI를 다룬 Domain-Partitioned Hybrid RAG 연구(arXiv:2602.23371)는 법률 코퍼스를 판례, 법령, 헌법으로 분리한 뒤 Neo4j 기반 법률 지식 그래프로 관계형 쿼리와 멀티홉 추론을 지원하는 아키텍처를 제안했는데, 이는 저희가 독립적으로 도달한 결론과 놀라울 정도로 유사합니다.&lt;/p&gt;
&lt;p&gt;결국 업계 전반에서 &quot;벡터 검색만으로는 부족하다&quot;는 공감대가 형성되고 있으며, 특히 법률처럼 조문 간 논리적 관계가 핵심인 도메인에서는 그래프 기반 확장이 사실상 필수적인 보완재로 논의되고 있습니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;시도했지만 부족했던 것들&lt;/h2&gt;
&lt;p&gt;벡터 검색의 한계를 우회하기 위해 몇 가지 방법을 시도했습니다.&lt;/p&gt;
&lt;h3&gt;쿼리 분해 (Query Decomposition)&lt;/h3&gt;
&lt;p&gt;질문을 여러 서브쿼리로 분해한 뒤 각각 검색하고 RRF(Reciprocal Rank Fusion)로 병합하는 방식입니다. 결과는 복수 정답에서 소폭 개선(+2~3 Q.Full)이 있었지만, &lt;strong&gt;단일 정답 문항에서 심각한 성능 저하&lt;/strong&gt;가 발생했습니다. pplx-embed-v1-4b 모델은 세법 문항에서 17/17이 0/17으로 완전히 무너졌습니다. 분해된 서브쿼리가 원래 맥락에서 벗어나는 쿼리 드리프트가 원인이었습니다.&lt;/p&gt;
&lt;h3&gt;Agentic RAG&lt;/h3&gt;
&lt;p&gt;LLM이 검색 결과를 보고, 부족하다고 판단하면 재검색하는 방식입니다. 이론적으로는 매력적이지만 근본적인 순환 논리가 있습니다. &quot;결과가 충분한지&quot; 판단하려면 이미 법률 지식이 있어야 합니다. 게다가 비용과 레이턴시가 3~10배 증가합니다.&lt;/p&gt;
&lt;h3&gt;Co-citation 기반 확장&lt;/h3&gt;
&lt;p&gt;판례에서 함께 인용된 조문을 확장하는 방식입니다. 저희 MongoDB에는 판례 73,032건에서 추출한 158,152건의 법조문 참조 데이터가 이미 있었습니다. 실제로 근로기준법 28조(구제신청)에서 출발하면, 관련 판례 177건을 거쳐 23조(해고제한)가 148회 공출현하는 것을 확인할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;하지만 데이터 폭발 문제가 심각했습니다. 민법 750조(불법행위)처럼 범용 조항 하나만 걸리면 관련 판례가 2,173건, 공출현 조문이 1,307개로 폭발합니다. 또한 co-citation은 통계적 상관관계이지 논리적 관계가 아닙니다. 28조와 26조(해고예고)는 판례에서 함께 인용되는 빈도가 낮지만, 논리적으로는 해고 절차의 핵심 구성요소입니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;다음 글 예고&lt;/h2&gt;
&lt;p&gt;이 글에서 확인한 것은 명확합니다. 법률 QA에서 벡터 검색은 필요하지만 충분하지 않습니다. 텍스트 유사도로는 포착할 수 없는 논리적 관계 -- 절차 체인, 계산 체인, 전제조건, 기간 제한 -- 가 법률 질의응답의 정확도를 결정합니다.&lt;/p&gt;
&lt;p&gt;이 &lt;strong&gt;법률 AI 검색 실험기&lt;/strong&gt; 시리즈에서는 이 문제를 실제로 어떻게 풀어갔는지를 다룹니다. 임베딩 모델 벤치마크, LLM selector 비교, query rewriting, Graph RAG 도입, 멀티 컬렉션 라우팅까지 — 한국 법률 도메인에서 RAG를 운영 가능한 수준까지 끌어올리는 과정을 한 편씩 기록할 예정입니다.&lt;/p&gt;
&lt;p&gt;다음 편에서는 법률 도메인에 맞는 임베딩 모델을 어떻게 골랐는지, 5종 모델을 직접 비교한 실험 결과를 다룹니다.&lt;/p&gt;
&lt;p&gt;법률 QA를 만들면서 배운 것은, 검색 시스템의 정확도는 임베딩 모델의 성능이 아니라 &quot;어떤 종류의 관계를 포착할 수 있느냐&quot;에 달려 있다는 것입니다. 벡터 검색이 잘하는 것(텍스트 유사도)과 법률 도메인이 요구하는 것(논리적 관계) 사이의 간극을 메우는 것이 이 시리즈의 주제입니다.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;참고 자료:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2502.12442&quot;&gt;HopRAG: Multi-Hop Reasoning for Logic-Aware Retrieval-Augmented Generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/html/2602.23371v1&quot;&gt;Domain-Partitioned Hybrid RAG for Legal Reasoning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dl.acm.org/doi/10.1145/3789506&quot;&gt;Retrieval-Augmented Generation for Multi-Hop Question Answering Based on Structured Planning (ACM TKDD)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.freecodecamp.org/news/how-to-solve-5-common-rag-failures-with-knowledge-graphs/&quot;&gt;How to Solve 5 Common RAG Failures with Knowledge Graphs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://towardsdatascience.com/vector-search-is-not-all-you-need-ecd0f16ad65e/&quot;&gt;Vector Search Is Not All You Need (Towards Data Science)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://neo4j.com/blog/genai/advanced-rag-techniques/&quot;&gt;Advanced RAG Techniques (Neo4j)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>CTO로 한해를 보내며.. (2019 회고)</title><link>https://blog.dongjun.win/posts/cto-2019/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/cto-2019/</guid><description>2019년 CTO 역할을 맡으며 겪은 조직 운영, 신뢰, 채용, 의사결정과 리더십의 시행착오를 돌아본 회고입니다.</description><pubDate>Tue, 28 Jan 2020 05:19:00 GMT</pubDate><content:encoded>&lt;p&gt;2020년 설이 지나서야 2019년 회고의 글을 씁니다. 목차를 만들어서 하나씩 회고를 하면서 써나갈까 합니다. 2019년은 정말 다사다난했습니다. 큰일들을 위주로 회고를 시작할까 합니다.&lt;/p&gt;
&lt;h2&gt;CTO가 되다..&lt;/h2&gt;
&lt;p&gt;ITAM GAMES는 2018년에 시니어 개발자로 입사하게 되었습니다. 개발자로서 만족하면서 개발 일을 프런트, 백앤드를 가리지 않고 개발을 했습니다. 2018년 말 전 CTO님께서 회사를 퇴사하면서 저희 대표님은 저에게 CTO 직을 제시하였고, 저는 거절했습니다. 하지만 결국은 2019년 1월.. CTO를 맡게 되었습니다. 총인원 약 60명 되는 회사의 CTO는 저에게 있어서 아직 부담으로 다가왔었습니다. 물론 개발팀만 50~60명은 아닙니다.
CTO를 하면서 많은 일이 있었고, &lt;code&gt;이불킥&lt;/code&gt;을 할 정도로 부족했던 나의 모습, 하나하나의 결정에 따른 책임, 회사를 퇴사하겠다는 직원, 항상 무언가 새로운 것만 추구했던 사업부.. CTO는 정말 쉬운 직책이 아니고 편한 직책이 아님을 경험하고 있습니다. (현재도 CTO로 재직 중에 있습니다.)&lt;/p&gt;
&lt;h3&gt;나의 뜻대로 되지 않는 모든 것.&lt;/h3&gt;
&lt;p&gt;2019년 초에는 모든 것이 나의 뜻대로 되지 않았습니다. 팀원들은 나를 신뢰하지 않은 듯한 느낌을 받았고, 저의 진심을 알아주지 않은 팀원들에게 서운한 감정도 많이 들었습니다. 또한 개발일 정도 충분히 가능하리라 봤던 것들도 안되었습니다. 팀원들은 무언가 불만이 많았던 것처럼 보였으며, 나를 무시하는 느낌까지 받았었습니다. 사업부나 기획팀도 일정에 따른 압박을 주기 시작했고, 저는 CTO로서 첫 번째 고비가 다가왔었습니다.&lt;/p&gt;
&lt;h5&gt;모든 잘못은 저로부터..&lt;/h5&gt;
&lt;p&gt;2019년 초기에는 위에 써놓은 것처럼 모든 것이 마음대로 되지 않았고 그로 인한 쌈닭이 되어 가는 모습을 보게 되었습니다. 저를 바라보고 함께 일하고 싶어서 왔던 동료들인데 제가 변해 가는 모습을 몇 개월 후에나 알게 되었을 때 정말 고개를 들지 못할 정도로 미안함을 느끼게 되었습니다. 모든 것이 제가 미숙했던 거였습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;왜 나는 동료들에게 다가가려 하지 않았을까?&lt;/li&gt;
&lt;li&gt;먼저 신뢰를 얻으려고 하지 않을까?&lt;/li&gt;
&lt;li&gt;일에 대한 욕심을 왜 이렇게까지 냈을까..&lt;/li&gt;
&lt;li&gt;개발팀의 동료를 생각하지 않고 왜 회사의 입장만 고수하고 강요했을까?&lt;/li&gt;
&lt;li&gt;모든 기준을 나로 두고 생각했을까..&lt;/li&gt;
&lt;li&gt;팀원 각자가 특출난 부분이 있는데 왜 그걸 알아내지 못했으며, 알면서도 그에 맞게 활용을 못 했을까?&lt;/li&gt;
&lt;li&gt;나 스스로 노력하지 않으면서, 나를 따라와 주고 믿어주며, 함께 할 거라 생각을 했을까?&lt;/li&gt;
&lt;li&gt;동료의 말을 들으려고 하지 않을까?&lt;/li&gt;
&lt;li&gt;한쪽 말만 듣고 판단하려고 했을까?&lt;/li&gt;
&lt;li&gt;왜 한 달에 한 번씩 회고를 하지 못했을까..?&lt;/li&gt;
&lt;li&gt;감정적으로 행동을 했으며, 말을 함부로 했을까..?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위의 11가지 말고도 더 많은 잘못이 있었음을 너무 늦게 깨달았습니다. &apos;그때 그렇게가 아니고 이렇게 했으면 조금 더 좋은 결과가 있었을 텐데..&apos;라는 생각을 지금도 합니다.
그래도 늦게라도 회고하고 반성하며 아래와 같은 생각을 가지고 회사 생활을 하게 되었습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;팀원들과 함께 하는 시간을 늘려보자. (함께 산책하기, 농담 따먹기 하기 등등)&lt;/li&gt;
&lt;li&gt;내가 말하는 시간보다 듣는 시간을 늘려보자.&lt;/li&gt;
&lt;li&gt;무언가 일을 진행할 때 욕심을 부리지 말자.&lt;/li&gt;
&lt;li&gt;팀원 각자의 특출난 부분에 맞게 업무를 분배하고 나아가자.&lt;/li&gt;
&lt;li&gt;감정적으로 행동하지 말고, 말을 함부로 하지 말자.&lt;/li&gt;
&lt;li&gt;한쪽의 의견만 듣지 말고 양쪽의 의견을 듣고 판단하자.&lt;/li&gt;
&lt;li&gt;팀원들을 신뢰하고 일을 맡기자.&lt;/li&gt;
&lt;li&gt;업무에 대한 부족함이 있을 시에는 부족한 부분을 채워주도록 하는 게 나의 역활 중 하나라 생각하자.&lt;/li&gt;
&lt;li&gt;새롭게 알게 된 지식은 공유하자.&lt;/li&gt;
&lt;li&gt;한 달에 한 번쯤은 스스로 돌아보고 회고하자.&lt;/li&gt;
&lt;li&gt;짧은 인생, 안 좋은 소리보다 좋은 소리를 하자.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위의 11가지를 생각하면서 변화를 하게 되었고 지금 이 순간은 저희 개발팀은 서로 함께 늙어 가면서 평생 같이 보는 사이가 되었습니다. 더 크게 깨달은 것 중의 하나는 저는 정말 멘탈이 강한 줄 알았는데 맨탈이 강한 게 아니었다는 것을 느끼게 되는 2019년이었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;지금은 서로 술도 한잔하고 함께 진솔한 애기를 많이 합니다. 팀원들은 이렇게 얘기합니다. 2019년 초기만 해도 머 저런 미친X.. 쌍또라... 등으로 저를 얘기했는데 어떻게 이렇게 웃고 서로 힘이 되어 주고 도와주는 사이가 됐는지 신기해합니다. (자슥들아 너희와 내가 함께 노력한 거다 ㅋㅋㅋ) 그중 한 명은 진짜로 코드에 심각한 오류를 심어 놓고 퇴사할까 라는 생각도 했다고 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;너무 많은 업무량..&lt;/h3&gt;
&lt;p&gt;CTO가 되고 나서 정말 사업부는 너무 많은 업무와 짧고 짧은 기간을 주었습니다. 저는 이 부분을 어떻게 풀어 나가야 할지에 대해서 고민을 했습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;저는 정말 야근이 싫었습니다. 그래서 팀원들에게 야근하게 만들고 싶지 않았습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5&gt;1. 서버 관리는 하지 말자.&lt;/h5&gt;
&lt;p&gt;미친 듯한 업무량을 제한된 시간에 팀원들과 함께 풀어나가기에는 개발에만 집중하기에도 부족했습니다. 여기에 있어서 서버 관리까지 한다고 생각하니 답이 나오지 않았습니다. 그래서 선택을 아래와 같이 했습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Lambda를 사용해서 서버를 관리하지 말자.&lt;/li&gt;
&lt;li&gt;S3, Route53, Cloudfront를 사용해서 SPA를 서비스하자&lt;/li&gt;
&lt;li&gt;Codepipeline을 이용해서 자동 배포 시스템을 구축하자.&lt;/li&gt;
&lt;li&gt;Mongodb를 사용하면서 직접 관리하지 말고 Mongodb Atlas를 사용하자.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같은 선택은 정말 신의 한 수였습니다. 선택에 따른 결과는 아래와 같았습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;서버 관리가 더 이상 필요하지 않았습니다.&lt;/li&gt;
&lt;li&gt;Mongodb의 모든 설정 및 관리가 필요하지 않았습니다.&lt;/li&gt;
&lt;li&gt;자동 배포로 인해 배포에 대한 부담감이 적어졌습니다.&lt;/li&gt;
&lt;li&gt;모니터링 툴이 필요하지 않았습니다. (Cloud Watch를 사용했으며, 더 필요한 부분은 log를 남겼습니다)
결론적으로 개발에 집중 할 수 있는 환경을 만들었습니다. 지금도 그때의 선택은 정말 잘했다고 생각합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;2. 급하게 처리할 수밖에 없던 업무들..&lt;/h5&gt;
&lt;p&gt;짧은 일정은 충분히 잘 만들 수 있던 서비스를 일정에 치여서 아쉬운 상태로 완료된 경우가 너무 많았습니다. 대표님과도 해당 이슈에 대한 부분에 대해 많은 얘기를 했지만, 항상 결과는 회사가 나아가기 위해서는 어쩔 수 없이 그 기간 안에 서비스가 나와야 된다는 애기로 끝났습니다.
지금 생각해보면 정말 어리석은 결정이었습니다. 제가 조금 더 강하게 의견을 말했으면 어땠을까 라는 생각을 많이 합니다.&lt;/p&gt;
&lt;p&gt;위와 같은 결정에 따른 결과는 아래와 같은 부정적으로 나타나기 시작했습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;완성도의 하락.&lt;/li&gt;
&lt;li&gt;그때 그 순간만을 위한 개발.&lt;/li&gt;
&lt;li&gt;개발자의 의욕 상실.&lt;/li&gt;
&lt;li&gt;기술의 부채.&lt;/li&gt;
&lt;li&gt;엄청난 기회비용의 낭비.&lt;/li&gt;
&lt;li&gt;효율적이지 못한 업무.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같은 상황은 개발팀뿐만 아니라 회사 전체에 악영향을 주었다고 생각합니다. 지금은 이와 같은 상황을 만들지 않기 위해서 노력하고 있습니다. (부채를 열심히 갚고 있고요..)&lt;/p&gt;
&lt;h3&gt;효율적인 업무 방식&lt;/h3&gt;
&lt;p&gt;CTO가 되면서 욕심을 부리고 싶었던 부분은 효율적인 업무 방식이었습니다. 그에 따라서 정말 다양한 툴을 사용했었습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;트렐로 + 슬랙&lt;/li&gt;
&lt;li&gt;테스크월드 + 슬랙&lt;/li&gt;
&lt;li&gt;github(저장소마다 있는 project) + 슬랙&lt;/li&gt;
&lt;li&gt;먼데이 + 슬랙&lt;/li&gt;
&lt;li&gt;노션 + 텔레그램(현재)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위의 5가지를 써보았고, 효율적인 업무를 위해서 애자일 방법론들을 짬뽕하고 조합해서 다양하게 적용을 해봤습니다. 하지만 결과적으로는 실패하게 되었습니다. 그에 따른 경험을 공유할까 합니다.&lt;/p&gt;
&lt;h4&gt;1. 모두가 함께 노력해야 된다.&lt;/h4&gt;
&lt;p&gt;모든 업무를 Task 화 하고 그에 맞게 스프린트를 단위로 개발을 하자라는 목표로 시작을 했습니다. 먼저 바로 모든 것을 바꾸기에는 무리가 있었다고 판단하였고 작은 거부터 시작하자는 목표로 진행했습니다. 하지만 쉽지 않았습니다. 누구에게는 이 방식이 편했고 누구에게는 이 방식이 불편했습니다.
업무수행 방식에 대한 약속 또한 지키지 못하는 경우가 자주 발생하였습니다. 업무수행 방식의 변화는 어느 순간부터 팀원들에게 스트레스가 되었고, 개발팀을 제외한 다른 팀, 대표님한테도 불필요한 일처럼 느끼게 만들어졌습니다. 또한 많은 업무량에 이와 같은 업무수행 방식의 변화는 더욱더 안 좋은 인식을 가져다주었습니다. 결론적으로 업무수행 방식의 변화는 회사 전체가 서로 도와줘야 된다는 것을 느끼게 되었습니다. 무엇보다도 대표님이 이해해주고 도와줘야 된다는 것을 절실하게 느꼈습니다. 그리고 엄청난 업무량과 짧은 기간에는 그냥 폭포수처럼 할 수밖에 없나? 정말 방법이 없을까? 라는 생각을 많이 하게 되었습니다. 이와 같은 경험은 2019년에는 아직 업무수행 방식을 변경하기에는 시기상조라는 결론을 내리게 되었고, 2020년에는 꼭 여유를 가지고 지금보다 나은 효율적인 업무수행 방식을 도입하겠다는 다짐을 했습니다.&lt;/p&gt;
&lt;h4&gt;2. 인식의 변화가 필요&lt;/h4&gt;
&lt;p&gt;효율적인 업무수행 방식은 회사에서 일하는 모두가 인식의 변화가 필요합니다. 그리고 이것을 적용하고 효율을 보기 위해서는 많은 시간과 노력이 필요하다는 것도 알아야 됩니다. 기존에 일했던 방식과 매우 다를 수도 있기 때문에 주변에서도 많이 도와줘야 되고 여유를 가지고 적용해야 된다는 것을 느꼈습니다.&lt;/p&gt;
&lt;h4&gt;3. 효율적인 업무수행 방식은 편하지 않다.&lt;/h4&gt;
&lt;p&gt;편하다는 것과 효율적이라는 말은 같지 않습니다. 효율적으로 업무를 하기 위해서는 불편함을 느낄 수밖에 없습니다. 가끔 효율적으로 업무 수행하는 게 편하게 일하는 거 아니에요? 라고 하지만 절대 아님을 알고 있어야 됩니다.&lt;/p&gt;
&lt;h4&gt;4. 업무수행 방식의 시스템화&lt;/h4&gt;
&lt;p&gt;확고한 체계를 가지고 시스템화 해야 됩니다. 아마 처음부터 확실한 시스템화는 하기 힘듭니다. 계속 보안하고 발전해 나가서 하나의 시스템으로 자리 잡게 해야 된다고 생각을 합니다. 그 누군가 새로 오든 혹은 누군가 회사를 퇴사하든가 문제없어야 되니깐요..&lt;/p&gt;
&lt;h4&gt;5. 대표님의 도움(?)&lt;/h4&gt;
&lt;p&gt;실패의 가장 큰 요인은 대표님이 불편해했고 불필요하다고 느꼈다는 것입니다. 그 누구보다 앞장서서 도와주셨으면 반은 성공하지 않을까 생각도 합니다.&lt;/p&gt;
&lt;p&gt;효율적인 업무수행 방식을 도입하기 위해서는 많은 어려움이 있음을 경험하고 알게 되었습니다. 하지만 절대 포기할 수 없기도 하고요. 효율적으로 업무를 하는 회사가 있으면 경험도 하고 싶습니다. 그리고 구글이나 아마존 등등에서는 어떻게 업무를 하고 있는지도 궁금하기도 하고요. 2020년에는 조금 더 효율적인 업무수행 방식에 대해서 경험도 하고 싶고 도입을 해야 한다는 다짐을 합니다.&lt;/p&gt;
&lt;h3&gt;퇴사를 원하는 직원&lt;/h3&gt;
&lt;p&gt;직원들 모두가 한 회사에서 평생을 함께하지 않습니다. 특히나 스타트업에서는 있을 수 없는 일입니다. 우리 회사도 퇴사를 한 직원이 있었으며, 퇴사를 생각하다가 지금까지 함께 일하는 직원도 있습니다.
제가 CTO가 아닌 개발자로 있었을 때는 크게 생각을 하지 않았습니다. 하지만 CTO가 되고 나서부터는 누군가가 퇴사한다고 할 때마다 가슴이 철렁합니다. 많지 않은 인원으로 개발팀을 꾸려가고 있는 상황에서 누군가 퇴사하면 모든 부분에 대해서 타격이 입기 때문입니다. 이와 같은 경험을 통해서 느낀 점은 아래와 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;그 누구도 그만둘 수 있다. 준비하자.&lt;/li&gt;
&lt;li&gt;퇴사를 막기 위해서 희망 고문 하지 말자.&lt;/li&gt;
&lt;li&gt;퇴사라는 결정을 하기 전에 미리 방지할 수 있어야된다.&lt;/li&gt;
&lt;li&gt;좋은 곳으로 이직을 하게 되면 진심으로 축하하자.&lt;/li&gt;
&lt;li&gt;퇴사하는 직원이 있으면 다른 직원들도 동요하게 된다. 주의하자.&lt;/li&gt;
&lt;li&gt;모든 계정 및 비밀번호를 쉽게 변경 할 수 있게 준비하자&lt;/li&gt;
&lt;li&gt;항상 문서화를 해서 퇴사를 해도 영향력이 적게 처리하자.&lt;/li&gt;
&lt;li&gt;누구보다 나 자신의 멘탈 관리를 하자.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;함께 하다가 퇴사를 한다는 얘기를 들으면 정말 서운한 감정이 먼저 앞서게 됩니다. 그다음이 업무에 대해서 생각을 하게 되고요. 솔직히 처음에는 맨탈도 많이 흔들리기도 했습니다. 맨탈 관리도 중요합니다. 하지만 언제든 함께할 수 없다는 것을 알고 준비해야 되고 좋은 곳으로 이직을 했을 시에는 진심으로 축하해야 된다고 생각합니다. 나아가서 누군가 퇴사한다는 것은 회사 전체 분위기에도 좋지 않은 영향을 미칠 수 있습니다. 그렇기 때문에 꼭 주의를 해야 됩니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;그 무엇이 됐든 미리미리 준비하는 것이 최고의 방법이라 생각합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;동기 부여&lt;/h3&gt;
&lt;p&gt;동기부여는 정말 어렵습니다. 지금도 어렵고 앞으로도 어렵습니다. 솔직히 어떻게 해야 될지도 모르겠다고 표현하는 게 맞는 거 같습니다. CTO로서 개발팀의 동기부여를 하고 번아웃 되지 않게 잘 해야 되다는 얘기를 들었습니다. 동기부여 어떻게 해야 될까요?? 우선, 제가 직접 해본 방식은 아래와 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;너는 혼자가 아니야 함께 하고 있어.&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;많은 얘기를 듣고 함께 하는 시간이 필요합니다. (가끔은 형으로써 동생으로서 조언도 하고요)&lt;/li&gt;
&lt;li&gt;공감대 형성도 아주 중요하다고 생각합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;회사는 일만 하는 곳이 아니야.&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;하루에 회사에 있는 시간이 많습니다. 일만 하는 곳으로 생각이 들게 하는 게 아니라 가끔은 게임도 하고 떠들고 놀 수도 있게 만들었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;흥미로운 기술 적용&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;정해진 기술만 가지고 개발을 한다면 본인 스스로가 정체된다는 느낌을 받을 수 있습니다. 팀원이 적용하고 싶은 기술이 있으면 언제든 오픈되어있고 적용도 할 수 있게 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;충분한 휴식을 주기&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;많은 업무량을 소화했으면 그것에 맞게 휴식도 주었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;희망 고문은 하지 말자&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;처음에는 희망고문적인 말을 해봤습니다. 우리가 이렇게 하면 우리는 정말 잘될 거다 같은 느낌으로요. 하지만 순간순간은 동기부여가 될 수 있지만, 장기적으로 봤을 때는 좋지 않은 결과로 다가왔습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;위와 같은 노력에도 불구하고 동기부여는 아직도 어렵습니다. 그리고 제가 열심히 노력한다고 해도 회사에서 그것에 맞게 따라주지 않으면 동기부여가 무산되기도 합니다. 누군가 동기부여에 대해서 강의를 하면 꼭 돈을 줘서라도 듣고 싶습니다.&lt;/p&gt;
&lt;h3&gt;부정적인 분위기에 주의하자&lt;/h3&gt;
&lt;p&gt;재미있는 건 긍정적인 분위기보다는 부정적인 분위기가 쉽게 퍼집니다. 부정적인 분위기는 다들 아시겠지만 업무 효율부터 모든 것에 마이너스가 됩니다. 아래와 같은 상황을 주의해야 됩니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;누굴 탓하는 분위기&lt;/li&gt;
&lt;li&gt;일하기 싫다는 분위기&lt;/li&gt;
&lt;li&gt;퇴사하는 사람한테 나오는 무언가 퇴사해서 좋다는 분위기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;글 쓰는 이 순간에는 저 위의 3가지만 생각이 납니다. 부정적인 분위기는 막는다고 막히는 게 아니라고 생각은 합니다. 하지만 최대한 주의를 하고 미리 예방한다면 좋지 않을까 합니다.&lt;/p&gt;
&lt;h3&gt;마지막으로..&lt;/h3&gt;
&lt;p&gt;너무 두서없이 주저리주저리 쓴 거 같습니다. 마지막으로 위의 글 말고도 생각나는 걸 그냥 막 나열해볼까 합니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;맨탈 관리 잘하자.&lt;/li&gt;
&lt;li&gt;왜 CTO를 뽑을 때 경험이 있는 사람을 우대하는지 알겠다.&lt;/li&gt;
&lt;li&gt;건강 관리하자.&lt;/li&gt;
&lt;li&gt;사람은 누구나 특출나게 잘하는 곳이 있다. 그것에 맞게 업무 분배가 중요하다.&lt;/li&gt;
&lt;li&gt;나 혼자 아무리 해보려고 해도 팀원이나 회사가 도와주지 않으면 아무것도 못 한다. 함께하자.&lt;/li&gt;
&lt;li&gt;효율적인 업무수행 방식은 못하더라도 문서화는 꼭 하자.&lt;/li&gt;
&lt;li&gt;욕심을 버리고 내려놓자.&lt;/li&gt;
&lt;li&gt;누군가 나에 대해서 얘기를 하는 거에 예민하게 반응하지 말자.&lt;/li&gt;
&lt;li&gt;말하기보다는 듣기를 조금 더 하자.&lt;/li&gt;
&lt;li&gt;부족한 부분을 탓하기보다는 채워주기 위해서 노력하자.&lt;/li&gt;
&lt;li&gt;말을 할 때 10번은 생각하고 말하자.&lt;/li&gt;
&lt;li&gt;생각 할 수 있는 여유를 가지고 개발을 할 수 있는 환경을 만들자. (급하게 해서는 절대 안 된다)&lt;/li&gt;
&lt;li&gt;진솔한 사람이 되자.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;아직도 많이 부족하기만 하다는 것을 글을 쓰면서 다시 한번 느끼게 됩니다. 2020년 제가 어떻게 될지는 모르겠지만 지금보다 조금 더 발전하고 나아가야겠습니다. 저와 함께 일한 동료들이 다시 또 함께 할 수 있게 그리고 개발자로서도 발전해 나가야겠다고 다짐을 합니다.&lt;/p&gt;
</content:encoded></item><item><title>Serverless를 선택한 이유 (Lambda, Atlas)</title><link>https://blog.dongjun.win/posts/lambda/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/lambda/</guid><description>CTO로서 Lambda와 MongoDB Atlas를 실무에 적용하며 서버리스 구조를 선택한 이유, 장점과 고려사항을 정리합니다.</description><pubDate>Fri, 17 Jan 2020 03:25:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/images/posts/serverless/cover.png&quot; alt=&quot;AWS Lambda와 서버리스 구성 표지&quot; /&gt;
CTO를 맡으면서 제가 선택하고 실무에 적용하면서 경험한 Serverless에 대해서 글을 남기려고 합니다.&lt;/p&gt;
&lt;h2&gt;Serverless?&lt;/h2&gt;
&lt;p&gt;여기에 와서 글을 읽으시는 분들은 Serverless가 무엇인지 충분히 알고 있을 거라고 생각합니다. 그래도 간단하게만 얘기한다면 진짜 Server가 없는 것은 아니고 Server를 신경 쓰지 않아도 서비스를 할 수 있게 하는 기술이라고 보면 됩니다.
조금 더 있어 보이게 얘기한다면 &lt;em&gt;&lt;strong&gt;애플리케이션 개발자가 서버를 프로비저닝하거나 애플리케이션의 확장을 관리할 필요가 없는 클라우드 컴퓨팅 모델을 가리킵니다.&lt;/strong&gt;&lt;/em&gt;
여기에서 저는 실무에 적용하고 경험한 Lambda와 MongoDB Atlas 등 Serverless를 선택한 이유와 그에 대한 글을 남길까 합니다.&lt;/p&gt;
&lt;h2&gt;Lambda&lt;/h2&gt;
&lt;p&gt;람다는 AWS에서 만든 &lt;em&gt;&lt;strong&gt;서버를 프로비저닝하거나 관리하지 않고도 코드를 실행할 수 있게 해주는 컴퓨팅 서비스&lt;/strong&gt;&lt;/em&gt;입니다. 솔직히 말이 조금 어렵지만 단순하게 서버 관리가 필요 없이 함수를 실행해 주는 놈? 이라고 생각하는 게 편합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;람다를 사용했을 시 장점은 무엇이 있을까?&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;개발에만 집중 할 수 있는 환경이 됩니다.&lt;/li&gt;
&lt;li&gt;서버에 대해서 고민을 할 필요가 없습니다. (오토스캐일링부터 다양한 관점에서)&lt;/li&gt;
&lt;li&gt;비용이 저렴합니다.&lt;/li&gt;
&lt;li&gt;서버뿐만 아니라 AWS의 기능에 대한 트리거 및 스케줄로 등등으로 사용이 가능합니다. (예로 code pipeline에서 s3 배포를 완료 후 Cloud front 캐시 초기화할 때도 사용됩니다)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;장점만 있으면 좋겠지만 단점도 존재합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;콜드 스타트 부분입니다. 최근에 동시성이라는 기능이 추가되어서 많이 좋아지긴 했습니다. 하지만 디비 커넥션과 같은 부분은 콜드 스타트 부분에서 초기화되기 때문에 생각을 해줘야 됩니다.&lt;/li&gt;
&lt;li&gt;로그 보는 부분이 매우 불편합니다. (AWS의 CloudWatch를 통해서 볼 수 있지만 불편합니다)&lt;/li&gt;
&lt;li&gt;동시 실행에 대한 제한이 있습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;단점을 극복 한 저의 경험은?&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;콜드 스타트&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;해당 부분은 현재 15분마다 해당 function을 호출해 주는 방법이 가장 좋다는 생각은 듭니다. 하지만 MSA로 구현이 되어 있다면 수많은 function을 호출하는 게 비용적인 측면에서 문제가 있을 수 있습니다. 저는 Lambda의 사용을 꼭 MSA로 구현하지 않아도 된다고 생각하기 때문에 해당 부분은 어떻게 설계하냐에 따라 달라질 거라 생각합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;로그 문제
&lt;ul&gt;
&lt;li&gt;lambda를 호출하는 부분에 공통으로 로그를 남기는 부분을 만들어 놓았습니다. 물론 x-ray와 같은 서비스를 사용해도 좋겠지만 그거보다는 직접 호출이 되었을 시 그리고 오류가 났을 시 등등을 전부 에러 로그 처리해 놓고 확인하게 하였습니다. 다음에는 ELK를 이용하여 로그 분석 관련해서 작업을 진행할까 생각도 하고 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;동시 실행에 대한 제한
&lt;ul&gt;
&lt;li&gt;이 부분은 방법이 없습니다. 미리미리 AWS에 동시 실행에 대한 제한 부분을 풀어 놓으면 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;제한된 모니터링 툴
&lt;ul&gt;
&lt;li&gt;Lambda에 대한 모니터링 툴은 많이 부족하고 찾기 힘듭니다. 물론 CloudWatch가 있긴하지만 뭔가 부족함이 있다는 것을 느낄 수 있습니다. 아직 이 부분은 정도 CloudWatch를 확인하네요.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Lambda를 직접 운영/개발을 하면서 알게 된 팁.&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Lambda layer를 꼭 사용해야 합니다.
&lt;ul&gt;
&lt;li&gt;layer 같은 경우는 외부 코드나 라이브러리, 모듈 등을 사전에 압축하여 하나의 큰 모듈처럼 사용 할 수 있게 해줍니다.&lt;/li&gt;
&lt;li&gt;layer를 사용하면 deploy 부분에 대한 속도 부분이 극명하게 차이 날 정도로 빨라집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Serverless Framework 사용 추천
&lt;ul&gt;
&lt;li&gt;이 부분을 팁으로 놓아야 하나 고민은 많이 되었습니다. 하지만 다른 프레임워크보다는 다양한 플러그인 지원, 그리고 방대한 커뮤니티 등등이 Lambda 혹은 Serverless를 사용하면서 큰 도움이 되었다고 생각합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Codepipeline 사용한 자동배포
&lt;ul&gt;
&lt;li&gt;AWS를 사용해서 배포를 자동화하면 좋습니다. 보안에 민감할 수 있으니 해당 배포에 대한 권한을 개발자에게 주기보다는 AWS IAM을 이용해서 자동 배포 할시에만 주는걸 추천해 드립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;MSA?? 굳이... 상황에 맞게 처리.
&lt;ul&gt;
&lt;li&gt;Lambda를 사용하면 MSA를 해야 될 거 같은 느낌을 받을 수 있지만, 굳이 그렇게 할 필요가 없습니다. Monolithic과 비슷하게 1개의 endpoint에 1개의 function으로 해도 됩니다. 제가 추천하는 건 적당한 선에서 상황에 맞게 처리하는 것을 추천합니다.&lt;/li&gt;
&lt;li&gt;MSA를 했을 때에는 DB 커젝션 수부터 로그 부분 그리고 function이 많아질 경우 그거에 대한 관리 등등을 고민해야 됩니다. 또한 콜드 스타트 때문에 매번 lambda 호출 시 드는 비용에 대해서도 고민할 필요가 있습니다.&lt;/li&gt;
&lt;li&gt;Monolithic으로 했을 때에는 너무 방대해진 코드의 용량에 대해 고민을 할 필요가 있습니다. 또한 동시 실행의 제한도 미리미리 신청해서 늘려 놓아야 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Lambda를 선택한 이유&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;위의 다양한 의견을 내긴 했지만 제가 Lambda를 선택한 가장 큰 이유는 서버 관리가 필요하지 않고 개발에 집중 할 수 있는 환경을 구성 할 수 있기 때문입니다. Lambda에서 나오는 단점은 충분히 커버도 가능할 거라 생각하고요. 비용 부분도 저렴합니다. 우선 프리티어와 관계없이 월 1백만 호출까지는 무료로 알고 있습니다. 호출된 횟수에 맞게 돈을 지불하기 때문에도 저렴하다고 말 할 수 있습니다. 트레픽이 많아지면 많이 비싸진다는 애기도 있긴 합니다. 이 부분도 말씀드리고 싶은것은 우선 Lambda가 아닌 다른 것을 사용해도 트레픽이 많아지면 가격이 비싸집니다. 물론 딱 물리적인 가격만 비교하면 Lambda가 비싸게 느껴질 수 있지만 트레픽 대응에 대한 서버 관리, 인력 고용 등등을 생각도 해야 됩니다.(대용량 트레픽에 따른 서버 관리하는 사람을 고용하는 것도 어렵고 급여도 많이 높을 거라 생각합니다) 이 모든 비용을 바라본다면 Lambda가 저렴하다고 생각합니다.&lt;/p&gt;
&lt;h2&gt;MongoDB Atlas&lt;/h2&gt;
&lt;p&gt;MongoDB Atlas는 MongoDB management service(MMS)입니다. 말이 어렵게 느껴질 수 있지만 결국 serverless로 MongoDB에 대한 모든 관리는 Atlas에서 해준다고 생각하면 편합니다. Lambda랑 비슷합니다. 한때는 mLab이 가장 유명했지만 MongoDB에서 인수를 하면서 서비스가 종료되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MongoDB Atlas의 장점&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;가장 좋은 건 MongoDB에 대한 관리가 필요 없습니다. Server부터 모든 기능 전부(리플리카셋, 샤딩 등등)&lt;/li&gt;
&lt;li&gt;모니터링 툴을 따로 쓸 이유가 없습니다.&lt;/li&gt;
&lt;li&gt;알람 또한 너무 잘되어 있어서 빠르게 확인 할 수 있습니다.&lt;/li&gt;
&lt;li&gt;Performance Advisor라는 기능을 제공하여 쿼리 속도부터 index가 필요한 부분까지 체크해줍니다.&lt;/li&gt;
&lt;li&gt;멀티 리전을 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;스토리지에 대한 Auto Scaling을 제공합니다.&lt;/li&gt;
&lt;li&gt;백업에 대해서도 지원합니다. (실시간 백업도 가능, 4.2버전 이상은 아직 미지원)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;단점은?&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;비용이 저렴하진 않습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;단점 부분은 솔직히 비용을 적긴 했지만 저는 합리적이라고 생각합니다. 단점을 찾기가 쉽지 않네요..&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MongoDB Atlas를 사용하면서 알게 된 팁&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;같은 리전, 같은 클라우드&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;당연한 소리이지만 같은 리전 그리고 같은 클라우드 서비스로 만드는 것을 추천합니다. (이유는 굳이 설명 안 하겠습니다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;Database Access 권한&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Database Access 권한인 경우는 모든 클러스트 공통으로 적용이 됩니다. 저희 같은 경우 개발 클러스트와 실 클러스트를 운영하는데 Database Access 권한을 주면 둘 다 동일하게 적용되었습니다. (다른 방법이 있는데 제가 모르는 거 일 수도 있습니다) 그렇기 때문에 해당 부분을 확인하는 게 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;Network Access 권한&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Network Access 같은 경우 보통 0.0.0.0/0으로 세팅 하는 경우가 있는데 보안상 추천하지 않습니다. 꼭 화이트 리스트로 하는 것을 추천합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;vpc peering&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;VPC peering을 한다고 해서 체감상 속도가 빨라지진 않습니다. 다만 이걸 하는 것을 추천하는 이유는 보안상의 이유가 크다고 볼 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;Performance Advisor 기능&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Performance Advisor 기능을 적극적으로 활용해야 됩니다. 정말 신기할 정도로 잘 추천해주고 그것에 맞게 처리하면 성능 개선을 확실히 볼 수 있습니다. 다만 초기에 데이터가 없을 시에는 확인이 되지 않을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;스케일 업&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;mongodb 스케일 업 시에는 간단하게 버튼만으로 추가 할 수 있습니다. 다만 스케일업 하는 동안은 서비스가 되지 않기 때문에 충분한 공지를 통해서 하시는걸 추천해 드립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;MongoDB Atlas를 선택한 이유&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;똑같은 소리를 반복하는 거일 수 있지만 서버 관리, 몽고디비의 다양한 기능, 설정 등등을 직접 관리할 필요가 없고 개발에 집중 할 수 있는 환경을 조성 할 수 있기 때문입니다. 초기에는 Atlas가 아닌 직접 MongoDB를 운영도 해보았습니다. EC2 3대와 글로벌 서비스를 위한 버지니아에 read 전용 EC2 2대까지 총 5대를 세팅하고 운영을 하면서 다양한 버그, 에러 그리고 모니터링 툴의 필요성, 알람의 필요성, 백업 계획 등등을 느끼고 있었지만, 개발을 할 수 있는 인력 및 여건이 되지 않아 Atlas로 옮기는 결정을 하고 작업을 했습니다. 옮기고 나서 만족도는 1,000,000%입니다. 다만 단점이라고 하면 비용에 있습니다. 하지만 위의 부가적인 작업 및 MongoDB에 문제가 있을 시 등등의 기회비용 인력 비용 모든 것을 다 합한다고 하면 합리적이라고 생각합니다.&lt;/p&gt;
&lt;h3&gt;Serverless 왜 선택했냐?&lt;/h3&gt;
&lt;p&gt;Serverless를 선택한 이유는 아래와 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;개발에 집중하는 환경&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;많은 스타트업이 그렇겠지만 개발자는 뽑기 힘들고 많이 있지 않습니다. (어디 있나요..?) 많지 않은 인력으로 서비스 개발만 해도 시간이 부족합니다. 그렇기 때문에 개발에 집중할 수 있는 환경을 조성하기 위한 하나의 선택지라 생각합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;Serverless에서 제공하는 기능들&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Lambda, Atlas에서 제공하는 기능들은 정말 꿀과 같습니다. (특히 Atlas) 이런 기능들은 직접 만들고 유지하고 운영하기에는 저희는 인력도 부족하고 시간도 부족합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;합리적인 비용&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;물리적인 하드웨어를 따지고 보면 비싸게 느껴질 수 있지만 모든 기회비용까지 생각하면 오히려 저렴하다고 생각합니다. (개발자 몸값만 생각해도...)&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;전문 지식&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Serverless를 이용하지 않고 직접 운영을 한다면 해당 기술에 대한 전문지식이 필요합니다. 다양한 버그, 상황에 맞는 설정 등 경험을 해보지 못하는 경우가 많고 아무리 많이 안다고 해도 전문적으로 해당 기술에 대한 Serverless 하는 회사에 보다는 많이 부족함도 사실입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;마무리하며.&lt;/h3&gt;
&lt;p&gt;다양한 서비리스 중 2가지만 애기하기 했지만 다른 좋은 서버리스도 많습니다. 상황에 맞게 그리고 비즈니스에 맞게 잘 활용한다면 아주 큰 도움이 될 수 있다고 생각합니다.&lt;/p&gt;
</content:encoded></item><item><title>Frontend 개발 후 AWS에 서비스 배포하기</title><link>https://blog.dongjun.win/posts/fronend-aws-s3-cloudfront-route-53-vue-angular-react/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/fronend-aws-s3-cloudfront-route-53-vue-angular-react/</guid><description>Vue, React, Angular 같은 프런트엔드 빌드 결과물을 S3, CloudFront, Route 53, CodePipeline으로 배포하는 과정을 정리합니다.</description><pubDate>Mon, 13 Jan 2020 02:47:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cover.png&quot; alt=&quot;프런트엔드 AWS 배포 구성 표지&quot; /&gt;
Frontend(vue, react, angular 등등)를 개발을 하고 서비스를 하기 위한 AWS 환경 설정 및 배포에 대해서 글을 남깁니다.&lt;/p&gt;
&lt;h2&gt;아키텍처&lt;/h2&gt;
&lt;p&gt;단순하게 SSR이 아니기 때문에 따로 서버를 두고 관리하기 보다는 S3에 파일을 올리고 그에 맞게 CDN인 Coundfront와 연결 후 Route53을 이용해서 도메인까지 연결하는 구조로 생각을 했습니다. 그리고 배포 시스템으로는 Codepipeline을 이용해서 배포 하는 방법을 채택했습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;개발자&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;개발 소스 GitHub 배포&lt;/li&gt;
&lt;li&gt;CodePipeline에서 Webhook을 이용 Github 확인&lt;/li&gt;
&lt;li&gt;CodeBuild를 이용하여 빌드(ex. npm run build와 같은 것 등등)&lt;/li&gt;
&lt;li&gt;CodeDeploy를 이용해서 S3 배포&lt;/li&gt;
&lt;li&gt;S3를 배포가 완료 되면 Lambda를 실행 시켜 CloudFront 캐시 초기화&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;고객&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;www.example.com 접속시 DNS 접속(Route53)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Route53에 연결된 CNS(CloudFront) 호출&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;캐싱이 되어 있으면 캐싱된 부분으로 리턴 안되어 있으면 S3 접근 후 리턴&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;아키텍처에 따른 환경 설정&lt;/h2&gt;
&lt;p&gt;CloudFormation을 이용해서 배포 하는 방법이 있지만 아직 그 부분은 익숙하지 않아서 아래와 같이 직접 배포를 하게 되었습니다.&lt;/p&gt;
&lt;h3&gt;1. S3&lt;/h3&gt;
&lt;h4&gt;1) 저장소 생성&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/s3-create-bucket.png&quot; alt=&quot;Amazon S3 버킷 생성 화면&quot; /&gt;&lt;/p&gt;
&lt;p&gt;위의 이미지의 버킷 만들기를 클릭 후 배킷을 생성 합니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;생성시 꼭 퍼블릭 액세스 차단은 비활성화를 하셔야됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;2) 정책 설정&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/s3-bucket-policy.png&quot; alt=&quot;Amazon S3 버킷 정책 설정 화면&quot; /&gt;&lt;/p&gt;
&lt;p&gt;만든 버킷에 접속 후에 해당 권한 탭 -&amp;gt; 버킷 정책을 클릭후 아래와 같은 정책을 입력 합니다. 그냥 해당 정책은 읽기 권한을 퍼블릭하게 오픈 한다는 의미 입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
 &quot;Version&quot;: &quot;2008-10-17&quot;,
 &quot;Statement&quot;: [
 {
 &quot;Sid&quot;: &quot;Stmt1484315864175&quot;,
 &quot;Effect&quot;: &quot;Allow&quot;,
 &quot;Principal&quot;: &quot;*&quot;,
 &quot;Action&quot;: &quot;s3:GetObject&quot;,
 &quot;Resource&quot;: &quot;arn:aws:s3:::버킷명/*&quot;
 }
 ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3) 정책 설정&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/s3-static-hosting.png&quot; alt=&quot;Amazon S3 정적 웹 사이트 호스팅 설정 화면&quot; /&gt;&lt;/p&gt;
&lt;p&gt;속성 탭에 있는 정적 웹사이트 호스팅을 위와 같이 설정합니다.&lt;/p&gt;
&lt;h3&gt;2. CloudFront 설정&lt;/h3&gt;
&lt;h4&gt;1) 생성&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-1.png&quot; alt=&quot;CloudFront 배포 설정 1단계&quot; /&gt;&lt;/p&gt;
&lt;p&gt;위의 Create Distribution을 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-2.png&quot; alt=&quot;CloudFront 배포 설정 2단계&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Web 부분의 Get Started를 클립 합니다. (저희는 Web이니깐요)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-3.png&quot; alt=&quot;CloudFront 배포 설정 3단계&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Origin Domain Name&lt;/strong&gt;
여기에 위의 S3 속성 설정의 앤드포인트(URL주소)를 가지고 와서 붙여넣기 합니다. (url 주소를 넣는 거지 버킷 아이디를 넣으면 안 됩니다.)
Ex) http://dev-front-itam.store-admin.s3-website.ap-northeast-2.amazonaws.com/&lt;/li&gt;
&lt;li&gt;다른 부분은 굳이 입력하지 않아도 됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;여기에서 버킷을 안 넣고 주소를 넣는 웹서비스이기 때문입니다. 특히 권한 문제.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-4.png&quot; alt=&quot;CloudFront 배포 설정 4단계&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Viewer Protocol Policy&lt;/strong&gt;
&lt;strong&gt;Redirect HTTP to HTTPS&lt;/strong&gt; 을 선택해주세요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Object Caching&lt;/strong&gt;
caching에 대한 설정을 다르게 하고 싶으시면 이 부분을 Customize로 설정후 아래의 활성화된 값을 넣으면 됩니다. 굳이 안 하려면 위의 사진과 같이 확인하시면 되요.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-5.png&quot; alt=&quot;CloudFront 배포 설정 5단계&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Price Class&lt;/strong&gt;
기본값인 Use All Edge Locations를 선택합니다. 물론 나의 타깃은 확고하고 정해져 있다고 하면 다른 값으로 설정하셔도 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alternate Domain Names (CNAMEs)&lt;/strong&gt;
서비스할 도메인을 넣으면 됩니다. 복수도 가능합니다. 여러 개의 도메인을 사용 시 한 줄 씩 쓰면 됩니다.
Ex) dev.example.com&lt;/li&gt;
&lt;li&gt;서비스할 도메인이 있을시 &lt;strong&gt;SSL Certificate&lt;/strong&gt;의 Custom SSL Certificate (example.com) 을 선택 후 인증서를 넣으면 됩니다. (인증서는 바로 아래의 버튼을 클릭 후 설정이 가능합니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-6.png&quot; alt=&quot;CloudFront 배포 설정 6단계&quot; /&gt;&lt;/p&gt;
&lt;p&gt;여기까지 왔으면 다 온 거라고 보시면 돼요. 여기서는 간단하게 Comment만 적어 두고 끝내면 됩니다. (관리하기 편하게 해당 서비스명을 넣는걸 추천해 드립니다.)&lt;/p&gt;
&lt;h4&gt;2) SPA 관련 세팅&lt;/h4&gt;
&lt;p&gt;SPA는 말 그대로 싱글 페이지 애플리케이션이기 때문에 모든 부분을 index.html 가게 해야 됩니다. 지금 이 설정을 하지 많으면 404 에러가 뜨게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-error.png&quot; alt=&quot;CloudFront 접근 오류 화면&quot; /&gt;&lt;/p&gt;
&lt;p&gt;위의 Create Custom Error Response를 클릭 해주세요.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/cloudfront-error-fix.png&quot; alt=&quot;CloudFront 접근 오류 해결 설정&quot; /&gt;&lt;/p&gt;
&lt;p&gt;위의 사진과 같이 404 관련된 에러는 전부 /index·html로 가게 해주면 됩니다. 여기에서 주의 깊게 볼 부분은 꼭 TTL은 0으로 세팅해주셔야 됩니다. 안 그러면 기다림이 발생합니다.&lt;/p&gt;
&lt;h3&gt;Route53&lt;/h3&gt;
&lt;p&gt;여기에서는 DNS 설정이 대한 부분은 제외하고 설명하겠습니다. 되게 간단합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/frontend-aws/route53.png&quot; alt=&quot;Route 53 도메인 연결 화면&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;이름에 서브 도메인을 입력합니다(CloudFront에서 넣은 도메인명).&lt;/li&gt;
&lt;li&gt;별칭을 클릭하면 Cloudfront영역에 선택 할 수 있는 주소가 생성되어 있습니다. 해당 부분을 선택합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같이 하면 Route 53은 설정이 끝납니다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;처음 하는 입장이면 생각보다 어렵다고 느껴지실 수 있습니다.&lt;/p&gt;
&lt;p&gt;직접 해보고 익숙해지면 쉽게 하실 수 있다는 생각이 듭니다. 위의 CloudFront 부분에서 도메인 인증서 하는 부분이 빠지긴 했지만, 그 부분은 그냥 버튼을 클릭 한 후에 하라는 대로 따라 하면 됩니다.&lt;/p&gt;
</content:encoded></item><item><title>EosJS API 사용</title><link>https://blog.dongjun.win/posts/eosjs-api-1/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/eosjs-api-1/</guid><description>EOSJS에서 자주 사용하는 체인 정보, 계정, 테이블 조회 API를 직접 실행할 수 있는 예제와 함께 정리합니다.</description><pubDate>Wed, 01 Aug 2018 01:32:00 GMT</pubDate><content:encoded>&lt;p&gt;안녕하세요. 권동준 입니다.
이전에 EOSJS 시작하기에서 간단하게 EOSJS를 사용하는 방법을 해봤습니다. 이번에는 EosJs에서 제공하는 api 중에 자주 쓰는 api를 소개하고 테스트 할 수 있게 진행을 하려고 합니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;api 목록을 보기를 원하시면 &lt;a href=&quot;https://github.com/EOSIO/eosjs-api/blob/master/docs/api.md#eos--object&quot;&gt;여기&lt;/a&gt;를 확인해 보시면 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;시작하기에 앞서 준비하기&lt;/h4&gt;
&lt;p&gt;모든 코드를 직접 사용 해볼 수 있게 할 예정입니다. 그렇게 하기 위해서는 준비가 필요합니다.&lt;/p&gt;
&lt;p&gt;준비 사항은 아래와 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;nodeJs&lt;/li&gt;
&lt;li&gt;eosJs&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위의 2개를 설치하고 javascript 파일 가장 위에 아래와 같이 넣어주세요.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const Eos = require(&apos;eosjs&apos;);

const config = {
    expireInSeconds: 60,
    broadcast: true,
    debug: false,
    sign: true,
    // mainNet bp endpoint
    httpEndpoint: &apos;https://api.eosnewyork.io&apos;,
    // mainNet chainId
    chainId: &apos;aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906&apos;,
};

const eos = Eos(config);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 넣고 나서 아래의 api 예제를 직접 코딩하고 nodeJs로 javascript를 실행하면 값이 나옵니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Bp Endpoint마다 응답속도 혹은 신뢰도가 각각 다르게 때문에 본인에 가장 맞는 bp를 사용하기를 권장합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;getBlock(blockNumOrId)&lt;/h4&gt;
&lt;p&gt;해당 블록의 정보를 가지고 올 수 있습니다.&lt;/p&gt;
&lt;p&gt;params:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;param&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;block_num_or_id&lt;/td&gt;
&lt;td&gt;블록의 아이디나 number&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Promise
eos.getBlock(1).then(result =&amp;gt; console.log(result)).catch(error =&amp;gt; console.error(error));

// callback
eos.getBlock(1, (error, result) =&amp;gt; console.log(error, result));

// Parameters object
eos.getBlock({block_num_or_id: 1}).then(console.log);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과 값:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ timestamp: &apos;2018-06-08T08:08:08.500&apos;,
  producer: &apos;&apos;,
  confirmed: 1,
  previous:
   &apos;0000000000000000000000000000000000000000000000000000000000000000&apos;,
  transaction_mroot:
   &apos;0000000000000000000000000000000000000000000000000000000000000000&apos;,
  action_mroot:
   &apos;aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906&apos;,
  schedule_version: 0,
  new_producers: null,
  header_extensions: [],
  producer_signature:
   &apos;SIG_K1_111111111111111111111111111111111111111111111111111111111111111116uk5ne&apos;,
  transactions: [],
  block_extensions: [],
  id:
   &apos;00000001405147477ab2f5f51cda427b638191c66d2c59aa392d5c2c98076cb0&apos;,
  block_num: 1,
  ref_block_prefix: 4126519930 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;해당 블록에서 어떠한 일을 했는지 보기 위해서는 transactions를 보면 됩니다.&lt;/p&gt;
&lt;p&gt;transactions를 보기 위해 아래와 같이 한번 같이 해보시죠.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ { status: &apos;executed&apos;,
    cpu_usage_us: 1170,
    net_usage_words: 40,
    trx:
     { id:
        &apos;8a29bfa66850b7d4a2b0b62173a24c5dfe4dbd7b39c211df6309d02a85374960&apos;,
       signatures: [Array],
       compression: &apos;none&apos;,
       packed_context_free_data: &apos;&apos;,
       context_free_data: [],
       packed_trx:
        &apos;9051595bad38f016a289000000000100a6823403ea3055000000572d3ccdcd0110e0a53cab294d7600000000a8ed3232dd0110e0a53cab294d76a0986af64b96bc65010000000000000004454f5300000000bb01496e74726f647563696e67204954414d204e6574776f726b2c20616e20454f532d426173656420444150502050726f6a656374206f6e20426c6f636b636861696e2047616d696e6720506c6174666f726d20666f722061205472616e73706172656e742047616d696e672045636f73797374656d2e202d2d576562736974653a2068747470733a2f2f6974616d2e67616d65732f656e202d2d54656c656772616d3a2068747470733a2f2f742e6d652f6974616d6e6574776f726b00&apos;,
       transaction: [Object] } }
 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같은 값으로 주며 저기에서도 transaction를 보면 actions가 있으며 그걸 보면 이 블록에서 어떤일들을 했는지 더욱 깊게 볼 수 있습니다.&lt;/p&gt;
&lt;h3&gt;getAccount(accountName)&lt;/h3&gt;
&lt;p&gt;Eos계정의 정보를 가지고 올때 사용합니다.&lt;/p&gt;
&lt;p&gt;Params:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;account_name&lt;/td&gt;
&lt;td&gt;eos 계정의 이름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Promise
eos.getAccount(&apos;itamnetwork1&apos;)
    .then(result =&amp;gt; console.log(result))
    .catch(error =&amp;gt; console.error(error));

// callback
eos.getAccount(&apos;itamnetwork1&apos;, (error, result) =&amp;gt; console.log(error, result));

// Parameters object
eos.getAccount({account_name: &apos;itamnetwork1&apos;})
    .then(result =&amp;gt; console.log(result))
    .catch(error =&amp;gt; console.error(error));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과 값&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ account_name: &apos;itamnetwork1&apos;,
  head_block_num: 8516805,
  head_block_time: &apos;2018-07-30T07:34:52.500&apos;,
  privileged: false,
  last_code_update: &apos;1970-01-01T00:00:00.000&apos;,
  created: &apos;2018-07-09T02:24:58.500&apos;,
  core_liquid_balance: &apos;12.6131 EOS&apos;,
  ram_quota: 14976,
  net_weight: 201000,
  cpu_weight: 10401000,
  net_limit: { used: 1679786, available: 11108657, max: 12788443 },
  cpu_limit: { used: 7950353, available: 6356380, max: 14306733 },
  ram_usage: 10934,
  permissions:
   [ { perm_name: &apos;active&apos;, parent: &apos;owner&apos;, required_auth: [Object] },
     { perm_name: &apos;owner&apos;, parent: &apos;&apos;, required_auth: [Object] } ],
  total_resources:
   { owner: &apos;itamnetwork1&apos;,
     net_weight: &apos;20.1000 EOS&apos;,
     cpu_weight: &apos;1040.1000 EOS&apos;,
     ram_bytes: 14976 },
  self_delegated_bandwidth:
   { from: &apos;itamnetwork1&apos;,
     to: &apos;itamnetwork1&apos;,
     net_weight: &apos;0.1000 EOS&apos;,
     cpu_weight: &apos;0.1000 EOS&apos; },
  refund_request: null,
  voter_info:
   { owner: &apos;itamnetwork1&apos;,
     proxy: &apos;&apos;,
     producers: [],
     staked: 4000,
     last_vote_weight: &apos;0.00000000000000000&apos;,
     proxied_vote_weight: &apos;0.00000000000000000&apos;,
     is_proxy: 0 } }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위의 결과값중에 다 중요하지만 몇개만 설명을 하려 합니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;account_name
누구나 다 알다 싶이 eos account name 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ram_quota&lt;/p&gt;
&lt;p&gt;내가 보유한 RAM 입니다. 단위는 byte입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;net_limit&lt;/p&gt;
&lt;p&gt;해당 계정이 가지고 있는 총 net, 사용 가능한 net, 사용한 net을 나타냅니다. 단위는 byte입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;cpu_limit&lt;/p&gt;
&lt;p&gt;해당 계정이 가지고 있는 총 cpu, 사용 가능한 cpu, 사용한 cpu을 나타냅니다. 단위는 us 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ram_usage&lt;/p&gt;
&lt;p&gt;해당 계정이 사용한 RAM 입니다 단위는 byte입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;total_resources
나에게 할당된 리소스의 eos를 보여줍니다. (누군가가 나에게 delegated한 것도 포함됩니다.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;self_delegated_bandwidth
내가 내 자신에게 delegated한 정보 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;voter_info&lt;/p&gt;
&lt;p&gt;투표에 대한 정보입니다. 여기에서 눈여겨 봐야될 부분은 staked입니다. 이부분은 현재 내가 staked 한 부분인데요. 좀더 자세히 설명한다면 내가 스스로 내 자신에게 delegated한 부분과 누군가에서 delegated한 부분을 포함한 값입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;getKeyAccounts(publicKey)&lt;/h3&gt;
&lt;p&gt;public key에 해당하는 account들을 가지고 옵니다.&lt;/p&gt;
&lt;p&gt;Params:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;public_key&lt;/td&gt;
&lt;td&gt;EOS의 public key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Promise
eos.getKeyAccounts(&apos;EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5&apos;)
    .then(result =&amp;gt; console.log(result))
    .catch(error =&amp;gt; console.error(error));

// callback
eos.getKeyAccounts(&apos;EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5&apos;,
    (error, result) =&amp;gt; console.log(error, result));

// Parameters object
eos.getKeyAccounts({public_key: &apos;EOS6S6C5ExCM7VHGdmG5h6VREVJEC33bpMJtLucwhyByPmzB58KW5&apos;})
    .then(console.log);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과값:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ account_names: [ &apos;itamnetwork1&apos; ] }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;EOS의 public key 한개로 여러 account를 만들수 있습니다. 그렇게 때문에 account_name의 값이 string으로 이루어진 array 입니다.&lt;/p&gt;
&lt;h3&gt;getCurrencyBalance(code, account, symbol)&lt;/h3&gt;
&lt;p&gt;code의 symbol에 해당하는 Token을 가지고 옵니다.&lt;/p&gt;
&lt;p&gt;Params:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;code&lt;/td&gt;
&lt;td&gt;컨트렉트 명 혹은 해당 컨트렉트가 있는 account명을 말합니다.&amp;lt;br /&amp;gt;ex) eosio.token, therealkarma 등등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;account&lt;/td&gt;
&lt;td&gt;조회할 EOS의 계정명 입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;symbol&lt;/td&gt;
&lt;td&gt;Token의 symbol 입니다. 이부분은 필수값이 아닌 옵션 값입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Promise
eos.getCurrencyBalance(&apos;eosio.token&apos;, &apos;itamnetwork1&apos;, &apos;EOS&apos;)
    .then(result =&amp;gt; console.log(result))
    .catch(error =&amp;gt; console.error(error));

// callback
eos.getCurrencyBalance(&apos;eosio.token&apos;, &apos;itamnetwork1&apos;, &apos;EOS&apos;,
    (error, result) =&amp;gt; console.log(error, result));

// Parameters object
eos.getCurrencyBalance({account: &apos;itamnetwork1&apos;, code: &apos;eosio.token&apos;, symbol: &apos;EOS&apos;})
    .then(console.log);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과값:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ &apos;12.6131 EOS&apos; ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과 값을 보면 string형식의 array가 나옵니다. 이유는 해당 컨트렉트안에 여러 symbol을 가진 token들이 있을수 있기 때문입니다. EOS 테스트넷인 정글넷을 보면 symbol을 제외하고 eosio.token을 조회하면 2개의 token들을 볼수 있습니다.&lt;/p&gt;
&lt;h3&gt;getCurrencyStats(code, symbol)&lt;/h3&gt;
&lt;p&gt;symbol에 해당하는  Token의 정보를 가지고 옵니다.&lt;/p&gt;
&lt;p&gt;Params:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;code&lt;/td&gt;
&lt;td&gt;컨트렉트 명 혹은 해당 컨트렉트가 있는 account명을 말합니다.&amp;lt;br /&amp;gt;ex) eosio.token, therealkarma 등등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;symbol&lt;/td&gt;
&lt;td&gt;Token의 symbol 입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Promise
eos.getCurrencyStats(&apos;eosio.token&apos;, &apos;EOS&apos;)
    .then(result =&amp;gt; console.log(result))
    .catch(error =&amp;gt; console.error(error));

// callback
eos.getCurrencyStats(&apos;eosio.token&apos;, &apos;EOS&apos;,
    (error, result) =&amp;gt; console.log(error, result));

// Parameters object
eos.getCurrencyStats({code: &apos;eosio.token&apos;, symbol: &apos;EOS&apos;})
    .then(console.log);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과값:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ EOS:
   { supply: &apos;1006148640.3388 EOS&apos;,
     max_supply: &apos;10000000000.0000 EOS&apos;,
     issuer: &apos;eosio&apos; } }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과값에 대한 설명은 아래와 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;supply
현재 공급된 토큰의 갯수 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;max_supply&lt;/p&gt;
&lt;p&gt;총 토큰의 갯수 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;issuer
발행자 입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;자주 쓰는 api들중 5개를 소개하는 시간을 가지게 되었습니다. 아직 더 많은 api들이 있고 다음 블로그에 이어서 많이 쓰는 api들에 대해서 연재할 계획입니다. 감사합니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;해당 예제는 &lt;a href=&quot;https://github.com/ITAMNETWORK/eosjs-api-example&quot;&gt;github&lt;/a&gt;에서 확인 하실 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;해당 게시글은 저의 블로그 혹은 itamnetwork 블로그에서 동일하게 확인 하실수 있습니다.&lt;/p&gt;
</content:encoded></item><item><title>eosJs 시작하기</title><link>https://blog.dongjun.win/posts/eosjs/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/eosjs/</guid><description>EOS 블록체인의 HTTP API를 JavaScript에서 다루기 위한 EOSJS 설치와 기본 연결 및 사용법을 정리합니다.</description><pubDate>Mon, 16 Jul 2018 05:19:00 GMT</pubDate><content:encoded>&lt;h2&gt;EOSJS 시작하기&lt;/h2&gt;
&lt;h4&gt;EOSJS란?&lt;/h4&gt;
&lt;p&gt;EOS 블록체인을 javascript로 좀더 편하게 컨트롤 할 수 있게 만들어 놓은 라이브러리라고 생각하면 편하다. 살짝만 깊게 들어가면 EOS에서 제공하는(nodeos) HTTP API를 이용하게 편하게 해 놓은 거라고 말 할 수 있다. (이더리움에는 &lt;a href=&quot;https://github.com/ethereum/web3.js&quot;&gt;web3.js&lt;/a&gt;, 네오에는 &lt;a href=&quot;https://github.com/CityOfZion/neon-js&quot;&gt;neon-js&lt;/a&gt;)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;EOSJS도 EOS에서 제공하는 HTTP API를 사용하기 때문에 BP들을 잘 선택해야된다.(응답속도, 제공여부 등등)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;설치&lt;/h4&gt;
&lt;p&gt;설치 방법으로는 2가지가 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;NPM을 통해서 간단하게 설치 할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;gt; npm install eosjs
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CNS를 이용하기&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/eosjs@15.0.3/lib/eos.min.js&quot;
        integrity=&quot;sha512-QX0dPq5pyX33coEuy5x1UqKHFDeveQYMp7Sz+qOUwRL9mol4QDvViU+QAjd+k6P7QjPjrDCoyhK1kz2GDxCP9A==&quot;
        crossorigin=&quot;anonymous&quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;EOS Connect&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;EOSJS를 이용한 EOS Connect&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const Eos = require(&apos;eosjs&apos;);

// 아래와 같이 하면 localhost Testnet에 접근한다.
const eos = Eos();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;localhost에 EOS가 구동 되어있지 않는다면 위의 코드를 실행하면 아래와 같은 에러가 난다. (당연히 connect하는 EOS의 httpEndpoint가 틀려도 아래와 같은 에러가 나온다.)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ FetchError: request to http://127.0.0.1:8888/v1/chain/get_info failed, reason: connect ECONNREFUSED 127.0.0.1:8888
    at ClientRequest.&amp;lt;anonymous&amp;gt; (/Users/mayajuni/Projects/eos-scan/node_modules/node-fetch/index.js:133:11)
    at ClientRequest.emit (events.js:182:13)
    at Socket.socketErrorListener (_http_client.js:382:9)
    at Socket.emit (events.js:182:13)
    at emitErrorNT (internal/streams/destroy.js:82:8)
    at emitErrorAndCloseNT (internal/streams/destroy.js:50:3)
    at process._tickCallback (internal/process/next_tick.js:63:19)
  name: &apos;FetchError&apos;,
  message:
   &apos;request to http://127.0.0.1:8888/v1/chain/get_info failed, reason: connect ECONNREFUSED 127.0.0.1:8888&apos;,
  type: &apos;system&apos;,
  errno: &apos;ECONNREFUSED&apos;,
  code: &apos;ECONNREFUSED&apos; }
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Mainnet connect 하기&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const Eos = require(&apos;eosjs&apos;);

const eos = Eos({httpEndpoint: &apos;mainnet httpEndpoint&apos;});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;아마 위와 같이 connect를 시도 한다면 아래와 같은 에러 메시지가 나온다.
&lt;img src=&quot;/images/posts/eosjs/connect-error.png&quot; alt=&quot;EOSJS 연결 오류 화면&quot; /&gt;&lt;/p&gt;
&lt;p&gt;에러가 나더라도 getInfo는 가능하네 이걸 통해서 chainId를 확인해서 넣자.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Mainnet인 경우는 aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906 이것이 공통으로 쓰는 chainId이기 때문에 넣으면 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같은 방법으로 connect를 하면 아래와 같이 최초 1회 get_info api를 호출한다. (처음에 이것도 모르고 매번 커넥트를 줬더니 모바일에서 데이터 사용량이 아주 높게 나온적이 있다.)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/posts/eosjs/network.png&quot; alt=&quot;EOSJS 네트워크 응답 화면&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;메인넷 주소는 https://api.eosnewyork.io 여기에서 확인 해 볼 수 있다. BP들마다 제공하는 정보의 양, 응답속도 등등이 다르기 때문에 확인을 해서 본인에게 가장 잘 맞는 BP를 찾는 것이 좋다.(혹은 본인이 직접 full node를 받아서 연결하는 방법도 있다.)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h6&gt;Connect Config&lt;/h6&gt;
&lt;pre&gt;&lt;code&gt;const Eos = require(&apos;eosjs&apos;);

const config = {
  chainId: null, // 32 byte (64 char) hex string
  keyProvider: [&apos;PrivateKeys...&apos;], // WIF string or array of keys..
  httpEndpoint: &apos;http://127.0.0.1:8888&apos;,
  expireInSeconds: 60,
  broadcast: true,
  verbose: false, // API activity
  sign: true
};

const eos = Eos(config);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이부분의 자세한 내용은 github에 있으니 여기에서 확인하면 된다.(&lt;a href=&quot;https://github.com/EOSIO/eosjs#configuration&quot;&gt;github-configuration&lt;/a&gt;)&lt;/p&gt;
&lt;h4&gt;getInfo - api&lt;/h4&gt;
&lt;p&gt;EOS의 기본 네트워크 정보를 가지고 올 수 있다. 코드는 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const eos = Eos().getInfo((error, info) =&amp;gt; {
    console.log(error, info);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;재미 있는 것은 다른 api들은 전부 Promise를 기본으로 하는데 getInfo만 callback방식이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;리턴된 값은 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    server_version: &apos;36a043c5&apos;,
    chain_id:
        &apos;aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906&apos;,
    head_block_num: 5469047,
    last_irreversible_block_num: 5468711,
    last_irreversible_block_id:
        &apos;00537227a657d1f4fd74de877e9ad1a3839540ece45eedeaf177b20e51b2da1b&apos;,
    head_block_id:
        &apos;005373770504c59e992214b3056c7bdabb07c53c5a9c4785909b90fe595a119f&apos;,
    head_block_time: &apos;2018-07-12T08:58:05.500&apos;,
    head_block_producer: &apos;helloeoscnbp&apos;,
    virtual_block_cpu_limit: 200000000,
    virtual_block_net_limit: 1048576000,
    block_cpu_limit: 199900,
    block_net_limit: 1048576
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위에서(EOS Connect) 애기 했던 chainId를 모를 경우 getInfo를 통해서 chainId를 얻어서 쓸 수 있다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 설치, connect, getInfo만 첫 걸음이라는 생각으로 가볍게 알아보았다.&lt;/p&gt;
</content:encoded></item><item><title>RethinkDB 소개</title><link>https://blog.dongjun.win/posts/rethinkdb/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/rethinkdb/</guid><description>실시간 변경 피드와 JSON 데이터 모델을 제공하는 RethinkDB의 특징, 설치와 기본 사용법을 소개합니다.</description><pubDate>Mon, 27 Mar 2017 04:18:00 GMT</pubDate><content:encoded>&lt;h2&gt;RethinkDB란?&lt;/h2&gt;
&lt;p&gt;Real-Time에 최적화된 오픈소스 데이터베이스라고한다. 그리고 확장 가능한 JSON 데이터 베이스이며, 전통적인 데이터베이스 아키텍처를 바꾸어 변경 사항을 폴링하는 대신 업데이트 된 쿼리 결과를 실시간, 지속적으로 push 할 수 있다고 한다.
특징을 설명하자면 아래와 같을꺼 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;실시간에 최적화 되어 있다.&lt;/li&gt;
&lt;li&gt;JSON 기반의 데이터베이스이다.&lt;/li&gt;
&lt;li&gt;업데이트가 발생 되었을 시 지속적/실시간으로 push를 해준다.&lt;/li&gt;
&lt;li&gt;확장이 쉬운 분산 데이터베이스&lt;/li&gt;
&lt;li&gt;실시간 웹 애플리케이션 구죽을 위한 &lt;code&gt;오픈소스&lt;/code&gt; 데이터 베이스&lt;/li&gt;
&lt;li&gt;웹UI 관리 콘솔을 제공한다.(서버 성능 확인, 쿼리테스트 데이터 테이블과 샤드 등등을 관리하는 도구이다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;RethinkDb와 실시간 동기화 서비스의 차이점은 무엇을까?&lt;/h3&gt;
&lt;p&gt;RethinkDb는 Firebase, pubNub, pusher와 같은 실시간 API와 근본적으로 다른 3가지가 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;실시간 동기화 API는 클라우드 서비스이고 RethinkDB는 오픈소스 프로젝트이다.&lt;/li&gt;
&lt;li&gt;실시간 동기화 API는 문서 동기화에만 국한되며, RethinkDB는 범용 데이터베이스 시스템이다. 테이블 조인, 하위쿼리, 지형공간 쿼리 등등을 포함한 쿼리를 실행 할 수 있다.&lt;/li&gt;
&lt;li&gt;실시간 동기화 API는 브라우저에서 직접 액세스하도록 설계되어 있다. 이러면 기본 앱을 쉽게 실행 할 수 있지만 앱이 확장되면 유연성이 제한된다. RethinkDB는 기존 데이터베이스와 같이 응용 프로그램 서버에서 엑세스 할 수 있도록 설계되어 있다. 쉽게 말해 많은 유연성을 가지고 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;RethinkDB와 MongoDB의 차이점은 무엇일까?&lt;/h3&gt;
&lt;p&gt;RethinkDB를 살펴 보면 Mongodb의 oplog가 생각이 든다. 물론 그거 말고도 비슷한 점이 많긴 하다. 하지만 기본적으로 다른 아키텍처를 기반으로 되어 있다. 개발자는 변경상항을 폴링하는 대신 실시간으로 업데이트 된 쿼리 결과를 계속 푸쉬하도록 RethinkDB에서 할 수 있다. 예로 들어 쿼리를 본다면 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;r.table(&apos;users&apos;).get(&apos;coffeemug&apos;).changes().run()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위에서 언급했지만 몽고디비의 oplog와 비교 될 수 있지만 oplog보다는 훨씬 노은 수준의 추상화를 제공한다.  RethinkDB의 피드는 쿼리 계산 엔진과 완벽하게 통합되므로 원시 복제 데이터뿐만 아니라 쿼리 결과의 변경 내용을 구독 할 수 있다. 이 아키텍처는 확장 가능한 실시간 응용 프로그램을 구축하는 데 필요한 시간과 노력을 크게 줄일 수 있다.&lt;/p&gt;
&lt;p&gt;이외에도 MongoDB에 비해 여러가지 장점을 제공한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테이블 조인, 하위 쿼리 및 대규모 병렬 분산 계산을 지원하는 고급 쿼리 언어다.&lt;/li&gt;
&lt;li&gt;우아하고 강력한 연산 및 모니터링 API로 쿼리 언어와 통합되며 RethinkDB를보다 쉽게 확장 할 수 있다.&lt;/li&gt;
&lt;li&gt;몇 번의 클릭만으로 샤드하고 복제 할 수있는 간단하고 아름다운 관리 UI 및 온라인 문서 및 쿼리 언어 제안을 제공한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;시스템 요구 사항&lt;/h3&gt;
&lt;p&gt;RethinkDB 서버는 C++로 작성되었으며, 32비트 및 64비트 리눅스 시스템과 OS X 10.7이상에서 실행 할 수 있다.
최소 2기가 이상의 램을 권장하지만 업격한 하드웨어 요구 사항은 없다.(다른 블로그를 보니 램이 부족해서 몽고디비로 이전했다는 사람도 있긴했다.)&lt;/p&gt;
&lt;h3&gt;라이센스는 무엇을까?&lt;/h3&gt;
&lt;p&gt;RethinkDB 서버 및 클라이언트 드라이버는 &lt;a href=&quot;http://www.apache.org/licenses/LICENSE-2.0.html&quot;&gt;Apache License 버전 2.0&lt;/a&gt; 에 따라 사용이 허가됩니다 .&lt;/p&gt;
&lt;p&gt;다음에는 간단한 설치부터 쿼리하는거까지 진행을 해보도록 하겠다.&lt;/p&gt;
&lt;p&gt;참고사항&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rethinkdb.com/faq/&quot;&gt;Rethink FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rethinkdb.com&quot;&gt;RethinkDB&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>2016년 회고</title><link>https://blog.dongjun.win/posts/2016/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/2016/</guid><description>여행, 이직, 공부와 가족의 변화를 돌아보며 2016년의 경험과 배움을 기록한 회고입니다.</description><pubDate>Fri, 30 Dec 2016 02:41:00 GMT</pubDate><content:encoded>&lt;p&gt;블로그를 운영 아닌 운영을 하면서 올해부터는 새롭게 회고를 해볼까 한다.&lt;br /&gt;
막상 회고를 한다고 하니 어떻게 글을 시작해야될지 머리속이 정리가 안된다. 또한 좋지 못하는 기억력을 다시한번 실감하게 되기도 하다.&lt;br /&gt;
시작을 어떻게 할까 고민을 하다가 카테고리를 나누어 써볼까 한다.&lt;/p&gt;
&lt;h2&gt;여행&lt;/h2&gt;
&lt;h5&gt;1. 일본(오키나와)&lt;/h5&gt;
&lt;p&gt;1월에 전에 다녔던 회사(컴팔)에서 처음으로 해외 워크샵(이라고 부르지만 그냥 놀러, 술마시러)을 갔다왔다. 오키나와는 일본의 휴양도시고 아주 좋다는 애기를 많이 들어 되게 기대했다. 하지만 정말 실망적이였다. 비싼 가격, 같이간 사람들, 아름답지 않은 날씨 등등이 나에게 있어서는 아주 매력적이지 않았다. 그 돈이면 차라리 동남아를 가고 말지 라는 생각도 많이 들었다. 음식도 비싸기만 했지 별로.... 내가 이렇게 실망한 가장 큰 이유는 여행 스타일때문인거 같기도 하다. 회사에서 간거라 그런지 계속 차타고 이동 내리고 구경 저녁에 술... 역시 나는 배낭을 매고 이리저리 내 발로 걸어 다니면서 다니는 여행이 나에게 맞는거 같다. 아니면 아에 휴양이거나, 같이 간 사람들도 회사 사람들이다 보니 더욱 별로였던거 같다.(이 회사는 사내정치가 심한 회사라 직원들끼리 파가 나누어져있었다) 위안으로 삼은건 내돈 주고 온게 아니고 회사돈으로 와서 다행이다(?)이정도...&lt;/p&gt;
&lt;h5&gt;2. 인도(+ 말레시아 잠시)&lt;/h5&gt;
&lt;p&gt;2014년도에 만난 인도 친구에게 결혼하면 꼭 결혼식에 참석하겠다라는 약속을 지키기 위해 떠난 여행이다. 다시 한번 인도에 대해 느끼게 되는 여행이기도 하다. 인도는 너무너무 힘들다..하하.... 내 여행인생 처음으로 비행기도 놓쳐보고(이때 진짜 인도가 싫었다. 그놈의 퍼스트서비스가 먼지 어떻게든 돈을 더 받아 먹을려고...에흉) 잠도 많이 못자기도 했다. 하지만 정말 좋은 점들도 있었다. 우선 정말 가족같은 우리 누나와 형님을 오랫만에 만난점.. 그리고 인도 친구의 결혼식(힌두결혼식)을 경험해 본것. 말레이시아에서 회사 직원과 함께 구경하고 밥먹었던 점은 정말 소중한 추억이고 기억이다. 아 특히 힌두 결혼식은 정말 특별하고 힘들었다. 이게 짧은거라고 하지만 나에게는 정말 하하... 결혼식 전날 신랑쪽(우리는 신부쪽이다)에서 마련한 공간에서 같이 춤추고 밥먹고 즐기는 것도 너무 재미 있었지만.. 결혼식 당일 새벽 4시에 일어나서 기도로 시작하고 결혼식장까지 가서 기도로 끝나는 모습은 정말... 하하... 종교가 대단하다는 생각도 많이 했다. 특히 인도 정통의상을 선물해 주고 그걸 입고 참석한 일, 외국인이라서 특히 2년전 약속을 지키기위해 온 우리를 좋게 생각해줘서 그런지 정말 이리저리 불려 많이 다녔다.. 5분도 못쉬고 정말 이리저리..(나의 사촌오빠의 엄마의 할아버지 등등) 그때 생각하면 참 힘들었지만 너무 즐거웠다.(하지만 힌두 결혼식은 2번 참석하기 힘들꺼 같다..하하) 마지막 돌아오는 하루는 말레이시아에 들렸다. 거기에서는 말레이시아 회사 직원이 차로 우리를 관광도 시켜줬다. 너무너무 고맙고 좋았던 기억이다. 거기서 잘때 호스텔에서 잤는데 특별했다 옥상에 올라가는 바가 있었는데 바로 쌍둥이 빌딩도 보이고 음악도 너무 좋고, 거기다 우리는 간단하게 맥주 마시는데 역시 배낭여행객이 많아서 그런데 너무 자연스럽게 외국인(우리도 외국인이긴하지만 하하)들과 함께 조인되어서 짧은 영어로 이리저리 애기하는게 너무 재미 있었다.&lt;/p&gt;
&lt;h5&gt;3. 베트남&lt;/h5&gt;
&lt;p&gt;회사를 그만두기로 하고 시간이 비어 떠난 베트남 여행이다. 하노이, 다낭, 호이안, 사파까지 비자기간인 2주를 거의 꽉 채웠다. 처음에는 혼자 먼저 떠나서 하노이에서 머물다가 와이프랑 와이프친구 만나고 와이프가 먼저 귀국하면 그다음에는 친동생같은 동생이 와서 같이 여행하는 좀 특이한 여행일정이였다. 하노이 생각하면 내가 지냈던 호스텔이 아직도 생각난다. 하룻밤에 6불인데 조식도 주고 밤에는 맥주도 무제한으로 줬었다..(남는게있어???) 특히 맥주 무제한 시간에는 이런 저런 사람들과 함께 애기하고 놀고 게임하는게 아주아주 재미있었다. 이때 영국남자 미국남자 미국커플 한국인들이랑 친해져서 같이 밥도 먹고.. 게임도 옆에 하노이 놀러온 중국인들과도 함께 했는데 너무너무 즐거웠다. 역시 우린 위아더 월드!!&lt;br /&gt;
와이프와서 같이간 다낭, 호이안 여기도는 하노이와 또 다른 매력을 지닌 곳이다. 여기에서 인생 조개요리를 먹었다. 로컬식당인데 영어를 못해서 서로 몸짓으로 음식 시키고 대화 했던거 생각하면 너무 재미있었다. 또 바로 옆 테이블 로컬 사람들이랑 같이 조인되서 영어를 못해 몸짓으로 서로 대화하고 맥주를 짝으로 마신 기억은 진짜 아우 ㅎㅎㅎ 너무 특별하다. 호이안은 너무 아름다운 도시였다. 하지만 여기서 진행했던 호핑투어는 진짜 아주 안좋은 기억이었다. 다행히 호텔에 컴플레인을 했더니 죄송하다고 하며, 돈을 깍아주고 공항까지 가는 택시를 지원해줬다. 나는 이런걸 원하는게 아니라서 거절을 막 했더니 이걸 안받으면 자신들이 혼난다고 하여 어쩔수 없이 받았다. 이 호텔은 정말 좋았다.. 다만 호핑투어가 안좋았을뿐...&lt;br /&gt;
다시 하노이 와서 와이프가 떠나고 동생이 왔다. 베트남에서 가장 좋았던 도시 &lt;code&gt;사파&lt;/code&gt;로 떠났다. 하노이는 너무나 더웠는데 사파는 아주 시원했다. 그리고 산에 있어서 그런지 뷰가 아주 그냥... 음식도 너무 맛나고.. 진짜 천국(?) 여기에서도 나의 오지랍(?)으로 현지인 베트남 사람과 식당에서 함께 음식을 먹고 술도 한잔하면서 놀았다..하하....
바디랭기쥐는 영어도 필요 없게 한다... 영어 따윈 훗! 베트남은 사랑이다!!!&lt;/p&gt;
&lt;h5&gt;4. 체코&lt;/h5&gt;
&lt;p&gt;어머니 환갑으로 떠나게 된 여행이다. 이 여행에서는 와이프에게 너무 고맙다. 시어머니랑 가는것도 쉽지 않은데 시어머니의 친구분(70대의 언니분이다. 이모라 부른다.)까지 함께 했으니 얼마나 힘들었을까... 모스크바를 들렸다가 체코 프라하를 갔다.
비행기는 러시아 항공을 이용했는데 만족스러웠다. 짐도 잃어버리지 않고.. 대망의 첫 모스크바.. 나는 정말 붉은성의 야경을 보고 싶었는데 못본게 가장 아쉽다. 하지만 모스크바(러시아)는 2번가진 않을꺼 같다. 물가도 비싸다능... 체코는 너무나 좋았다 특히 맥주는 아주 그냥.. 하루를 맥주로 시작하고 맥주로 끝냈다. 도시들도 너무나 아름답고 물가도 너무 싸다.. 만약 디지털 노마드를 한다고 하면 체코에서 살고 싶은 생각이다.. 아 첫 에어비앤비도 너무 저렴하고 좋았다.&lt;/p&gt;
&lt;h5&gt;5. 종합&lt;/h5&gt;
&lt;p&gt;올해만 4번이나 여행을 갔다 왔지만 항상 또 가고 싶다.. 그리고 귀국하면 느낌은 꼭 갔다오지 않은 듯한 느낌이다. 동내 근처 놀러갔다온 느낌?? 그리고 항상 영어를 잘하고 싶다라고 생각해도 작심 3일... 2017년에는 꼭 영어 공부를 잘 해야겠다. 앞으로 2017년에 이미 잡혀 있는 10월(보라카이)여행을 말고는 자제를 해야겠다.. 돈도 모아서 집을 사야지.. 아시아나 마일리지가 동남아 왕복권이 있어서 내년에 한번 더 갈 가능성은 있긴하다..하하..&lt;/p&gt;
&lt;h2&gt;가족&lt;/h2&gt;
&lt;p&gt;기억에 나는건 역시나 우리 할아버지께서 돌아가신거다. 할아버지를 생각하면 말이 없으시지만 우리를 챙겨주시는 모습, 할머니가 잔소리 할때마다 은근슬쩍 피하는 모습.. 그리고 뇌졸증으로 쓰러지시고나서 회복후에는 할머니와 함께 손잡고 다니는 모습.. 할머니도 걱정되면서 어머니도 걱정 되었다. 하지만 걱정과 다르게 이겨내시는 모습에 안심이 된다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;매년 경험하는 죽음.. 무언가 나에게 먼 느낌이면서도 아주 가까운 곳에 있는거 같다.. 여기서 다시 한번 생각한다. 나의 삶의 목표에 대해.. 그리고 중요한게 무엇인지 다시 한번 되세김 해본다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;회사&lt;/h2&gt;
&lt;h5&gt;컴팔&lt;/h5&gt;
&lt;p&gt;2년 넘게 함께한 그리고 아주 많은 일들이 있어던 회사(컴팔)에서 이직을 했다. 컴팔에서는 너무 편했다. 할일도 많지 않았고 내가 하고싶은데로 하면 되었기 때문이다. 하지만 나에게 있어서 너무 나태하게 하는 곳이기도 했다. 내가 짠 소스가 정확히 맞는 소스인지도 모르겠고, 누군가와 함게 개발을 하고 싶었다. 코드 리뷰도 하고 싶었고 개발적인 토론도 하고 싶었다. 컴팔은 나에게 아주 큰 공부도 되고 배움도 되는 곳이였지만 슬슬 도전을 해야 된다고 생각이 되는 시기였다.&lt;/p&gt;
&lt;h5&gt;트레포트&lt;/h5&gt;
&lt;p&gt;스카웃(면접제의)가 와서 면접을 보고 입사를 했다. 트레포트에서는 나를 포함한 우리 팀은 6명이였다. 신규 프로젝트를 진행했는데 내가 공부를 하고 있었던 angular2로 진행하였다. 여기서 문제는 나를 제외하고 전부 angular2를 몰랐다.. 한달간 리딩을 하고 공부를 진행하면서 프로젝트를 진행하였는데. 너무나도 고맙게 팀원들이 아주 잘 따라와 주었고 내가 만족할 정도로 포퍼먼스도 내주었다.(우리 팀원은 4년차 1명 2년차 1명 남어지는 신입이다..) rxjs와 data flow 기반의 코딩은 역시나 어려웠다. 나조차도 확실히 알지 못하는 상황에서 누군가를 가르친다는건 참 모순적이기도 했다. 그래도 팀원들이 아주 잘 따라와주었지만 아쉽게도 회사 사정상 첫번째 프로젝트는 중단하게 되었다. 이어서 한 두번째 프로젝트 기간은 길지 않았고 빠르게 진행하게 되었다. 그래서 우리는 이미 만들어진 컴포넌트를 찾게 되었고, 우리는 아이오닉을 기반으로 진행하게 되었다. 하지만 아이오닉은 자체 라우터를 제공하였고 우리는 그걸 사용하지 못했다. 이유는 아이오닉 자체 라우터는 url 방식이 아니기 때문이다. angular2 라우터를 붙이게 되었고 리덕스를 포함하여 프로젝트를 설계하고 진행하게 되었다. 하지만 트레포트와 인연이 여기까지였다. 나의 의지와 상관 없이 회사에서의 일방적인 통보.. 팀원들과 헤어진다는게 너무 아쉬웠고 팀원들도 같이 화를 내주었다.(2016년 말에 같이 한잔을 했다능..그리고 그 팀원들중에서도 회사를 나온 사람이 좀 있더라...)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;내가 만약 40대 50대 아이가 있는데 회사에서 갑자기 퇴사 통보를 한다고 하면.. 얼마나 당황하고 힘들까 라는 생각이 많이 들었다. 회사의 의존된 내가 되지 말고 회사와 관계 없이 나의 가치를 더 올려야겠다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5&gt;o2palm&lt;/h5&gt;
&lt;p&gt;트레포트를 들어가기전에 스카웃 제의를 받았던 곳이다. 트레포트를 뒤로 두고 입사하게 되었다. 여기는 아에 첫 스타트 업으로 아직 캐쉬카우가 없는 곳이여서 엄청 고민을 했던 곳이였다. 여기 회사는 되게 자유도가 높은 회사이다. 출퇴근 시간이 정해져 있지 않으며, 자택근무를 원하면 언제든 자택근무도 가능하다. 그리고 1년이 지나지 않아도 휴가를 묻지 않고 마음대로 쓸수 있는 곳이다. 원하면 굳이 한국에서 일 안해도 된다고 한다.. 역시 자유에는 그만큼의 책임이 있다. 오투팜은 철저한 성과위주이다. 성과가 없으면 연봉이 감봉된다. 역시나 성과가 좋으면 연봉이 상승되는 구조로 되어 있다. 아직 체계를 잡아가는 중이지만 많이 잡혀가고 있다. 모두다가 지라를 사용하고 있고(개발 뿐만 아니라 모든 직원 대표님까지), 슬랙을 이용하며, 리모트 환경을 잡아가고 있다. 현재 nodeJs와 angular2 그리고 아이오닉으로 개발을 진행하고 있다. 내가 항상 해왔던 것들.. 열심히 해야지..
다만 개발팀에서 가장 아쉬운건 신입 2명이다. 올해에는 솔찍히 실망이 컸다. 트레포트의 신입들과 비교가 많이 된다. 내년에는 좀더 잘할꺼라 생각을 하고 다시한번 기대를 해본다.&lt;/p&gt;
&lt;h2&gt;개발&lt;/h2&gt;
&lt;p&gt;2016년도는 기존 angular1에서 angular2로 바꾸기 시작했고 실제 프로젝트에서도 사용하기 시작했다. 그리고 스터디도 가장 많이 하게 되는 년도이기도 하다. 많은 선배님들이 도와주었고 특히 유자소프트에 합류하게 된것은 너무나도 좋은 기회였고 많은 것들을 배우고 있다.&lt;br /&gt;
프론트엔드 개발자로써 좀더 나아가기 위해서 어떻게 해야될까 라는 생각을 다시 한번 하게 되었다. javascript에 대해 공부를 좀더 하게 되었고, 오픈소스도 만들게 되었고 그리고 첫 오픈소스 첫 이슈도 등록 되었다.(아직 이슈 처리를 못했다. 얼렁해야지) 많은 글은 등록하지 못했지만 블로그도 시작하게 되었다.&lt;/p&gt;
&lt;h2&gt;마지막으로..&lt;/h2&gt;
&lt;p&gt;2016년도는 많은 일들이 있었다. 가장 힘든일은 커뮤니케이션에 대한 것이었다. 모든걸 솔찍하게 애기하는건 정말 좋지 않구나 라는 생각도 하게 되었고 말을 많이 안하는게 좀더 좋은 커뮤니케이션인가 라는 생각도 하게 되었다. 솔찍히 아직도 그건 나에게 하나의 숙제로 남아있다. 2017년에도 아마 이건 나의 고민이지 않을까 싶다. 그리고 2016년도에 나를 많이 도와주신 분들이 있는데 너무너무 감사하다고 전달하고 싶다. 특히 자존감에 대해 한번더 생각하게 해준 피터, 항상 내 옆에서 인생의 선배로 그리고 개발자의 방향으로 도움을 주는 동재형님, 나에게 항상 힘이 되는 자성이, 새로운 시각을 가지게 해준 마이클, 항상 개발자의 모습을 보여주고 조언을 해주는 성균형님 너무너무 감사하다.&lt;br /&gt;
그리고 항상 내 옆에서 응원해주고 힘이 되어 준 우리 와이프 너무 사랑한다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;항상 조금씩 0.000001미리라도 나아가야겠다. 자신감보다 자존감을 키우자.&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>es2015 요약</title><link>https://blog.dongjun.win/posts/es2015/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/es2015/</guid><description>ES2015에서 추가된 let, const, Set, Map, 화살표 함수, 클래스, 템플릿 문자열과 이터레이터 문법을 예제와 함께 정리합니다.</description><pubDate>Mon, 05 Dec 2016 05:59:02 GMT</pubDate><content:encoded>&lt;h2&gt;ES2015&lt;/h2&gt;
&lt;p&gt;ES2015는 Javascript의 버전이다.
Ecma라는 단체에서 기존의 결점을 보완한 표준 자바스크립트 버전을 매년 발표한다.
ES는 EcamaScript의 줄임말이다.&lt;/p&gt;
&lt;h2&gt;let&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;블록 스코프 변수(block scoped variable)이다.&lt;/li&gt;
&lt;li&gt;var가 함수 스코프 변수 이라는 점에서 대비된다.&lt;/li&gt;
&lt;li&gt;블록 단위안에서도 hoisting 되지 않는다.&lt;/li&gt;
&lt;li&gt;같은 스코프에서 재선언이 불가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;블록 스코프 변수 / 함수 스코프 변수 예제&lt;pre&gt;&lt;code&gt;if(true) {
    var a = &apos;a&apos;;
    let b = &apos;b&apos;;
    console.log(&apos;a =&amp;gt; &apos;, a); // a
    console.log(&apos;b =&amp;gt; &apos;, b); // b
}
console.log(&apos;a =&amp;gt; &apos;, a); // a
console.log(&apos;b =&amp;gt; &apos;, b); // b is not defined
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;hoisting 예제&lt;pre&gt;&lt;code&gt;if(true) {
    console.log(&apos;a =&amp;gt; &apos;, a); // undefined
    console.log(&apos;b =&amp;gt; &apos;, b); // b is not defined
    var a = &apos;a&apos;;
    let b = &apos;b&apos;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;같은 스코프에서 재선언 불가능 예제&lt;pre&gt;&lt;code&gt;var a = &apos;a&apos;;  // a = &apos;a&apos;
var a = &apos;aa&apos;; // a = &apos;aa&apos;
let b = &apos;b&apos;;  // b = &apos;b&apos;
let b = &apos;bb&apos;; // Identifier &apos;b&apos; has already been declared
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;const&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;읽기 전용 상수&lt;/li&gt;
&lt;li&gt;객체를 할당하면 참조값이 상수에 할당되므로 객체의 프로퍼티는 변경 가능&lt;/li&gt;
&lt;li&gt;const 또한 block scoping을 따르며 hoisting 되지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;읽기 전용 예제&lt;pre&gt;&lt;code&gt;const a = &apos;a&apos;; // a = &apos;a&apos;
a = &apos;b&apos;        // Assignment to constant variable.
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;프로퍼티 변경 예제&lt;pre&gt;&lt;code&gt;const a = {name: &apos;a&apos;};
console.log(&apos;a.name =&amp;gt; &apos;, a.name); // a
a.name = &apos;b&apos;;
console.log(&apos;a.name =&amp;gt; &apos;, a.name); // b
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Set - Collection&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Set은 유일한 값들로 구성된 Collection&lt;/li&gt;
&lt;li&gt;add한 순서대로 원소를 가지고 있다.&lt;/li&gt;
&lt;li&gt;중복된 값은 자동 삭제된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const a = new Set();
a.add(1).add(2).add(1); // Set {1, 2}
console.log(&apos;a size: &apos;, a.size); // 2
a.delete(2); // Set {1}
a.forEach(f =&amp;gt; console.log(f)); // 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;WeakSet - Collection&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;weakly하게 값을 참조한다는 뜻인데 WeakSet이 갖는 객체의 참조값이 다른곳에서 참조되지 않으면 객체는 garbage collect 대상&lt;/li&gt;
&lt;li&gt;객체 참조값만 가진다.&lt;/li&gt;
&lt;li&gt;iterable 객체가 아니다.&lt;/li&gt;
&lt;li&gt;.has(), .get(), .set(), .delete()만 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const ws = new WeakSet();
const value = {a: &apos;a&apos;};
ws.add(value).add({b: &apos;moon&apos;});
console.log(ws); // WeakSet {Object {b: &quot;moon&quot;}, Object {a: &quot;a&quot;}}
/* after the garbage Collection has run */
console.log(ws); // WeakSet {Object {a: &quot;a&quot;}}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Map - Collection&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;key-value로 이루어진 Collection&lt;/li&gt;
&lt;li&gt;iterable 객체&lt;/li&gt;
&lt;li&gt;삽입한 순서대로 원소를 가진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const map = new Map();
const obj = {a: 2};
map.set(obj, 2).set(1, 1).set(1, 2);
console.log(&apos;map has 1: &apos;, map.has(1)); // map has 1:  true
console.log(&apos;value of 1: &apos;, map.get(1)); // value of 1: 2
map.delete(1);
console.log(&apos;map has 1: &apos;, map.has(1)); // map has 1: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;WeakMap - Collection&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;WeakSet 과 비슷한 개념으WeakMap 의 key 가 약하게 참조된다.&lt;/li&gt;
&lt;li&gt;key는 객체참조 값만을가진다.&lt;/li&gt;
&lt;li&gt;iterable 객체가 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const ws = new WeakMap();
const obj = {a: &apos;a&apos;};
ws.set(obj, 1).set({b: 2}, 2);
console.log(ws); // WeakMap {Object {a: &quot;a&quot;} =&amp;gt; 1, Object {b: 2} =&amp;gt; 2}
/* after the garbage Collection has run */
console.log(ws); // WeakMap {Object {a: &quot;a&quot;} =&amp;gt; 1}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;arrow function&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;보다 간결한 구문을 지닌 익명함수이다.&lt;/li&gt;
&lt;li&gt;자기 고유의 this 를 갖지 않고, 외부 스코프의 this 를 그대로 가진다 (lexical
binding)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const func = (x, y) =&amp;gt; { return x + y; };
/* json을 리턴할 때에는 괄호를 넣어준다. */
const func2 = () =&amp;gt; ({ foo: 1 });
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;class&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;기존모델에 단지 새롭게 추가된 구문일 뿐,전혀 새로운 객체지향 모델이 아니다.&lt;/li&gt;
&lt;li&gt;생성자나 상속의 좀 더 간단하고 명확한 구문이 제공된다.&lt;/li&gt;
&lt;li&gt;생성자는 constructor 로 표현된다.&lt;/li&gt;
&lt;li&gt;클래스 바디는 중괄호 안에 두고 여기에 메소드를 function 키워드 없이 정의한다.&lt;/li&gt;
&lt;li&gt;메소드는 prototype 프로퍼티에 추가된다.&lt;/li&gt;
&lt;li&gt;class는 프로퍼티와 메소드로 이루어져 있다.&lt;/li&gt;
&lt;li&gt;class 는 extends 구문으로 다른 class 를 상속한다.&lt;/li&gt;
&lt;li&gt;자식 클래스에 constructor 가 없으면 부모의 그것이 자동으로 호출된다.&lt;/li&gt;
&lt;li&gt;생성자에서 super 키워드를 통해 상속 계층을 구현한다. this 보다 먼저 사용하지 않으면 예외가 발생한다.&lt;/li&gt;
&lt;li&gt;static 키워드를 통해 정적 메소드를 만들 수 있다. 이 메소드는 클래스 prototype 프로퍼티가 아닌 클래스 자체 메소드
다. (유틀리티 함수 작성에 쓰인다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;일반 예제&lt;pre&gt;&lt;code&gt;class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    static distance(a, b) {
        const dx = a.x - b.x;
        const dy = a.y - b.y;
        return Math.sqrt(dx*dx + dy*dy);
    }
}
const p1 = new Point(5, 5);
const p2 = new Point(10, 10);
console.log(Point.distance(p1, p2)); // 7.0710678118654755
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;상속&lt;pre&gt;&lt;code&gt;class Cat {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(this.name + &apos; makes a noise.&apos;);
    }
}
class Lion extends Cat {
    speak() {
        super.speak();
        console.log(this.name + &apos; roars.&apos;);
    }
}
const lion = new Lion(&apos;happy&apos;);
lion.speak();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Template string&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;백틱(Backtick)을 이용해 문자열을 만드는 새로운 방법&lt;/li&gt;
&lt;li&gt;코드의 가독성을 높여준다.&lt;/li&gt;
&lt;li&gt;${} 표현식을 사용하여 변수, 함수, 연산식 등을 표현 할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const name = &apos;dowon&apos;;
const myStr = `Hi ${name}. Have a great day!`;
console.log(myStr); // Hi dowon. Have a great day!
console.log(&quot;string text line 1\n&quot;+
&quot;string text line 2&quot;);
/* &quot;string text line 1
 string text line 2&quot; */
console.log(`string text line 1
string text line 2`);
/* &quot;string text line 1
 string text line 2&quot; */
const a = 5;
const b = 10;
console.log(`Fifteen is ${a + b} and not ${2 * a + b}.`); // Fifteen is 15 and not 20.
const a = () =&amp;gt; &quot;test&quot;;
console.log(`a: =&amp;gt; ${a()}`);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;for~of 구문&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;for~in 문은 객체의 열거가능한 모든 속성에 대해 반복했다면, for ~ of 문은 컬렉션의 요소를 반복.&lt;/li&gt;
&lt;li&gt;forEach(), for in 구문과 달리, break, continue, 그리고 return 구문과 함께 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;for~of 루프 구문은 data를 순회하기 위한 구문&lt;/li&gt;
&lt;li&gt;배열 뿐만 아니라 Collection 객체, DOM NodeList 등등 을 다를수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ex)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (let chr of &quot;12&quot;) {
console.log(chr);
}
//1
//2
let iterable = [10, 20, 30];
for (let value of iterable) {
    console.log(value);
}
let iterable = new Uint8Array([0x00, 0xff]);
for (let value of iterable) {
    console.log(value);
}
// 0
// 255
let iterable = new Map([[&quot;a&quot;, 1], [&quot;b&quot;, 2], [&quot;c&quot;, 3]]);
for (let entry of iterable) {
    console.log(entry);
}
// [a, 1]
// [b, 2]
// [c, 3]
for (let [key, value] of iterable) {
    console.log(value);
}
// 1
// 2
// 3
let iterable = new Set([1, 1, 2, 2, 3, 3]);
for (let value of iterable) {
    console.log(value);
}
// 1
// 2
// 3
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Iterator&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Iterator 는 새로운 문법이나 built-in 이 아니라 프로토콜(약속)이다.&lt;/li&gt;
&lt;li&gt;간단한 약속만 지키면 누구나 만들수 있다.&lt;/li&gt;
&lt;li&gt;Set Map Array 등이 Iterator 객체이다.&lt;/li&gt;
&lt;li&gt;Symbol.iterator 를 Key 로 갖는 속성이 반드시 존재해야 한다.&lt;/li&gt;
&lt;li&gt;다음 규칙에 따라 next() 메서드를 구현한 객체를 iterator 라고 한다:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;아래의 두 속성을 가지는 객체를 리턴하며 인자가 없는 함수:
1. done (boolean)
    - iterator 가 순회를 모두 마쳤을 경우 true
    - iterator 가 순회할 다음 value 가 존재할 경우 false
2. value - iterator 에 의해 리턴될 값. done 이 true 일 경우 생략 가능
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ex:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var iterable = {
  [Symbol.iterator]() {
    return {
      i: 0,
      next() {
        if (this.i &amp;lt; 3) {
          return { value: this.i++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};
for (var value of iterable) {
  console.log(value);
}
// 0
// 1
// 2
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Rest Parameter 와 Default Parameter&lt;/h2&gt;
&lt;p&gt;이건 코드로 확인하는게 가장 편하다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;rest parameter&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 사용법은 아래와 같다.
function(a, b, ...theArgs) {
   // ...
}
// ex
function restParam(...arg) {
    for(let val of arg) console.log(val);
}
restParam(1,2,3,4,5);
// es2
function restParam2(first, ...arg) {
    console.log(first); // 시작
    for(let val of arg) console.log(val);
    // 1
    // 2
    // 3
    // 4
    // 5
}
restParam(&apos;시작&apos;, 1,2,3,4,5);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Default Parameter&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;parameter 값이 없을 시 Default로 값을 넣어준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;function defaultParam(a = &apos;test&apos;, b = &apos;done&apos;) {
    console.log(`a: ${a}`);
    console.log(`b: ${b}`);
}
defaultParam();
//
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;기타&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const obj = {a:1, b:2};
const {a,b} = obj;
console.log(a,b); // 12
const a = [1,2,3];
const aCopy = [...a];
console.log(aCopy);
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>angular2 실무 프로젝트 회고</title><link>https://blog.dongjun.win/posts/angular2/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/angular2/</guid><description>Angular 2와 Ionic 2를 실무 프로젝트에 적용하며 TypeScript, RxJS, Redux, AOT와 모듈 설계에서 얻은 경험을 정리한 회고입니다.</description><pubDate>Wed, 30 Nov 2016 12:04:00 GMT</pubDate><content:encoded>&lt;p&gt;이것은 내가 실무로 프로젝트 리더이였을 때 느꼈던 회고를 기록으로 남긴다.&lt;/p&gt;
&lt;h2&gt;대화형 커머스 - 총 6명&lt;/h2&gt;
&lt;p&gt;Angular2 로 하는 첫 실무  프로젝트이며, 이 프로젝트는 angular2-seed 를 사용하여   진행하였습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;생각외로 typescript 의 진입장벽은 높지 않았고 팀원(신입도)들이 잘 받아드리고 편하게 사용했다.&lt;/li&gt;
&lt;li&gt;rxJs, redux 에 대해 팀원들을 이해시키기까지 어려웠다.(결국 이해한 팀원만 해당 기술 개발을 하고 남어지 팀원은 사용법만 익히게 하였다. 이와 같이 하니 어려움 없이 프로젝트 진행이 가능했다)&lt;/li&gt;
&lt;li&gt;Rc4 으로 시작해서 정식  버전까지 마이그레이션을  했었는데 가장 힘들었을 때가   ngModule 나올 때였다.(Rc5)&lt;/li&gt;
&lt;li&gt;ngModule 을 잘이해하고  설계를 잘해고 개발하면 아주 좋다.(이해하기 편할려면   angular1 의 모듈을 생각하면 될 듯하다.)&lt;/li&gt;
&lt;li&gt;개발하면서 redux 를 사용하였지만 이게 꼭 필요한가에 대해서는    아직도 의문점이다.(있으면 편하긴하다)&lt;/li&gt;
&lt;li&gt;JIT 컴파일보다 AOT 컴파일이 확실히 체감을 느낄  정도로 빠르다. 무조건   필수다.&lt;/li&gt;
&lt;li&gt;Tpyescript 의 타입과 인터페이스는 정말 개발자가 놓치고 있는 부분을 잡아주며, 오류도 적게 내면서 개발속도 즉 생산성도 높혀준다.(typescript 는 정말 좋다)&lt;/li&gt;
&lt;li&gt;Control flow 기반의 코딩이 너무 익숙하다 보니 Data flow 기반의 코딩이 쉽지 않다.(우린 둘다 잘해야된다.)&lt;/li&gt;
&lt;li&gt;rxJs 를 잘 알고 이해하면 angular2 의 코드의 질이   좋아진다.&lt;/li&gt;
&lt;li&gt;zoneJs 도 공부하면 좀더 품질의 코드가 나오지만 자료가 많지    않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;일정표 - 총 3명&lt;/h2&gt;
&lt;p&gt;대화형 커머스가 우선순위에서 밀리면서 시작한 새로운 프로젝트 입니다. 일정이 길지 않아서 ionic2 를 검토하고 잘 맞는다면 그것을 통해 진행을 하자고 했습니다. 확실히 ionic2 는 하이브리드 앱에 적합하였지만 모바일웹으로 사용하기에도 무리가 없었습니다. 그에 맞혀  바로 ionic2 를 가지고 요구  사항을 분석하고 설계하여 첫 번째 angular2 프로젝트의 회고를 토대로 개발을 진행하였고 현재도 진행하고 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ionic2 에는 라우터가 포함되어 있지 않다.(하지만 angular2 기반이기 때문에 포함 시킬 수 있다.)&lt;/li&gt;
&lt;li&gt;Router 를 썼을씨 html5 base url 을 설정해도 #가 붙는다.(ex.  domail.com/#/login)&lt;/li&gt;
&lt;li&gt;Angular2-seed 보다 설정이 간편하다.(설치만 하고 따로 셋팅을 하지 않아도 된다.)&lt;/li&gt;
&lt;li&gt;Ionic2 serve 는 많이 무겁다(메모리 릭이 잘난다)&lt;/li&gt;
&lt;li&gt;지원하는 component 가 많다.&lt;/li&gt;
&lt;li&gt;Service worker 를 사용한다.(주석 처리되어 있어서 주석만 풀면된다. 지원하지 않는 브라우저는 그냥 패스된다).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;코드리뷰 - 총 6명&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;서로의 실력 향상에 가장  좋은  방법이다.&lt;/li&gt;
&lt;li&gt;리뷰를 진행하는 사람의 역할이 크다.(리뷰를 하는 사람만 말하는 코드리뷰는  효과가    반감된다.)&lt;/li&gt;
&lt;li&gt;신입들은 꼭 1 질문씩 하는 것으로 진행하였다.(가만히  있는 것은 알고 있다고  생각하여    물어보았다)&lt;/li&gt;
&lt;li&gt;꼭 tsLint 나 jsLint 를 사용하여 코드리뷰 때 컨벤션에 대한 애기가    안나오게  해야된다.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>nodeJs mysql (async/await를 이용한 mySql 모듈 만들기)</title><link>https://blog.dongjun.win/posts/typescript-nodejs-mysql/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/typescript-nodejs-mysql/</guid><description>Node.js와 TypeScript에서 MySQL 연결 풀과 트랜잭션을 Promise 및 async/await 방식으로 감싸는 구현을 정리합니다.</description><pubDate>Mon, 11 Jul 2016 23:53:11 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/images/posts/typescript-nodejs-mysql/cover.png&quot; alt=&quot;Node.js와 MySQL&quot; /&gt;
nodeJs를 이용하여 mysql 혹은 mariaDB 등 RDB를 사용하는 경우가 많다. es7에 제안된 async/await를 사용하여 mysql 모듈을 만들어 볼까 한다.(모듈이라고 하지만 그저 wrapping 한거다.)&lt;/p&gt;
&lt;h2&gt;기존 사용 했던 mysql 코드&lt;/h2&gt;
&lt;p&gt;처음 mysql을 썼었을때 pool을 이용하여, 매번 connection을 맺고 끊어주고, 또 트랜젝션을 맺고 롤백과 commit을 해주는 코드를 썼다.&lt;/p&gt;
&lt;p&gt;아마 대부분이 아래와 같을 것이다.:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const mysql = require(&apos;mysql&apos;);
const DBpool  = mysql.createPool({
  connectionLimit : 10,
  host            : &apos;example.org&apos;,
  user            : &apos;bob&apos;,
  password        : &apos;secret&apos;,
  database        : &apos;my_db&apos;
});

const get = id =&amp;gt; {
    DBpool.getConnection((err, con) =&amp;gt; {
        if (err) {
            throw err;
        }
        con.query(&apos;select * from user where id= ?&apos;, [id], (err, data) =&amp;gt; {
            con.release();
            ...
        });
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜젝션을 사용:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// pool은 생략
const insert = id =&amp;gt; {
    DBpool.getConnection((err, con) =&amp;gt; {
        if (err) {
            throw err;
        }
        con.beginTransaction(err =&amp;gt; {
            if (err) {
                con.release();
                throw err;
            }

            con.query(&apos;select * from user where id = ?&apos;, [id], (err, data) =&amp;gt; {
                if(err) {
                    return con.rollback(() =&amp;gt; {
                        con.release();
                        throw err;
                    });
                }

                con.query(&apos;insert into user (name) values (?)&apos;, [data[0].name], (err, data) =&amp;gt; {
                    if(err) {
                        return con.rollback(() =&amp;gt; {
                            con.release();
                            throw err;
                        });
                    }

                    con.commit((err) =&amp;gt; {
                        if (err) {
                            return con.rollback(() =&amp;gt; {
                                con.release();
                                throw err;
                            });
                        }
                        return con.release();
                    });
                });
                ...
            });
        });
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 되면 매번 db 작업을 할때마다 connection 맺어주고 끊어주는 중복된 코드를 작성해야되며, 트렌젝션을 사용할 때는 콜백헬과 좀더 더 긴 코드를 매번 처리해줘야된다.&lt;/p&gt;
&lt;p&gt;필자는 이렇게 하는 것이 너무나도 마음에 안들었고 매번 중복된 코드를 쓰는게 너무너무 귀찮아서 아래와 같이 만들어서 사용했다.&lt;/p&gt;
&lt;h2&gt;1. 시작하기&lt;/h2&gt;
&lt;p&gt;async/await를 사용하기 위해서는 &lt;a href=&quot;https://babeljs.io/&quot;&gt;Babel&lt;/a&gt;을 사용하거나 &lt;a href=&quot;https://www.typescriptlang.org/&quot;&gt;Typescript&lt;/a&gt; 같은 것을 사용해야된다. 필자는 Typescript를 사용하기 때문에 Typescript로 진행 하겠다.&lt;/p&gt;
&lt;p&gt;기본 설정:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;NodeJs 설치&lt;/li&gt;
&lt;li&gt;Typescript 설치&lt;/li&gt;
&lt;li&gt;Typings 설치&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;자세한 설정은 &lt;a href=&quot;https://mayajuni.github.io/2016/06/30/typescript-express_%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0/&quot;&gt;Typescript + ExpressJs 시작하기&lt;/a&gt;를 참고하여 진행하면 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;2. promise-mysql&lt;/h2&gt;
&lt;p&gt;async/await는 전에 &lt;a href=&quot;https://mayajuni.github.io/2016/07/04/expressJs-error/&quot;&gt;ExpressJs Error&lt;/a&gt;에서 설명 했듯이 모든 리턴은 promise로 받아야된다. 그래서 기존 mysql은 callback 기반이기 때문에 사용하지 못하고 npm에 있는 &lt;a href=&quot;https://www.npmjs.com/package/promise-mysql&quot;&gt;promise-mysql&lt;/a&gt; 모듈을 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install --save promise-mysql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;promise-mysql모듈은 typings에 없기 때문에 설치를 하지 않고 진행한다.&lt;/p&gt;
&lt;h2&gt;3. Module 만들기&lt;/h2&gt;
&lt;p&gt;기존에는 모든 함수에 connection 맺고 끊는 혹은 콜백하고 커밋하는 코드를 넣어줬다. 이제 그부분을 분리하여, 모듈로 만들 것이다.&lt;/p&gt;
&lt;h4&gt;1) connection&lt;/h4&gt;
&lt;p&gt;내가 생각하는 순서는 다음과 같다.:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;function을 받는다.&lt;/li&gt;
&lt;li&gt;받은 function의 paramter들을  &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Functions/arguments&quot;&gt;&quot;...args&quot;&lt;/a&gt;를 사용하여 args에 담는다.&lt;/li&gt;
&lt;li&gt;connection을 맺고 connection 객체를 생성한다.&lt;/li&gt;
&lt;li&gt;받은 function을 connection객체와 함께 기존 paramter(args)를 넘겨주어 실행 시킨다.&lt;/li&gt;
&lt;li&gt;catch를 통해 error가 있을시 connection을 닫아주고 throw error을 해준다.&lt;/li&gt;
&lt;li&gt;error가 없을시에는 connection을 닫아주고 실행된 function을 값을 넘겨준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같이 생각을 했으면, 아마 아래와 같은 코드가 나올 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/**
 * 기존 import 하는 방식이 아닌 이유는 promise-mysql은
 * 정의 파일(typings)이 없기 때문에 아래와 같이 쓴다.
 */
const promiseMysql = require(&apos;promise-mysql&apos;);

const pool  = promiseMysql.createPool({
  connectionLimit : 10,
  host: &apos;example.org&apos;,
  user: &apos;bob&apos;,
  password: &apos;secret&apos;,
  database: &apos;my_db&apos;
});

export const connect = fn =&amp;gt; async (...args) =&amp;gt; {
    /* DB 커넥션을 한다. */
    let con: any = await pool.getConnection();
    /* 로직에 con과 args(넘겨받은 paramter)를 넘겨준다. */
    const result = await fn(con, ...args).catch(error =&amp;gt; {
        /* 에러시 con을 닫아준다. */
        con.connection.release();
        throw error;
    });
    /* con을 닫아준다. */
    con.connection.release();
    return result;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) 트렌젝션 모듈&lt;/h4&gt;
&lt;p&gt;트렌젝션 모듈도 위의 connection모듈과 크게 다르지 않을것이다. 그저 롤백과 커밋이 들어간것이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;function을 받는다.&lt;/li&gt;
&lt;li&gt;받은 function의 paramter들을  &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Functions/arguments&quot;&gt;&quot;...args&quot;&lt;/a&gt;를 사용하여 args에 담는다.&lt;/li&gt;
&lt;li&gt;connection을 맺고 connection 객체를 생성한다.&lt;/li&gt;
&lt;li&gt;트렌젝션을 시작하는 코드를 넣는다.&lt;/li&gt;
&lt;li&gt;받은 function을 connection객체와 함께 기존 paramter(args)를 넘겨주어 실행 시킨다.&lt;/li&gt;
&lt;li&gt;catch를 통해 error가 있을시 rollback과 connection을 닫아주고 throw error을 해준다.&lt;/li&gt;
&lt;li&gt;error가 없을시에는 commit과 connection을 닫아주고 실행된 function을 값을 넘겨준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위와 같이 생각을 했으면, 아마 아래와 같은 코드가 나올 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// pool 생략
export const transaction = fn =&amp;gt; async (...args) =&amp;gt; {
    /* DB 커넥션을 한다. */
    const con: any = await pool.getConnection();
    /* 트렌젝션 시작 */
    await con.connection.beginTransaction();
    /* 비지니스 로직에 con을 넘겨준다. */
    const result = await fn(con, ...args).catch(async (error) =&amp;gt; {
        /* rollback을 진행한다. */
         await con.rollback();
        /* 에러시 con을 닫아준다. */
        con.connection.release();
        throw error;
    });
    /* commit을 해준다. */
    await con.commit();
    /* con을 닫아준다. */
    con.connection.release();
    return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 만든 모듈을 하나로 합치고 mysql모듈이라고 명칭하면 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/**
 * 기존 import 하는 방식이 아닌 이유는 promise-mysql은
 * 정의 파일(typings)이 없기 때문에 아래와 같이 쓴다.
 */
const promiseMysql = require(&apos;promise-mysql&apos;);
import * as dotenv from &apos;dotenv&apos;;

dotenv.config({
    silent: true,
    path: &apos;.env&apos;
});

const pool = promiseMysql.createPool({
    connectionLimit : 10,
    host: process.env.MYSQL_HOST,
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_PASSWORD,
    database: process.env.MYSQL_DB
});

export module mysql {
    export const connect = fn =&amp;gt; async (...args) =&amp;gt; {
        /* DB 커넥션을 한다. */
        const con: any = await pool.getConnection();
        /* 로직에 con과 args(넘겨받은 paramter)를 넘겨준다. */
        const result = await fn(con, ...args).catch(error =&amp;gt; {
            /* 에러시 con을 닫아준다. */
            con.connection.release();
            throw error;
        });
        /* con을 닫아준다. */
        con.connection.release();
        return result;
    };

    export const transaction = fn =&amp;gt; async (...args) =&amp;gt; {
        /* DB 커넥션을 한다. */
        const con: any = await pool.getConnection();
        /* 트렌젝션 시작 */
        await con.connection.beginTransaction();
        /* 비지니스 로직에 con을 넘겨준다. */
        const result = await fn(con, ...args).catch(async (error) =&amp;gt; {
            /* rollback을 진행한다. */
             await con.rollback();
            /* 에러시 con을 닫아준다. */
            con.connection.release();
            throw error;
        });
        /* commit을 해준다. */
        await con.commit();
        /* con을 닫아준다. */
        con.connection.release();
        return result;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면 mysql 모듈이 완성이다.&lt;/p&gt;
&lt;h2&gt;4. 사용법&lt;/h2&gt;
&lt;p&gt;일반 connection 사용:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 위에 만든 mysql 모듈이다. */
import {mysql} from &quot;mysql&quot;

const get = mysql.connect((con: any, id: string) =&amp;gt; con.query(&apos;select * from user&apos;, [id]));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;너무 간단하게 한줄로 끝내버렸다. 물론 단순 select한 값을 리턴했기 때문에 위와 같이 한줄로 나올수 있는 것이다. 만약 다른 비지니스 로직이 있다고 하면 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 위에 만든 mysql 모듈이다. */
import {mysql} from &quot;mysql&quot;

const get = mysql.connect(async (con: any, id: string) =&amp;gt; {
        const result = await con.query(&apos;select * from user&apos;, [id]);

        // ...비지니스로직...

        return result
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;굳이 동기로 할 필요 없을시에는 async를 빼도 된다.&lt;/p&gt;
&lt;p&gt;트랜젝션을 사용:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 위에 만든 mysql 모듈이다. */
import {mysql} from &quot;mysql&quot;

const insert = mysql.transaction(async (con: any, id: string) =&amp;gt; {
    const user = await con.query(&apos;select * from user where id = ?&apos;, [id]);
    await con.query(&apos;insert into user (name) values (?)&apos;, [user[0].name]);
    /* 리턴할 값이 없을시 그냥 return만 써도 된다. */
    return user;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜젝션을 사용하는 코드는 더욱더 짧아진 코드량을 볼 수 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;이 모듈에 대한 예제를 &lt;a href=&quot;https://github.com/mayajuni/async-await-mysql&quot;&gt;github&lt;/a&gt;에 올렸다. 한번 보면 좀더 이해하기 편할 것이다. 도움이 되었다면 위의 별도 한번 눌러 주는 센스!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;반말로 블로그를 작성하였는데 이해해주시기 바랍니다. 문의 및 수정 사항은 댓글이나 mayajuni10@gmail.com으로 이메일 보내주시기 바랍니다.&lt;/code&gt;&lt;/p&gt;
</content:encoded></item><item><title>ExpressJs Error</title><link>https://blog.dongjun.win/posts/expressjs-error/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/expressjs-error/</guid><description>Express.js에서 동기 및 비동기 오류를 미들웨어로 전달하고 환경별로 응답을 구성하는 에러 처리 방식을 정리합니다.</description><pubDate>Mon, 04 Jul 2016 00:25:35 GMT</pubDate><content:encoded>&lt;p&gt;저번 포스트에서는 서버 구동에 대해서 포스팅했다. 이번에는 Error 처리에 대해 포스팅 하겠다.&lt;/p&gt;
&lt;h2&gt;기본적인 에러처리&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/&apos;, (req, res) =&amp;gt; {
    throw new Error(&apos;에러 발생&apos;)
})
app.use((err, req, res, next) =&amp;gt; {
    console.log(err.message)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 처리를 하면 큰 문제가 생긴다. 아래와 같이 callback을 받아 error 처리를 할 시 절대로 에러를 잡지 못한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/&apos;, (req, res) =&amp;gt; {
    callback(error =&amp;gt; {
        throw new Error(&apos;에러 발생&apos;)
    });
})

app.use((err, req, res, next) =&amp;gt; {
    console.log(err.message)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그래서 하는 방식이 next를 통한 에러를 전달하는 방식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/&apos;, (req, res, next) =&amp;gt; {
    callback(error =&amp;gt; {
        if(error) return next(error);

        callback2(error =&amp;gt; {
            if(error) return next(error);
        });
    });
})
app.use((err, req, res, next) =&amp;gt; {
    console.log(err.message)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 처리시 2가지의 문제점을 가지고 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;로직 및 모든 부분에 Error 처리를 해줘야 한다.
(이거 의외로 되게 유지보수하기 힘들고, 귀찮은 작업이다. 깜빡 한번하면 그냥 죽어버린다.)&lt;/li&gt;
&lt;li&gt;내가 Error 처리를 하지 못하는 부분에서는 Error처리를 할 수 없다.(모듈 안에서 에러가 났던가, 기타 등등)&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;위와 같은 문제 때문에 필자는 &lt;a href=&quot;https://github.com/brianc/node-domain-middleware&quot;&gt;node-domain-middleware&lt;/a&gt; 미들웨어를 사용하여 에러처리를 했다.(편하고 좋았다.) 하지만 &lt;a href=&quot;http://expressjs.com/ko/advanced/best-practice-performance.html#section-8&quot;&gt;expressJs의 성능우수 사례&lt;/a&gt;를 보면 도메인 사용을 권장하지 않고 더이상 사용되지 않는 모듈이라고 되어 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;프로미스를 이용한 에러 처리&lt;/h2&gt;
&lt;p&gt;es2015에 있는 &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise&quot;&gt;Promise&lt;/a&gt;를 이용하여 Error 처리 하는 방식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/&apos;, (req, res, next) =&amp;gt; {
    callback()
        .then(_ =&amp;gt; {
            // 로직1
        })
        .then(_ =&amp;gt; {
            // 로직2
        })
        .catch(next);
})
app.use((err, req, res, next) =&amp;gt; {
    console.log(err.message)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 하면 catch를 통해 promise로 처리하는 부분의 모든 error를 처리 할 수 있다. 위에 단점으로 적었던 2가지 전부를 해결 할 수 있다. 나의 생각 일 수 있지만 코드도 좀더 간결해 보인다(아닐수도 있다.)
하지만 여기서 코드를 좀더 간결하게 해보도록 하겠다. es7 스팩인 async/await를 이용할 것이다.&lt;/p&gt;
&lt;h3&gt;async/await 이용한 에러 처리&lt;/h3&gt;
&lt;p&gt;async/await가 다소 생소 할 수 있다. 비동기 코드를 동기화 간편하게 해주는 것이다. es7에 제안된 스팩이며, 자세한 내용은 &lt;a href=&quot;https://blogs.msdn.microsoft.com/typescript/2015/11/03/what-about-asyncawait/&quot;&gt;이곳&lt;/a&gt;을 보자. 구글 검색해도 많이 나온다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;async/await 사용하기 위해서는 &lt;a href=&quot;https://babeljs.io/docs/usage/cli/&quot;&gt;Babel&lt;/a&gt; 혹은 &lt;a href=&quot;https://www.typescriptlang.org/&quot;&gt;typescript&lt;/a&gt; 등을 사용해야된다. 필자는 typescript를 사용한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;처음에는 우선 아래와 같이 (req, res, next) 부분을 감싸도록 하겠다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;const wrap = fn =&amp;gt; (...args) =&amp;gt; fn(...args).catch(args[2]);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;[&quot;...args&quot;](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Functions/arguments)는 es2015 문법이다. 이렇게 사용하면 args안에 parameter 값들이 순차적으로 들어가게된다.
wrap은 함수를 받아서 그 함수를 실행 하고 catch를 통해 error가 발생시 next(error)을 해주는 역활이다. router에서는 기본적으로 paramter를 req, res, next를 주기 때문에 args[2]는 next 이다.

이걸 풀어서 아래와 같이 할 수 있다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;const wrap = fn =&amp;gt; (req, res, next) =&amp;gt; fn(req, res, next).catch(next);
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;만든 wrap을 아래와 같이 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;app.get(&apos;/&apos;, wrap(async (req, res, next) =&amp;gt; {
    let data = await callback();
    let data2 = await 로직1();
    /* 리턴 값이 없음 아래와 같이 써도 된다. */
    await 로직2();
}))
app.use((err, req, res, next) =&amp;gt; {
    console.log(err.message)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;코드를 보면 짐작하시겠지만 async/await를 통해 비동기 로직을 동기식으로 간편하게 로직처리 한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;여기에도 유의점이 있다. 이것을 사용하기 위해서는 return promise 이어야된다. 단순 callback에 대한 처리를 할 수 없다. 하지만 많은 모듈 혹은 미들웨어가 promise를 제공(?)하기 때문에 사용하기에는 불편함이 없다.(기존 로직은 async/await로 처리하면되며, mongoose나 mysql 같은경우 이미  promise를 사용 할 수 있어 큰 불편이 없다.)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;직접 이렇게 하고 사용을 하면 생산성이 확실히 빨라진다. 코드도 짧아지고 가독성도 좋아 진다. 이렇게 한번 쓰기 시작하면서 모든 노드 프로젝트는 이 방식으로 개발하고 있다. 필자는 이 방식(방법)을 추천한다.&lt;/p&gt;
&lt;h5&gt;참고&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://strongloop.com/strongblog/async-error-handling-expressjs-es7-promises-generators/&quot;&gt;Asynchronous Error Handling in Express with Promises, Generators and ES7&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;http://expressjs.com/ko/advanced/best-practice-performance.html&quot;&gt;프로덕션 우수 사례: 성능 및 신뢰성&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;반말로 블로그를 작성하였는데 이해해주시기 바랍니다. 문의 및 수정 사항은 댓글이나 mayajuni10@gmail.com으로 이메일 보내주시기 바랍니다.&lt;/code&gt;&lt;/p&gt;
</content:encoded></item><item><title>Typescript + ExpressJs 시작하기</title><link>https://blog.dongjun.win/posts/typescript-express/</link><guid isPermaLink="true">https://blog.dongjun.win/posts/typescript-express/</guid><description>TypeScript와 Express.js로 REST API 프로젝트를 구성하고 테스트, 빌드, 실행하는 과정을 예제로 정리합니다.</description><pubDate>Thu, 30 Jun 2016 00:07:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/images/posts/typescript-express/cover.jpg&quot; alt=&quot;TypeScript, Express.js와 Node.js&quot; /&gt;&lt;/p&gt;
&lt;p&gt;최근 &lt;a href=&quot;https://angular.io/&quot;&gt;Angular2.0&lt;/a&gt; 을 스터디 하면 &lt;a href=&quot;http://www.typescriptlang.org/&quot;&gt;Typescript&lt;/a&gt;를 알게 되었다. 사용하면서 모든 javascript에 적용을 시키면 정말 편할꺼 같아서 개인적으로 Restful Api 서버를 만들어 보고, 그걸을 토대로 기록을 남긴다.&lt;/p&gt;
&lt;h2&gt;시작하기 및 설정&lt;/h2&gt;
&lt;p&gt;시작하기 전에 먼저 설치를 해야된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nodejs.org/en/&quot;&gt;NodeJs&lt;/a&gt; 6버젼 이상을 추천한다.(es2015지원이 빵빵하다!)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;http://www.typescriptlang.org/&quot;&gt;Typescript&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NodeJs는 해당 홈페이지 들어가서 다운로드를 받고 설치하면 문제 없지 진행 할 수 있다.
Typescript 설치는 터미널을 열고 아래와 같이 npm으로 간편하게 설치가 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g typescript
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1. 프로젝트 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;mkdir myapp
cd myapp
npm init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;npm init을 했을시 package.json을 생성시켜주지만 직접 파일로 만들어도 된다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;package.json
: 필요한 노드 모듈을 정의하고 프로젝트 설명이 기록되어 있다. 또한 npm 실행 script도 사용할수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;myapp&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;&quot;,
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;echo \&quot;Error: no test specified\&quot; &amp;amp;&amp;amp; exit 1&quot;
  },
  &quot;author&quot;: &quot;&quot;,
  &quot;license&quot;: &quot;ISC&quot;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;초기 셋팅을 하면 위와 같이 된다.&lt;/p&gt;
&lt;h3&gt;2. expressJs 설치&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;npm install --save express
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 express 설치를 하면 package.json에 아래와 같이 추가가 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;dependencies&quot;: {
    &quot;express&quot;: &quot;^4.14.0&quot;
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. typings&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;typings
: 타입스크립트에서 사용되는 모듈 혹은 라이브러리 등등의 정의가 있는 파일이다.(쉽게 말해 자동완성 기능을 해준다.) 기능과 사용법 자세한 설명은 &lt;a href=&quot;https://github.com/typings/typings&quot;&gt;Typings&lt;/a&gt;에서 보자&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;설치와 설정은 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g typings
typings init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;typings init을 하면 typings.json이 생성된다. 여기에 우리가 설치한 Definition File들이 기록된다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;NodeJs를 통해 사용되는 모듈뿐만 아니라 그 이외의 수많은 Definition이 있기 때문에 검색 후 설치하는 것을 권장한다. typings search [모듈이름] 으로 찾을 수 있으며, typings install로 설치가 가능하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;typings.json이 만들어졌으면, 우리가 사용한 모듈이랑 노드에 대해 설치를 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typings install env~node --save --global
typings install dt~express --save --global
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위의 문법은 &lt;a href=&quot;https://github.com/typings/typings&quot;&gt;Typings&lt;/a&gt;에 가면 설명나와 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;아마 위에 2개만 설치하고 타입스크립트 컴파일을 하면 에러가 떨어질 것이다. 이유는 express 정의 파일안에 serve-static, express-serve-static-core 파일을 import 하는 부분이 있다. 또 serve-static 안에 mime라는 정의를 임포트 하기 때문에 같이 설치한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;typings install dt~serve-static --save --global
typings install dt~express-serve-static-core --save --global
typings install dt~mime --save --global
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설치가 완료 되면 typings.json을 보면 아래와 같이 되어 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;myapp&quot;,
  &quot;dependencies&quot;: {},
  &quot;globalDependencies&quot;: {
    &quot;express&quot;: &quot;registry:dt/express#4.0.0+20160317120654&quot;,
    &quot;express-serve-static-core&quot;: &quot;registry:dt/express-serve-static-core#0.0.0+20160625155614&quot;,
    &quot;node&quot;: &quot;registry:env/node#6.0.0+20160622202520&quot;,
    &quot;mime&quot;: &quot;registry:dt/mime#0.0.0+20160316155526&quot;,
    &quot;serve-static&quot;: &quot;registry:dt/serve-static#0.0.0+20160606155157&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;먼저 파일을 만들고 위와 같이 작성후 &lt;code&gt;typings install&lt;/code&gt;로 한꺼번에 설치가 가능하다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;모든 모듈, 라이브러리 등등에 정의 파일이 존재하지 않는다. 그래서 정의 파일을 사용하지 않아도 오류 없이 사용이 가능하다.&lt;code&gt;const redisStore = require(&quot;connect-redis&quot;);&lt;/code&gt; 이와 같이 선언하면 정의 파일 없이도 에러 없이 사용 가능하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4. typescript 설정&lt;/h3&gt;
&lt;p&gt;typescript를 사용하면 tsconfig.json라는 파일을 만들어서 설정을 진행 할 수 있다. 자세한 설명은 &lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/tsconfig-json.html&quot;&gt;공식홈페이지&lt;/a&gt;에서 확인 할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;compilerOptions&quot;: {
    &quot;target&quot;: &quot;es6&quot;,
    &quot;module&quot;: &quot;commonjs&quot;,
    &quot;moduleResolution&quot;: &quot;node&quot;,
    &quot;sourceMap&quot;: true,
    &quot;emitDecoratorMetadata&quot;: true,
    &quot;experimentalDecorators&quot;: true,
    &quot;removeComments&quot;: false,
    &quot;noImplicitAny&quot;: false
  },
  &quot;exclude&quot;: [
    &quot;typings&quot;,
    &quot;node_modules&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위의 내용으로 파일을 만든다.&lt;/p&gt;
&lt;p&gt;이제 거의 완성이 되었다. 이렇게 되면 아래와 같은 구조가 된다.(하위 폴더는 생략)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;├── node_modules
├── typings
├── package.json
├── typings.json
└── tsconfig.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;코딩 시작&lt;/h2&gt;
&lt;p&gt;여기까지 오셨으면 설치 및 설정까지 완료된 것이다. 이제부턴 코드를 작성하겠다.
es2015를 기반으로 사용할것이며, 기본적으로 es2015를 공부하면 좀더 좋다. 물론 es5로 코딩도 가능하다.
post, get, delete, put 메소드를 사용하여 {result : Hello world}를 리턴을 목표로 한다&lt;/p&gt;
&lt;h3&gt;1. 테스트 코드 만들기&lt;/h3&gt;
&lt;p&gt;우리가 만든 예제가 잘 돌아가는지 테스트를 하기 위해 mocha를 이용하여 테스트 코드를 만든다. 테스트 코드에 대해서는 설명을 하진 않겠다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g mocha
npm install --save-dev should
npm install --save-dev supertest

typings install dt~mocha --save --global
typings install dt~should --save --global
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위에 것들을 다 설치하면 바로 test 폴더를 만들고 그안에 app.spec.ts 파일을 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const request = require(&apos;supertest&apos;);
require(&apos;should&apos;);

const server: any = request.agent(&apos;http://localhost:3000&apos;);

describe(&apos;테스트 시작&apos;, () =&amp;gt; {
    it(&apos;GET&apos;, done =&amp;gt; server.get(&apos;/&apos;).expect(200).expect(&quot;Content-type&quot;,/json/)
        .end((err, res) =&amp;gt; {
            if(err) throw err;
            res.body.should.be.a.Object();
            res.body.should.have.property(&apos;result&apos;);
            res.body.result.should.equal(&apos;Hello World&apos;);
            done();
        }));
    it(&apos;POST&apos;, done =&amp;gt; server.post(&apos;/&apos;).expect(200).expect(&quot;Content-type&quot;,/json/)
        .end((err, res) =&amp;gt; {
            if(err) throw err;
            res.body.should.be.a.Object();
            res.body.should.have.property(&apos;result&apos;);
            res.body.result.should.equal(&apos;Hello World&apos;);
            done();
        }));
    it(&apos;DELETE&apos;, done =&amp;gt; server.delete(&apos;/&apos;).expect(200).expect(&quot;Content-type&quot;,/json/)
        .end((err, res) =&amp;gt; {
            if(err) throw err;
            res.body.should.be.a.Object();
            res.body.should.have.property(&apos;result&apos;);
            res.body.result.should.equal(&apos;Hello World&apos;);
            done();
        }));
    it(&apos;PUT&apos;, done =&amp;gt; server.put(&apos;/&apos;).expect(200).expect(&quot;Content-type&quot;,/json/)
        .end((err, res) =&amp;gt; {
            if(err) throw err;
            res.body.should.be.a.Object();
            res.body.should.have.property(&apos;result&apos;);
            res.body.result.should.equal(&apos;Hello World&apos;);
            done();
        }));
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 테스트 코드도 만들었겠다. 슬슬 본격적인 코딩에 들어가겠다.&lt;/p&gt;
&lt;h3&gt;2. app.ts&lt;/h3&gt;
&lt;p&gt;서버에 대한 설정을 하는 역활을 한다. 이 글에서는 간단하게 router랑 기본 설정말 할것이며, 이후 logging, db(mongo,mysql etc)설정, session(redis, cookie)등의 설정은 다루지 않겠다.&lt;/p&gt;
&lt;p&gt;완성까지는 아니지만 express + typescript + mongodb 를 활용하여 만든 &lt;a href=&quot;https://github.com/mayajuni/blog/tree/master/server-node&quot;&gt;github&lt;/a&gt;를보면 알 수 있다. 그안에 logging부터 restapi 테스트까지 전부 있다.&lt;/p&gt;
&lt;p&gt;코딩하는 방법은 여러가지가 있겠지만 es2015의 &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Classes&quot;&gt;Class&lt;/a&gt;를 사용하겠다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import * as express from &quot;express&quot;;

export class Server {
    /* app에 대한 타입 설정 */
    public app: express.Application;

    constructor() {
        /* express 설정을 위한 express 선언 */
        this.app = express();
        /* 라우터 */
        this.router();

        /* Not Foud */
        this.app.use((req: express.Request, res: express.Response, next: Function) =&amp;gt; {
            /**
             *  Error이라는 정의가 있지만 Error에는 status라는 정의가 없어서 any 설정
             *  (아마 typescript로 개발하다보면 any를 많이 쓰게된다)
             */
            const err: any = new Error(&apos;not_found&apos;);
            err.status = 404;
            next(err);
        });

        /* 에러 처리 */
        this.app.use((err: any, req: express.Request, res: express.Response) =&amp;gt; {
            err.status  = err.status || 500;
            console.error(`error on requst ${req.method} | ${req.url} | ${err.status}`);
            console.error(err.stack || `${err.message}`);

            err.message = err.status  == 500 ? &apos;Something bad happened.&apos; : err.message;
            res.status(err.status).send(err.message);
        });
    }

    private router() {
        /**
         * 에러 처리를 좀더 쉽게 하기 위해서 한번 감싸준다.
         * es7에 제안된 async await를 사용하여 에러처리시 catch가 되기 편하게 해준 방식이다.
         * http://expressjs.com/ko/advanced/best-practice-performance.html#section-10 을 참고하면 좋다.
         */
        const wrap = fn =&amp;gt; (req, res, next) =&amp;gt; fn(req, res, next).catch(next);
        //get router
        const router: express.Router = express.Router();

        //get
        router.get(&quot;/&quot;, wrap(async (req, res) =&amp;gt; {
            res.status(200).json({result: &quot;Hello World&quot;})
        }));

        //post
        router.post(&quot;/&quot;, wrap(async (req, res) =&amp;gt; {
            res.status(200).json({result: &quot;Hello World&quot;})
        }));

        //put
        router.put(&quot;/&quot;,  wrap(async (req, res) =&amp;gt; {
            res.status(200).json({result: &quot;Hello World&quot;})
        }));

        //delete
        router.delete(&quot;/&quot;,  wrap(async (req, res) =&amp;gt; {
            res.status(200).json({result: &quot;Hello World&quot;})
        }));

        this.app.use(router);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위의 라우터 부분은 추후 한번 더 블로깅 하겠다. 자세하게 보고 싶으면 &lt;a href=&quot;http://expressjs.com/ko/advanced/best-practice-performance.html#section-10&quot;&gt;expressJs 성능 우수 사례의 올바른 예외처리(프로미스 사용)&lt;/a&gt;를 참고하면 된다.&lt;/p&gt;
&lt;h3&gt;3. server.ts&lt;/h3&gt;
&lt;p&gt;app.ts에 설정된 내용을 가지고 서버를 만들고 스타트 하는 역활을 한다. 물론 app.ts에서 해도 되지만 확정성을 고려하여 따로 분리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import {Server} from &apos;./app&apos;;
import * as express from &quot;express&quot;;

/* 따로 설정하지 않았으면 3000 port를 사용한다. */
const port: number = process.env.PORT || 3000;
const app: express.Application = new Server().app;
app.set(&apos;port&apos;, port);

app.listen(app.get(&apos;port&apos;), () =&amp;gt; {
   console.log(&apos;Express server listening on port &apos; + port);
}).on(&apos;error&apos;, err =&amp;gt; {
   console.error(err);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. server run&lt;/h3&gt;
&lt;p&gt;타입 스크립트는 한번 컴파일을 하지 않으면 js 파일이 생성되지 않는다 그렇게 때문에 꼭 컴파일을 해야된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tsc --p tsconfig.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면 ts파일 이외의 js 파일과 js.map 파일이 생성된다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;ts 파일이 위치한 곳에 생성되기 때문에 안좋아 보일수 있다. gulp나 grunt를 사용하면 해결 할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;node server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 하면 서버가 구동된다.
&lt;img src=&quot;/images/posts/typescript-express/server-start.png&quot; alt=&quot;Express.js 서버 구동 결과&quot; /&gt;&lt;/p&gt;
&lt;p&gt;구동 까지 완료 되었으면, 처음에 만든 테스트를 실행 하여, 제대로 되는지 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mocha
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mocha만 치면 프로젝트의 test폴더 안에 있는 모든 테스트 파일을 구동한다.
이제 결과는 아래와 같다.
&lt;img src=&quot;/images/posts/typescript-express/test-result.png&quot; alt=&quot;Express.js 테스트 실행 결과&quot; /&gt;&lt;/p&gt;
&lt;p&gt;물론 웹으로 요청 한 것도 볼 수 있다.
&lt;img src=&quot;/images/posts/typescript-express/web-result.png&quot; alt=&quot;Express.js 웹 응답 결과&quot; /&gt;&lt;/p&gt;
&lt;p&gt;마지막으로 package.json에 script 추가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;myapp&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;&quot;,
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;npm run tsc &amp;amp; mocha&quot;,
    &quot;start&quot;: &quot;npm run tsc &amp;amp;&amp;amp; node server&quot;,
    &quot;tsc&quot;: &quot;tsc --p tsconfig.json&quot;
  },
  &quot;author&quot;: &quot;&quot;,
  &quot;license&quot;: &quot;ISC&quot;,
  &quot;dependencies&quot;: {
    &quot;express&quot;: &quot;^4.14.0&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;mocha&quot;: &quot;^2.5.3&quot;,
    &quot;should&quot;: &quot;^9.0.2&quot;,
    &quot;supertest&quot;: &quot;^1.2.0&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;아주 기본적인 구동 및 테스트만 했다.
언제든 궁금한 사항이나 버그, 오류가 있을 시 mayajuni10@gmail.com으로 이메일 주시거나 혹은 아래의 댓글로 남겨주시면 수정 및 최대한 아는 범위에서 답변 하겠다.&lt;/p&gt;
&lt;p&gt;테스트로 만든 예제 또한 &lt;a href=&quot;https://github.com/mayajuni/myapp&quot;&gt;github&lt;/a&gt;에 공개되어 있어 볼수 있다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;반말로 블로그를 작성하였는데 이해해주시기 바랍니다.&lt;/code&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>