[{"data":1,"prerenderedAt":1144},["ShallowReactive",2],{"blog-list":3},[4,465,600,798,919],{"id":5,"title":6,"author":7,"body":8,"category":450,"date":451,"description":452,"extension":453,"featured":454,"icon":299,"image":455,"meta":456,"navigation":454,"path":457,"readTime":458,"seo":459,"stem":460,"tags":461,"__hash__":464},"blog\u002Fblog\u002Fmulti-agent-queue.md","멀티 에이전트 큐를 설계하면서 겪은 세 번의 구조 변경","fxpoet",{"type":9,"value":10,"toc":443},"minimark",[11,20,28,33,36,46,49,55,62,66,69,183,194,197,202,209,213,220,226,229,251,255,262,274,281,415,422,426,433,436,439],[12,13,14,15,19],"p",{},"Prompt Combo의 출발점은 ",[16,17,18],"strong",{},"\"프롬프트를 큐에 넣고 알아서 돌아가게 하자\""," 는 생각이었습니다. 말로 하면 간단하지만, 실제로 구현해보면 전혀 다른 문제였습니다.",[12,21,22,23,27],{},"처음에는 정말 단순하게 시작했습니다. 프롬프트 배열을 만들고 ",[24,25,26],"code",{},"for...of"," 로 순차 실행한 뒤, 응답을 로그에 쌓으면 끝이라고 생각했습니다. 문제는 그 다음부터였습니다.",[29,30,32],"h2",{"id":31},"_1차-구조-순차-실행의-한계","1차 구조: 순차 실행의 한계",[12,34,35],{},"첫 번째 구조는 아래와 같았습니다.",[37,38,44],"pre",{"className":39,"code":41,"language":42,"meta":43},[40],"language-text","[Queue]\nPrompt A -> Claude -> Response A\nPrompt B -> Claude -> Response B\nPrompt C -> Claude -> Response C\n","text","",[24,45,41],{"__ignoreMap":43},[12,47,48],{},"작동은 했습니다. 그런데 바로 첫 번째 피드백이 들어왔습니다.",[50,51,52],"blockquote",{},[12,53,54],{},"\"이 프롬프트는 GPT-4o로 돌리고, 저건 Claude로 돌리면 안 되나요?\"",[12,56,57,58,61],{},"단일 모델만 가정한 큐 구조에서는 답이 나오지 않았습니다. 하지만 이 요구 하나가 아키텍처 전체를 흔들었습니다. 이제 아이템마다 ",[16,59,60],{},"어떤 모델을 어떤 API 키로, 어떤 파라미터로"," 실행할지 개별 설정이 가능해야 했습니다.",[29,63,65],{"id":64},"_2차-구조-모델별-라우팅","2차 구조: 모델별 라우팅",[12,67,68],{},"큐 아이템에 모델 정보를 붙였습니다.",[37,70,74],{"className":71,"code":72,"language":73,"meta":43,"style":43},"language-ts shiki shiki-themes github-dark","interface QueueItem {\n  id: string\n  prompt: string\n  model: 'claude' | 'gpt' | 'gemini'\n  params: ModelParams\n  status: 'pending' | 'running' | 'done' | 'error'\n}\n","ts",[24,75,76,93,107,117,140,151,177],{"__ignoreMap":43},[77,78,81,85,89],"span",{"class":79,"line":80},"line",1,[77,82,84],{"class":83},"snl16","interface",[77,86,88],{"class":87},"svObZ"," QueueItem",[77,90,92],{"class":91},"s95oV"," {\n",[77,94,96,100,103],{"class":79,"line":95},2,[77,97,99],{"class":98},"s9osk","  id",[77,101,102],{"class":83},":",[77,104,106],{"class":105},"sDLfK"," string\n",[77,108,110,113,115],{"class":79,"line":109},3,[77,111,112],{"class":98},"  prompt",[77,114,102],{"class":83},[77,116,106],{"class":105},[77,118,120,123,125,129,132,135,137],{"class":79,"line":119},4,[77,121,122],{"class":98},"  model",[77,124,102],{"class":83},[77,126,128],{"class":127},"sU2Wk"," 'claude'",[77,130,131],{"class":83}," |",[77,133,134],{"class":127}," 'gpt'",[77,136,131],{"class":83},[77,138,139],{"class":127}," 'gemini'\n",[77,141,143,146,148],{"class":79,"line":142},5,[77,144,145],{"class":98},"  params",[77,147,102],{"class":83},[77,149,150],{"class":87}," ModelParams\n",[77,152,154,157,159,162,164,167,169,172,174],{"class":79,"line":153},6,[77,155,156],{"class":98},"  status",[77,158,102],{"class":83},[77,160,161],{"class":127}," 'pending'",[77,163,131],{"class":83},[77,165,166],{"class":127}," 'running'",[77,168,131],{"class":83},[77,170,171],{"class":127}," 'done'",[77,173,131],{"class":83},[77,175,176],{"class":127}," 'error'\n",[77,178,180],{"class":79,"line":179},7,[77,181,182],{"class":91},"}\n",[12,184,185,186,189,190,193],{},"각 모델별 API 클라이언트를 만들고, 러너가 아이템의 ",[24,187,188],{},"model"," 필드를 보고 적절한 클라이언트로 라우팅하는 구조였습니다. 여기서 ",[16,191,192],{},"동시 실행"," 이라는 두 번째 문제가 등장했습니다.",[12,195,196],{},"순차 실행이면 단순합니다. 하나 끝나면 다음 하나. 하지만 사용자는 곧 이렇게 묻습니다.",[50,198,199],{},[12,200,201],{},"\"Claude 응답 기다리는 동안 GPT도 같이 돌리면 안 되나요?\"",[12,203,204,205,208],{},"가능합니다. 대신 그러려면 ",[16,206,207],{},"동시성 제어"," 가 필요합니다. 모델별 rate limit은 다르고, 에러 처리도 독립적이어야 하며, 터미널 뷰어는 여러 스트림을 동시에 보여줘야 했습니다.",[29,210,212],{"id":211},"_3차-구조-워커-풀","3차 구조: 워커 풀",[12,214,215,216,219],{},"결국 ",[16,217,218],{},"워커 풀(Worker Pool)"," 구조로 돌아왔습니다.",[37,221,224],{"className":222,"code":223,"language":42,"meta":43},[40],"------------------------------------------------------------\n|                     Queue Manager                        |\n|        priority sorting + dependency resolve            |\n|-----------|-----------|-----------|-----------|\n            |           |           |\n         Worker 1    Worker 2    Worker 3\n         (Claude)      (GPT)      (Gemini)\n            |           |           |\n            |------ merged streams -----------|\n                          |\n                   Log Aggregator\n",[24,225,223],{"__ignoreMap":43},[12,227,228],{},"이 구조의 핵심은 세 가지였습니다.",[230,231,232,239,245],"ol",{},[233,234,235,238],"li",{},[16,236,237],{},"워커는 모델에 종속되지 않습니다."," 워커는 실행 단위일 뿐이고, 어떤 모델이든 어떤 프롬프트든 받아서 처리합니다.",[233,240,241,244],{},[16,242,243],{},"큐 매니저가 라우팅을 결정합니다."," 우선순위, 의존 관계, 모델별 rate limit을 종합해서 다음 실행 대상을 고릅니다.",[233,246,247,250],{},[16,248,249],{},"로그 애그리게이터가 스트림을 합칩니다."," 각 워커에서 오는 SSE 스트림을 하나의 타임라인으로 병합합니다.",[29,252,254],{"id":253},"의존-관계가-있는-경우","의존 관계가 있는 경우",[12,256,257,258,261],{},"이 구조에서 가장 가치 있었던 건 ",[16,259,260],{},"프롬프트 간 의존 관계"," 였습니다. 예를 들면:",[263,264,265,268,271],"ul",{},[233,266,267],{},"Prompt A: \"이 코드를 분석해줘\" -> Claude",[233,269,270],{},"Prompt B: \"A의 결과를 바탕으로 테스트 코드를 작성해줘\" -> GPT",[233,272,273],{},"Prompt C: \"A와 B의 결과를 종합해서 문서화해줘\" -> Claude",[12,275,276,277,280],{},"B는 A가 끝나야 실행할 수 있고, C는 A와 B가 모두 끝나야 합니다. 이런 순서를 처리하려면 ",[16,278,279],{},"DAG(Directed Acyclic Graph)"," 기반의 의존성 해석이 필요했습니다.",[37,282,284],{"className":71,"code":283,"language":73,"meta":43,"style":43},"function getNextExecutable(queue, completed) {\n  return queue\n    .filter(item => item.status === 'pending')\n    .filter(item => item.dependsOn.every(depId => completed.has(depId)))\n    .sort((a, b) => a.priority - b.priority)\n}\n",[24,285,286,309,317,344,378,411],{"__ignoreMap":43},[77,287,288,291,294,297,300,303,306],{"class":79,"line":80},[77,289,290],{"class":83},"function",[77,292,293],{"class":87}," getNextExecutable",[77,295,296],{"class":91},"(",[77,298,299],{"class":98},"queue",[77,301,302],{"class":91},", ",[77,304,305],{"class":98},"completed",[77,307,308],{"class":91},") {\n",[77,310,311,314],{"class":79,"line":95},[77,312,313],{"class":83},"  return",[77,315,316],{"class":91}," queue\n",[77,318,319,322,325,327,330,333,336,339,341],{"class":79,"line":109},[77,320,321],{"class":91},"    .",[77,323,324],{"class":87},"filter",[77,326,296],{"class":91},[77,328,329],{"class":98},"item",[77,331,332],{"class":83}," =>",[77,334,335],{"class":91}," item.status ",[77,337,338],{"class":83},"===",[77,340,161],{"class":127},[77,342,343],{"class":91},")\n",[77,345,346,348,350,352,354,356,359,362,364,367,369,372,375],{"class":79,"line":119},[77,347,321],{"class":91},[77,349,324],{"class":87},[77,351,296],{"class":91},[77,353,329],{"class":98},[77,355,332],{"class":83},[77,357,358],{"class":91}," item.dependsOn.",[77,360,361],{"class":87},"every",[77,363,296],{"class":91},[77,365,366],{"class":98},"depId",[77,368,332],{"class":83},[77,370,371],{"class":91}," completed.",[77,373,374],{"class":87},"has",[77,376,377],{"class":91},"(depId)))\n",[77,379,380,382,385,388,391,393,396,399,402,405,408],{"class":79,"line":142},[77,381,321],{"class":91},[77,383,384],{"class":87},"sort",[77,386,387],{"class":91},"((",[77,389,390],{"class":98},"a",[77,392,302],{"class":91},[77,394,395],{"class":98},"b",[77,397,398],{"class":91},") ",[77,400,401],{"class":83},"=>",[77,403,404],{"class":91}," a.priority ",[77,406,407],{"class":83},"-",[77,409,410],{"class":91}," b.priority)\n",[77,412,413],{"class":79,"line":153},[77,414,182],{"class":91},[12,416,417,418,421],{},"순환 의존성 감지, 실패한 의존성의 후속 처리, 재시도 규칙까지 들어가면 단순한 큐가 아니라 사실상 ",[16,419,420],{},"작업 스케줄러"," 에 가까워집니다.",[29,423,425],{"id":424},"결국-배운-점","결국 배운 점",[12,427,428,429,432],{},"세 번 구조를 바꾸면서 배운 건 ",[16,430,431],{},"\"사용자의 멘탈 모델이 맞아야 한다\""," 는 점이었습니다. 사용자는 이를 그냥 \"프롬프트 목록\"처럼 생각합니다. 내부적으로는 워커 풀과 DAG가 돌아가더라도, UI에서는 드래그로 순서를 바꾸고 선으로 의존 관계를 연결하는 정도로 보여야 했습니다.",[12,434,435],{},"결국 중요한 건 내부 구조를 과시하는 게 아니라, 사용자가 복잡함을 느끼지 않게 만드는 일이었습니다. 그게 좋은 도구라고 생각합니다.",[12,437,438],{},"다음 글에서는 Cross Validation, 즉 여러 LLM에게 같은 질문을 보내고 응답을 비교하는 기능을 구현하면서 겪은 이야기를 정리합니다.",[440,441,442],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":43,"searchDepth":95,"depth":95,"links":444},[445,446,447,448,449],{"id":31,"depth":95,"text":32},{"id":64,"depth":95,"text":65},{"id":211,"depth":95,"text":212},{"id":253,"depth":95,"text":254},{"id":424,"depth":95,"text":425},"Architecture","2026-04-07","순차 실행에서 워커 풀 구조로 넘어오기까지, Prompt Combo의 큐 아키텍처를 세 번 뜯어고친 기록입니다.","md",true,"\u002Fmain-screenshot.png",{},"\u002Fblog\u002Fmulti-agent-queue","12 min read",{"title":6,"description":452},"blog\u002Fmulti-agent-queue",[299,462,463],"architecture","multi-agent","IMm4svUO0n3yd2Gd_ySRRn1yqk7vt4pO6lqHlekN4D4",{"id":466,"title":467,"author":7,"body":468,"category":586,"date":587,"description":588,"extension":453,"featured":589,"icon":590,"image":455,"meta":591,"navigation":454,"path":592,"readTime":593,"seo":594,"stem":595,"tags":596,"__hash__":599},"blog\u002Fblog\u002Flocal-first.md","서버 없이 만드는 로컬 퍼스트 아키텍처에 대한 판단",{"type":9,"value":469,"toc":579},[470,477,481,484,495,498,502,505,510,513,520,524,527,538,545,549,552,563,566,569,576],[12,471,472,473,476],{},"Prompt Combo를 처음 구상할 때 가장 먼저 한 결정 중 하나는 ",[16,474,475],{},"서버를 두지 않는다"," 는 것이었습니다. 기능을 만들기 어렵게 만드는 선택이었지만, 결국 이 제품에는 그게 맞다고 판단했습니다.",[29,478,480],{"id":479},"왜-서버를-두는-방식이-편해-보였나","왜 서버를 두는 방식이 편해 보였나",[12,482,483],{},"서버를 두면 쉬운 점이 많습니다.",[263,485,486,489,492],{},[233,487,488],{},"API 키 연결 UX를 단순하게 만들 수 있습니다.",[233,490,491],{},"실행 로그를 중앙에서 수집할 수 있습니다.",[233,493,494],{},"과금과 계정 관리도 한곳에서 처리할 수 있습니다.",[12,496,497],{},"하지만 이 편의성은 곧바로 신뢰 문제로 이어집니다. 사용자는 자기 프롬프트와 결과, 그리고 API 키가 어디를 지나가는지 예민하게 봅니다.",[29,499,501],{"id":500},"프롬프트-도구에서-가장-무서운-질문","프롬프트 도구에서 가장 무서운 질문",[12,503,504],{},"실제 사용자 입장에서 가장 먼저 나오는 질문은 이것입니다.",[50,506,507],{},[12,508,509],{},"\"내 키가 네 서버를 거치나요?\"",[12,511,512],{},"이 질문에 \"네, 하지만 안전합니다\"라고 답하는 순간 설명 비용이 생깁니다. 그리고 그 비용은 제품의 나머지 장점을 상당 부분 잠식합니다.",[12,514,515,516,519],{},"Prompt Combo는 이 질문에 ",[16,517,518],{},"\"아니요, 로컬에만 저장됩니다\""," 라고 답하는 쪽을 선택했습니다. 그 한 문장이 제품을 훨씬 쉽게 이해하게 만들었습니다.",[29,521,523],{"id":522},"로컬-퍼스트의-대가","로컬 퍼스트의 대가",[12,525,526],{},"물론 손해도 있습니다.",[263,528,529,532,535],{},[233,530,531],{},"기기간 동기화는 기본 제공하기 어렵습니다.",[233,533,534],{},"웹 대시보드 같은 기능을 바로 만들기 어렵습니다.",[233,536,537],{},"결제 이후 라이선스 처리도 더 신중하게 붙여야 합니다.",[12,539,540,541,544],{},"그럼에도 불구하고 이 대가는 감수할 가치가 있었습니다. Prompt Combo의 핵심은 팀 협업 SaaS가 아니라, ",[16,542,543],{},"개발자가 자기 컴퓨터에서 빠르게 프롬프트 워크플로를 돌리는 도구"," 이기 때문입니다.",[29,546,548],{"id":547},"기술적으로는-무엇을-선택했나","기술적으로는 무엇을 선택했나",[12,550,551],{},"로컬 저장소에는 아래 세 가지 계층을 분리했습니다.",[230,553,554,557,560],{},[233,555,556],{},"민감한 자격 증명: OS 보안 저장소",[233,558,559],{},"프롬프트와 큐 정의: 앱 내부 데이터 디렉터리",[233,561,562],{},"로그와 실행 캐시: 회전 가능한 로컬 파일",[12,564,565],{},"이렇게 나누면 사용자가 원하는 지점만 지우거나 백업하기 쉬워집니다. 그리고 데이터 성격이 섞이지 않아 장애 대응도 단순해집니다.",[29,567,568],{"id":568},"결론",[12,570,571,572,575],{},"로컬 퍼스트는 단순한 구현 취향이 아니라 제품 메시지 자체였습니다. Prompt Combo는 \"계정을 만들고 서버에 접속해서 쓰는 서비스\"가 아니라, ",[16,573,574],{},"설치 후 바로 자기 환경에서 돌리는 도구"," 로 보여야 했습니다.",[12,577,578],{},"그 기준에서 보면 서버를 두지 않는 선택은 제약이 아니라 정체성에 가까웠습니다.",{"title":43,"searchDepth":95,"depth":95,"links":580},[581,582,583,584,585],{"id":479,"depth":95,"text":480},{"id":500,"depth":95,"text":501},{"id":522,"depth":95,"text":523},{"id":547,"depth":95,"text":548},{"id":568,"depth":95,"text":568},"Philosophy","2026-03-28","프록시 서버를 두지 않고 사용자 PC 안에서만 API 키와 실행 기록을 다루기로 한 이유를 정리했습니다.",false,"lock",{},"\u002Fblog\u002Flocal-first","7 min read",{"title":467,"description":588},"blog\u002Flocal-first",[597,598,462],"local-first","security","GNjoxA-UoYpLi0zo6EbOWeMpFfutK3oHG9v7gopB9IQ",{"id":601,"title":602,"author":7,"body":603,"category":784,"date":785,"description":786,"extension":453,"featured":589,"icon":787,"image":455,"meta":788,"navigation":454,"path":789,"readTime":790,"seo":791,"stem":792,"tags":793,"__hash__":797},"blog\u002Fblog\u002Fstreaming-log.md","3개의 LLM 스트림을 동시에 처리하는 터미널 뷰어는 어떻게 버티게 했나",{"type":9,"value":604,"toc":777},[605,612,616,619,622,633,637,640,716,719,723,726,729,740,743,747,750,757,761,764,767,774],[12,606,607,608,611],{},"터미널 뷰어는 겉으로 보기엔 로그 창입니다. 하지만 실제 구현은 단순한 텍스트 append와는 거리가 멉니다. Prompt Combo에서는 여러 LLM의 스트림이 동시에 들어오기 때문에, ",[16,609,610],{},"정렬과 렌더링과 스크롤"," 을 모두 같이 다뤄야 했습니다.",[29,613,615],{"id":614},"가장-먼저-부딪힌-문제","가장 먼저 부딪힌 문제",[12,617,618],{},"처음 구현은 이벤트가 올 때마다 DOM에 한 줄씩 추가하는 방식이었습니다. 데모에서는 괜찮았지만, 실제로 Claude와 GPT와 Gemini를 동시에 붙이자 바로 느려졌습니다.",[12,620,621],{},"문제는 세 가지였습니다.",[263,623,624,627,630],{},[233,625,626],{},"너무 잦은 렌더링",[233,628,629],{},"자동 스크롤 충돌",[233,631,632],{},"오래 열린 세션의 메모리 증가",[29,634,636],{"id":635},"스트림을-바로-그리지-않고-버퍼링했다","스트림을 바로 그리지 않고 버퍼링했다",[12,638,639],{},"해결의 출발점은 \"이벤트가 올 때마다 그리지 않는다\"였습니다. 각 워커 스트림은 메모리 버퍼에 먼저 쌓고, 짧은 간격으로 병합해 화면에 반영했습니다.",[37,641,643],{"className":71,"code":642,"language":73,"meta":43,"style":43},"const flushInterval = 48\n\nsetInterval(() => {\n  const chunk = drainBufferedEvents()\n  if (chunk.length) appendToTimeline(chunk)\n}, flushInterval)\n",[24,644,645,659,664,676,692,711],{"__ignoreMap":43},[77,646,647,650,653,656],{"class":79,"line":80},[77,648,649],{"class":83},"const",[77,651,652],{"class":105}," flushInterval",[77,654,655],{"class":83}," =",[77,657,658],{"class":105}," 48\n",[77,660,661],{"class":79,"line":95},[77,662,663],{"emptyLinePlaceholder":454},"\n",[77,665,666,669,672,674],{"class":79,"line":109},[77,667,668],{"class":87},"setInterval",[77,670,671],{"class":91},"(() ",[77,673,401],{"class":83},[77,675,92],{"class":91},[77,677,678,681,684,686,689],{"class":79,"line":119},[77,679,680],{"class":83},"  const",[77,682,683],{"class":105}," chunk",[77,685,655],{"class":83},[77,687,688],{"class":87}," drainBufferedEvents",[77,690,691],{"class":91},"()\n",[77,693,694,697,700,703,705,708],{"class":79,"line":142},[77,695,696],{"class":83},"  if",[77,698,699],{"class":91}," (chunk.",[77,701,702],{"class":105},"length",[77,704,398],{"class":91},[77,706,707],{"class":87},"appendToTimeline",[77,709,710],{"class":91},"(chunk)\n",[77,712,713],{"class":79,"line":153},[77,714,715],{"class":91},"}, flushInterval)\n",[12,717,718],{},"48ms는 아주 정교한 숫자는 아니지만, 체감상 실시간으로 보이면서도 렌더링 횟수를 크게 줄여줬습니다.",[29,720,722],{"id":721},"스크롤은-유저가-따라가고-있을-때만-자동으로","스크롤은 \"유저가 따라가고 있을 때만\" 자동으로",[12,724,725],{},"자동 스크롤도 단순하면 문제가 생깁니다. 사용자가 위쪽 로그를 읽고 있을 때 아래로 계속 끌어당기면 UI가 바로 짜증납니다.",[12,727,728],{},"그래서 마지막 근처에 머물러 있을 때만 자동 스크롤을 유지하도록 바꿨습니다.",[263,730,731,734,737],{},[233,732,733],{},"하단 근처면 자동 추적",[233,735,736],{},"위로 올리면 추적 해제",[233,738,739],{},"다시 맨 아래로 내려오면 추적 재개",[12,741,742],{},"이 패턴 하나만으로 뷰어가 훨씬 안정적으로 느껴졌습니다.",[29,744,746],{"id":745},"병합-기준은-도착-순서가-아니라-타임라인","병합 기준은 도착 순서가 아니라 타임라인",[12,748,749],{},"각 모델의 응답 속도는 다릅니다. 단순히 먼저 도착한 순서대로 섞으면 실제 실행 흐름이 이해되지 않는 경우가 생깁니다. 그래서 내부 이벤트는 공통 timestamp를 기준으로 다시 정렬했습니다.",[12,751,752,753,756],{},"물론 네트워크 지연 때문에 완전한 절대 순서를 보장할 수는 없습니다. 하지만 사용자 관점에서는 ",[16,754,755],{},"\"무엇이 먼저 시작되고, 무엇이 그 뒤에 이어졌는가\""," 를 읽을 수 있어야 했고, 타임라인 정렬이 그 요구를 잘 만족했습니다.",[29,758,760],{"id":759},"긴-세션은-접는다","긴 세션은 접는다",[12,762,763],{},"오래 실행하는 사용자를 위해서는 메모리 관리가 필요했습니다. 모든 로그를 끝까지 DOM에 들고 있으면 결국 브라우저든 Electron이든 버벅입니다.",[12,765,766],{},"그래서 오래된 구간은 접힌 블록으로 요약하고, 필요할 때만 펼치게 했습니다. 화면에는 필요한 밀도만 남기고, 원본 로그는 파일로 보존하는 방식입니다.",[12,768,769,770,773],{},"결국 터미널 뷰어의 핵심은 예쁜 UI보다 ",[16,771,772],{},"읽을 수 있는 속도와 남아있는 기록"," 이었습니다. 이 기준으로 설계를 단순화하니 구현 판단도 훨씬 쉬워졌습니다.",[440,775,776],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":43,"searchDepth":95,"depth":95,"links":778},[779,780,781,782,783],{"id":614,"depth":95,"text":615},{"id":635,"depth":95,"text":636},{"id":721,"depth":95,"text":722},{"id":745,"depth":95,"text":746},{"id":759,"depth":95,"text":760},"Engineering","2026-03-20","여러 모델의 SSE 응답을 한 화면의 타임라인으로 합칠 때 DOM 업데이트, 스크롤, 메모리 문제를 어떻게 풀었는지 정리했습니다.","stream",{},"\u002Fblog\u002Fstreaming-log","10 min read",{"title":602,"description":786},"blog\u002Fstreaming-log",[794,795,796],"terminal","streaming","performance","k-iaUJMk8eGbPE3vIzptyJHU8mPhL3Z1FFXkdDt5TrM",{"id":799,"title":800,"author":7,"body":801,"category":906,"date":907,"description":908,"extension":453,"featured":589,"icon":909,"image":455,"meta":910,"navigation":454,"path":911,"readTime":912,"seo":913,"stem":914,"tags":915,"__hash__":918},"blog\u002Fblog\u002Felectron-decisions.md","Electron을 선택한 이유, 그리고 후회하지 않는 이유",{"type":9,"value":802,"toc":900},[803,806,810,813,824,827,838,842,845,852,856,859,862,873,876,880,883,886,897],[12,804,805],{},"Prompt Combo를 데스크톱 앱으로 만들기로 했을 때 가장 오래 끌었던 결정 중 하나가 런타임 선택이었습니다. 다들 Electron보다 더 가볍고 더 현대적인 대안을 얘기했지만, 결국 저는 Electron을 골랐습니다.",[29,807,809],{"id":808},"비교-대상은-명확했다","비교 대상은 명확했다",[12,811,812],{},"처음 후보는 세 가지였습니다.",[263,814,815,818,821],{},[233,816,817],{},"Electron",[233,819,820],{},"Tauri",[233,822,823],{},"Neutralino",[12,825,826],{},"비교 기준도 단순했습니다.",[230,828,829,832,835],{},[233,830,831],{},"스트리밍 UI를 안정적으로 처리할 수 있는가",[233,833,834],{},"파일 시스템과 OS 통합이 충분한가",[233,836,837],{},"배포와 업데이트 운영 비용이 감당 가능한가",[29,839,841],{"id":840},"electron의-약점은-알고-있었다","Electron의 약점은 알고 있었다",[12,843,844],{},"번들 크기와 메모리 사용량은 확실히 약점입니다. 이건 부정할 수 없습니다. 하지만 Prompt Combo의 핵심은 초경량 유틸리티가 아니라, 여러 스트림과 로컬 데이터와 외부 API를 동시에 다루는 작업 환경입니다.",[12,846,847,848,851],{},"이 경우에는 \"조금 더 가벼움\"보다 ",[16,849,850],{},"검증된 런타임과 디버깅 편의성"," 이 더 중요했습니다.",[29,853,855],{"id":854},"실제로-중요했던-것은-개발-속도였다","실제로 중요했던 것은 개발 속도였다",[12,857,858],{},"초기 제품에서는 기능 가설이 자주 바뀝니다. 큐 구조가 바뀌고, 로그 뷰어가 바뀌고, 설정 저장 방식도 바뀝니다. 이 시기에는 런타임 최적화보다 빠른 반복이 더 중요합니다.",[12,860,861],{},"Electron은 이 점에서 아주 단순했습니다.",[263,863,864,867,870],{},[233,865,866],{},"웹 기술 스택 재사용이 쉬웠고",[233,868,869],{},"디버깅 도구가 익숙했고",[233,871,872],{},"문서와 사례가 충분히 많았습니다",[12,874,875],{},"제품이 아직 모양을 잡아가는 단계라면, 이런 안정성은 생각보다 큰 이점입니다.",[29,877,879],{"id":878},"언제-다른-선택을-할-수도-있나","언제 다른 선택을 할 수도 있나",[12,881,882],{},"물론 제품이 더 커지고 요구사항이 바뀌면 판단도 달라질 수 있습니다. 예를 들어 초경량 배포가 절대적인 요구가 되거나, OS 네이티브 통합을 훨씬 더 강하게 밀어야 한다면 다른 선택지가 맞을 수도 있습니다.",[12,884,885],{},"하지만 현재 Prompt Combo의 우선순위는 이렇습니다.",[263,887,888,891,894],{},[233,889,890],{},"안정적인 데스크톱 경험",[233,892,893],{},"빠른 기능 반복",[233,895,896],{},"스트리밍 UI와 로컬 저장의 예측 가능성",[12,898,899],{},"이 기준에서는 Electron이 가장 현실적인 선택이었습니다. 적어도 지금까지는 후회하지 않습니다.",{"title":43,"searchDepth":95,"depth":95,"links":901},[902,903,904,905],{"id":808,"depth":95,"text":809},{"id":840,"depth":95,"text":841},{"id":854,"depth":95,"text":855},{"id":878,"depth":95,"text":879},"Decision Log","2026-03-14","Tauri와 Neutralino도 검토했지만 Prompt Combo에는 Electron이 더 맞았던 이유를 제품 요구사항 기준으로 설명합니다.","document",{},"\u002Fblog\u002Felectron-decisions","8 min read",{"title":800,"description":908},"blog\u002Felectron-decisions",[916,917,462],"electron","desktop","4r7B4MFkxW0WTt700ynzuz865pD4RsHDrKKptW6NTPM",{"id":920,"title":921,"author":922,"body":923,"category":922,"date":922,"description":927,"extension":453,"featured":589,"icon":922,"image":922,"meta":1129,"navigation":454,"path":1130,"readTime":922,"seo":1131,"stem":1142,"tags":922,"__hash__":1143},"blog\u002Fblog\u002Fcross-validation.md","[object Object]",null,{"type":9,"value":924,"toc":1122},[925,928,932,939,942,956,960,963,974,981,985,988,1002,1005,1072,1076,1079,1082,1090,1101,1105,1108,1116,1119],[12,926,927],{},"Cross Validation은 말 그대로 여러 LLM에게 같은 질문을 보내고, 결과를 서로 비교해 신뢰도를 올리려는 시도입니다. 아이디어 자체는 단순하지만, 실제로 도구로 만들면 생각보다 많은 예외가 생깁니다.",[29,929,931],{"id":930},"왜-단일-응답으로는-부족했나","왜 단일 응답으로는 부족했나",[12,933,934,935,938],{},"하나의 모델만 쓸 때 가장 큰 문제는 사용자가 ",[16,936,937],{},"정답인지 아닌지 판단하기 위해 다시 직접 읽어야 한다"," 는 점입니다. 결국 시간을 줄이려고 LLM을 쓰는데, 마지막 판단 비용이 그대로 남아 있었습니다.",[12,940,941],{},"그래서 Prompt Combo에서는 다음 흐름을 기본 패턴으로 잡았습니다.",[230,943,944,947,950,953],{},[233,945,946],{},"같은 질문을 Claude와 GPT에 동시에 보냅니다.",[233,948,949],{},"두 응답을 각각 구조화합니다.",[233,951,952],{},"합의한 부분과 충돌한 부분을 분리합니다.",[233,954,955],{},"마지막 요약 프롬프트에서 차이점만 다시 검토합니다.",[29,957,959],{"id":958},"합의가-항상-품질을-의미하지는-않는다","합의가 항상 품질을 의미하지는 않는다",[12,961,962],{},"처음에는 두 모델이 같은 답을 하면 정확도가 올라간다고 생각했습니다. 하지만 실험을 해보니 꼭 그렇지는 않았습니다.",[263,964,965,968,971],{},[233,966,967],{},"질문이 모호할수록 둘 다 비슷한 추측을 하는 경우가 있습니다.",[233,969,970],{},"최신성이 필요한 정보는 둘 다 낡은 지식을 답할 수 있습니다.",[233,972,973],{},"코드 수정 제안은 둘 다 같은 버그를 놓치는 경우가 있습니다.",[12,975,976,977,980],{},"즉, Cross Validation은 ",[16,978,979],{},"정확성을 자동 보장하는 장치가 아니라, 리뷰 범위를 줄이는 장치"," 에 가깝습니다.",[29,982,984],{"id":983},"그래서-비교-기준을-따로-만들었다","그래서 비교 기준을 따로 만들었다",[12,986,987],{},"응답을 그냥 문장 단위로 비교하면 신호보다 잡음이 많았습니다. 대신 아래 기준을 분리해서 보게 했습니다.",[263,989,990,993,996,999],{},[233,991,992],{},"사실 주장",[233,994,995],{},"제안된 행동",[233,997,998],{},"가정과 전제",[233,1000,1001],{},"미해결 위험",[12,1003,1004],{},"이렇게 나누면 \"같은 말을 다르게 표현한 경우\"와 \"실제로 결론이 다른 경우\"를 구분하기 쉬워집니다.",[37,1006,1008],{"className":71,"code":1007,"language":73,"meta":43,"style":43},"type ValidationBlock = {\n  claims: string[]\n  actions: string[]\n  assumptions: string[]\n  risks: string[]\n}\n",[24,1009,1010,1022,1035,1046,1057,1068],{"__ignoreMap":43},[77,1011,1012,1015,1018,1020],{"class":79,"line":80},[77,1013,1014],{"class":83},"type",[77,1016,1017],{"class":87}," ValidationBlock",[77,1019,655],{"class":83},[77,1021,92],{"class":91},[77,1023,1024,1027,1029,1032],{"class":79,"line":95},[77,1025,1026],{"class":98},"  claims",[77,1028,102],{"class":83},[77,1030,1031],{"class":105}," string",[77,1033,1034],{"class":91},"[]\n",[77,1036,1037,1040,1042,1044],{"class":79,"line":109},[77,1038,1039],{"class":98},"  actions",[77,1041,102],{"class":83},[77,1043,1031],{"class":105},[77,1045,1034],{"class":91},[77,1047,1048,1051,1053,1055],{"class":79,"line":119},[77,1049,1050],{"class":98},"  assumptions",[77,1052,102],{"class":83},[77,1054,1031],{"class":105},[77,1056,1034],{"class":91},[77,1058,1059,1062,1064,1066],{"class":79,"line":142},[77,1060,1061],{"class":98},"  risks",[77,1063,102],{"class":83},[77,1065,1031],{"class":105},[77,1067,1034],{"class":91},[77,1069,1070],{"class":79,"line":153},[77,1071,182],{"class":91},[29,1073,1075],{"id":1074},"유용했던-실제-사례","유용했던 실제 사례",[12,1077,1078],{},"코드 리뷰 프롬프트에서는 효과가 꽤 좋았습니다. 한 모델은 보안 취약점을 잘 잡고, 다른 모델은 회귀 가능성을 더 잘 짚는 식으로 분업이 생겼기 때문입니다.",[12,1080,1081],{},"특히 아래처럼 역할을 나눌 때 결과가 좋았습니다.",[263,1083,1084,1087],{},[233,1085,1086],{},"Model A: 버그와 예외 처리 집중",[233,1088,1089],{},"Model B: DX, 유지보수성, 테스트 갭 집중",[12,1091,1092,1093,1096,1097,1100],{},"결론적으로 Prompt Combo의 Cross Validation은 ",[16,1094,1095],{},"정답 제조기"," 보다는 ",[16,1098,1099],{},"리뷰어 두 명을 동시에 붙이는 장치"," 에 가깝습니다. 이 관점으로 접근했을 때 훨씬 현실적으로 동작했습니다.",[29,1102,1104],{"id":1103},"남은-과제","남은 과제",[12,1106,1107],{},"아직도 해결 중인 문제는 두 가지입니다.",[230,1109,1110,1113],{},[233,1111,1112],{},"두 모델이 모두 틀렸을 때는 합의가 오히려 자신감을 높인다는 점",[233,1114,1115],{},"긴 응답에서는 비교 비용이 다시 커진다는 점",[12,1117,1118],{},"그래서 앞으로는 분야별 템플릿과 비교 스키마를 더 세밀하게 나눌 계획입니다. 범용 모드 하나로는 한계가 분명했습니다.",[440,1120,1121],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":43,"searchDepth":95,"depth":95,"links":1123},[1124,1125,1126,1127,1128],{"id":930,"depth":95,"text":931},{"id":958,"depth":95,"text":959},{"id":983,"depth":95,"text":984},{"id":1074,"depth":95,"text":1075},{"id":1103,"depth":95,"text":1104},{},"\u002Fblog\u002Fcross-validation",{"title":1132,"description":927},{"Cross Validation":1133,"description":1134,"date":1135,"category":1136,"readTime":1137,"author":7,"icon":1138,"image":455,"tags":1139},"LLM끼리 서로 검증시키면 정확도가 올라갈까?","Claude와 GPT를 동시에 돌려 응답의 합의점과 차이점을 비교할 때 무엇이 좋아지고 무엇이 오히려 복잡해지는지 정리했습니다.","2026-04-03","Feature Deep Dive","9 min read","validation",[1138,1140,1141],"claude","gpt","blog\u002Fcross-validation","8mK-KpjxzMTZ4GrJ7D2IXaV9SEjEHGgMUpq2Zdcghoo",1775707752181]