<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>NxtGen 팀 블로그</title>
        <link>https://nxtgen.co.kr/blog</link>
        <description>NxtGen 엔지니어들이 구축과 운영 현장에서 배운 것을 각자의 이름으로 기록합니다.</description>
        <lastBuildDate>Tue, 06 Oct 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ko</language>
        <copyright>© 2026 NxtCloud.</copyright>
        <item>
            <title><![CDATA[사내 CI 구성하기]]></title>
            <link>https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks</link>
            <guid>https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks</guid>
            <pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[조직이 같이 쓰는 깃 액션 무료 시간이 한 번 바닥난 일에서 시작해, ARC로 빌드 러너를 저희 EKS로 가져온 과정을 정리했습니다. Karpenter 위에서 빌드를 옮길 수 없는 워크로드로 다룬 규칙, 13분을 3분 40초로 되돌린 커스텀 러너 이미지, 대시보드로 확인한 실사용량까지 담았습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>깃 액션 무료 시간은 레포지토리마다 따로 주어지는 게 아니라 조직 전체가 같이 쓰는 형태였습니다.
그 시간이 한 번 바닥나고 나서야, 누가 얼마나 쓰고 있는지 저희가 챙겨 보는 체계가 없었다는 걸 알게 되었습니다.
러너를 저희 EKS로 가져오면서 정한 것들과, 아직 길다고 느끼는 부분까지 정리했습니다.</p>
</blockquote>
<p>안녕하세요. NxtCloud FDE/SA 류명재(Jay)입니다. 이번에는 제가 사내 CI 러너를 저희 EKS 클러스터로 가져온 과정에 대해서 설명을 해보려고 합니다. 왜 이걸 하게 되었는지, 그리고 구성하면서 어떤 기준으로 선택했는지를 위주로 정리해봤습니다.</p>
<p>시작하기 전에 범위를 짚어두겠습니다. 이 글은 ARC를 설치하는 방법 자체는 다루지 않습니다. 설치는 공식 문서에 잘 나와 있습니다. 배포 쪽, 그러니까 이미지가 올라간 다음에 ArgoCD가 클러스터에 반영하는 CD 부분도 기존 그대로라서 이 글에서는 빠집니다. 다루는 건 빌드가 돌아가는 자리를 어디로 옮겼는지, 그리고 그 자리를 어떤 규칙으로 운영하게 되었는지입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="깃-액션-시간은-조직이-같이-쓰는-지갑이었습니다">깃 액션 시간은 조직이 같이 쓰는 지갑이었습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EA%B9%83-%EC%95%A1%EC%85%98-%EC%8B%9C%EA%B0%84%EC%9D%80-%EC%A1%B0%EC%A7%81%EC%9D%B4-%EA%B0%99%EC%9D%B4-%EC%93%B0%EB%8A%94-%EC%A7%80%EA%B0%91%EC%9D%B4%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="깃 액션 시간은 조직이 같이 쓰는 지갑이었습니다에 대한 직접 링크" title="깃 액션 시간은 조직이 같이 쓰는 지갑이었습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>저희는 GitHub Actions, 흔히 깃 액션이라고 부르는 걸로 빌드를 하고 있었습니다. 처음에는 레포지토리마다 각자 시간이 있는 줄 알았는데, 확인해 보니 무료 시간은 조직 단위로 주어졌습니다. 저희 조직 플랜 기준으로 한 달에 2,000분이라는 시간을 모든 레포지토리가 같이 나눠 쓰는 형태입니다.[1]</p>
<p>여기에 OS별 배수가 하나 더 붙습니다. 리눅스 러너는 1분을 쓰면 1분이 차감되지만, 윈도우는 2배, 맥은 10배가 차감됩니다. 예를 들어 데스크톱 앱 릴리스처럼 윈도우나 맥 빌드가 필요한 작업이 하나만 붙어도, 공용 시간은 생각보다 훨씬 빠르게 줄어듭니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="레포지토리별 시간이 아니라 조직 하나의 지갑을 같이 쓰는 형태. 맥 빌드 10분이 리눅스 빌드 100분과 같은 값이다" src="https://nxtgen.co.kr/assets/images/01-7e7986a5261fd11cb0b6d027c254497b.png" width="2000" height="960" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">레포지토리별 시간이 아니라 조직 하나의 지갑을 같이 쓰는 형태. 맥 빌드 10분이 리눅스 빌드 100분과 같은 값이다</span></span>
<em>레포지토리별 시간이 아니라 조직 하나의 지갑을 같이 쓰는 형태. 맥 빌드 10분이 리눅스 빌드 100분과 같은 값이다</em></p>
<p>실제로 이 시간이 바닥난 적이 있었습니다. 그때 저희가 할 수 있었던 건 임시방편뿐이었습니다. 한 프로젝트는 레포지토리를 개인 계정으로 미러링해서 개인 무료 시간으로 빌드를 돌렸습니다. 다행히 그 시점에는 다들 깃 액션을 많이 쓰고 있지 않아서 큰 사고로 이어지지는 않았습니다. 그런데 프로젝트가 늘고 빌드가 늘어날수록, 어느 날 갑자기 모든 레포지토리가 빌드를 못 하는 순간이 올 수 있는 구조입니다. 물론 결제 한도를 올려서 시간을 더 사는 방법도 있습니다. 다만 그건 비용을 더 내고 같은 구조를 유지하는 것이라, 누가 얼마나 쓰는지 모르는 상태는 그대로 남습니다.</p>
<p>그리고 이 일로 더 크게 느낀 점이 하나 있었습니다. GitHub 결제 화면에서 레포지토리별 사용량을 내려받아 볼 수는 있습니다. 하지만 평소에 그걸 챙겨 보는 사람이 없었고, 어떤 레포지토리가 시간을 많이 쓰는지, 어떤 빌드가 자주 실패하는지를 한눈에 보는 자리가 없었습니다. 시간을 늘리는 것만큼이나, 빌드를 저희가 보고 조절할 수 있는 체계가 필요하다고 생각하게 되었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="러너가-저희-밖에-있어서-생기는-문제들">러너가 저희 밖에 있어서 생기는 문제들<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%9F%AC%EB%84%88%EA%B0%80-%EC%A0%80%ED%9D%AC-%EB%B0%96%EC%97%90-%EC%9E%88%EC%96%B4%EC%84%9C-%EC%83%9D%EA%B8%B0%EB%8A%94-%EB%AC%B8%EC%A0%9C%EB%93%A4" class="hash-link" aria-label="러너가 저희 밖에 있어서 생기는 문제들에 대한 직접 링크" title="러너가 저희 밖에 있어서 생기는 문제들에 대한 직접 링크" translate="no">​</a></h2>
<p>시간 문제를 정리하다 보니, 러너가 GitHub 쪽에 있다는 것 자체에서 오는 문제들도 같이 보였습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="도입 전 구조. 배포는 이미 클러스터 안에 있었고, 빌드만 바깥에 있었다" src="https://nxtgen.co.kr/assets/images/02-da4e4c3fd295ffcaf82da8cd00c46f5c.png" width="2000" height="1000" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">도입 전 구조. 배포는 이미 클러스터 안에 있었고, 빌드만 바깥에 있었다</span></span>
<em>도입 전 구조. 배포는 이미 클러스터 안에 있었고, 빌드만 바깥에 있었다</em></p>
<p>우선 러너가 저희 VPC 밖에 있다 보니 DB를 붙인 테스트나 내부 API를 호출하는 테스트를 바로 할 수 없습니다. OIDC로 AWS 권한은 받을 수 있지만, 그건 누구인지를 증명할 뿐 private 서브넷까지 가는 길을 열어주지는 않습니다. SSM 포트 포워딩 같은 우회 방법이 있긴 해도, 테스트마다 터널을 붙여야 해서 번거롭습니다. 그 다음에 빌드 환경을 저희가 통제할 수 없습니다. 어떤 OS 이미지에서, 어떤 네트워크를 타고 빌드가 되는지는 GitHub이 정하는 영역입니다. 그리고 빌드 캐시도 GitHub 캐시 스토리지에 기대거나 매번 처음부터 다시 받는 형태였습니다.</p>
<p>보안 쪽에도 고민이 있었습니다. 깃 액션 생태계에서는 Action 태그를 다른 커밋으로 바꿔치기하는 공격이나 워크플로우에서 크리덴셜이 새는 사고 소식이 계속 들려옵니다. 러너를 저희 쪽으로 가져오면 적어도 인증 경로와 실행 환경은 저희가 통제할 수 있는 영역이 됩니다.[2]</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="후보는-생각보다-빨리-정리됐습니다">후보는 생각보다 빨리 정리됐습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%ED%9B%84%EB%B3%B4%EB%8A%94-%EC%83%9D%EA%B0%81%EB%B3%B4%EB%8B%A4-%EB%B9%A8%EB%A6%AC-%EC%A0%95%EB%A6%AC%EB%90%90%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="후보는 생각보다 빨리 정리됐습니다에 대한 직접 링크" title="후보는 생각보다 빨리 정리됐습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>GitHub 러너를 그대로 쓰는 것부터 CI 도구를 아예 바꾸는 것까지 몇 가지를 놓고 비교했습니다. CircleCI, TeamCity, Woodpecker, Drone 같은 도구들도 고려 대상에 있었습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="두 관문을 차례로 통과시킨 결과. 두 번째 관문에서 사실상 답이 정해졌다" src="https://nxtgen.co.kr/assets/images/03-c4409e538c8469e6d5029e5e1d6de962.png" width="2000" height="880" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">두 관문을 차례로 통과시킨 결과. 두 번째 관문에서 사실상 답이 정해졌다</span></span>
<em>두 관문을 차례로 통과시킨 결과. 두 번째 관문에서 사실상 답이 정해졌다</em></p>
<p>두 번째 관문이 거의 전부였습니다. 저희는 이미 GitHub으로 코드를 관리하고 있고 워크플로우도 깃 액션으로 짜여 있어서, 도구를 바꾸면 모든 레포지토리의 파이프라인을 다시 써야 하고 운영할 시스템도 하나 더 늘어납니다. 그래서 깃 액션은 그대로 두고, 빌드가 돌아가는 러너만 저희 클러스터로 가져오는 방향이 가장 자연스러웠습니다. 그 역할을 하는 것이 Actions Runner Controller, 줄여서 ARC라는 GitHub 공식 쿠버네티스 컨트롤러입니다.</p>
<p>ARC로 가면 개발자 입장에서 바뀌는 건 기본적으로 워크플로우의 한 줄입니다.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># 변경 전</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">runs-on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ubuntu</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">latest</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 변경 후 (러너 스케일 셋 이름, 예시)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">runs-on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> my</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">arc</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">runners</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="구조는-이렇게-잡았습니다">구조는 이렇게 잡았습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EA%B5%AC%EC%A1%B0%EB%8A%94-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%9E%A1%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="구조는 이렇게 잡았습니다에 대한 직접 링크" title="구조는 이렇게 잡았습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>ARC는 크게 세 가지로 구성됩니다. 리스너는 GitHub에 대기 중인 job이 있는지 계속 물어보면서 러너가 몇 개 필요한지 정하고, 컨트롤러는 그 수에 맞춰 러너 파드를 실제로 만들고 지우며, 러너 스케일 셋은 러너를 최소 몇 개, 최대 몇 개까지 띄울지 정해둔 묶음입니다. 저희는 job 하나가 들어오면 러너 파드 하나가 뜨고, job이 끝나면 그 파드가 바로 사라지는 ephemeral이라는 방식으로 구성했습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="job 하나의 라이프사이클. 리스너가 먼저 물어보는 방식이라 저희 쪽에 인바운드 포트를 열 필요가 없다" src="https://nxtgen.co.kr/assets/images/04-25484e872e1a32c7c952051d6e6d3ca7.png" width="2000" height="1080" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">job 하나의 라이프사이클. 리스너가 먼저 물어보는 방식이라 저희 쪽에 인바운드 포트를 열 필요가 없다</span></span>
<em>job 하나의 라이프사이클. 리스너가 먼저 물어보는 방식이라 저희 쪽에 인바운드 포트를 열 필요가 없다</em></p>
<p>여기서 몇 가지를 정했습니다.</p>
<p>리스너가 GitHub으로 먼저 연결을 맺는 long-poll이라는 방식이라, 저희 클러스터에 GitHub이 들어올 구멍을 열어둘 필요가 없습니다. 인증은 개인 토큰(PAT) 대신 GitHub App으로 했습니다. 권한 범위를 러너 관리에 필요한 만큼만 줄 수 있고, 특정 사람 계정에 묶이지 않기 때문입니다.</p>
<p>그리고 ARC 컨트롤러와 리스너는 Karpenter가 관리하지 않는, 항상 떠 있는 노드에 고정했습니다. 컨트롤러가 Karpenter 노드 위에 있으면, 노드가 정리되는 순간 러너를 관리하는 쪽이 같이 사라지는 순환 의존이 생깁니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="관리하는 쪽은 고정 노드에, 일하는 쪽은 Karpenter 노드에 두었다" src="https://nxtgen.co.kr/assets/images/05-9538b278a5034632adc5c8eec9bc52ea.png" width="2000" height="980" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">관리하는 쪽은 고정 노드에, 일하는 쪽은 Karpenter 노드에 두었다</span></span>
<em>관리하는 쪽은 고정 노드에, 일하는 쪽은 Karpenter 노드에 두었다</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="빌드는-옮길-수-없는-워크로드입니다">빌드는 옮길 수 없는 워크로드입니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%B9%8C%EB%93%9C%EB%8A%94-%EC%98%AE%EA%B8%B8-%EC%88%98-%EC%97%86%EB%8A%94-%EC%9B%8C%ED%81%AC%EB%A1%9C%EB%93%9C%EC%9E%85%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="빌드는 옮길 수 없는 워크로드입니다에 대한 직접 링크" title="빌드는 옮길 수 없는 워크로드입니다에 대한 직접 링크" translate="no">​</a></h2>
<p>구성하면서 가장 많이 고민한 부분이 여기입니다. 저희 클러스터는 이미 Karpenter라는 노드 오토스케일러로 앱 노드를 관리하고 있었습니다. 그런데 앱 파드에 맞춰진 규칙을 빌드에 그대로 쓸 수는 없었습니다.</p>
<p>앱 파드는 다른 노드로 옮겨져도 됩니다. Deployment가 다른 노드에 새 파드를 띄우면 서비스는 계속됩니다. 하지만 빌드는 옮길 수가 없습니다. 돌고 있던 빌드가 다른 노드로 쫓겨나면 처음부터 다시 해야 하기 때문입니다. 지금 저희 빌드는 몇 분 안에 끝나는 편이지만, 앞으로 Docker 이미지 빌드처럼 수십 분짜리 작업이 붙을 걸 생각하면 이 부분은 처음부터 막아두는 게 맞다고 봤습니다. CI 노드풀의 규칙은 대부분 이 차이에서 나왔습니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="노드-정리-규칙을-따로-두었습니다">노드 정리 규칙을 따로 두었습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%85%B8%EB%93%9C-%EC%A0%95%EB%A6%AC-%EA%B7%9C%EC%B9%99%EC%9D%84-%EB%94%B0%EB%A1%9C-%EB%91%90%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="노드 정리 규칙을 따로 두었습니다에 대한 직접 링크" title="노드 정리 규칙을 따로 두었습니다에 대한 직접 링크" translate="no">​</a></h3>
<p>저희 기본 노드풀은 노드가 비었거나 덜 쓰이고 있으면 정리하는 규칙(WhenEmptyOrUnderutilized)을 쓰고 있었습니다. 덜 쓰이는 노드의 파드를 다른 노드로 옮기고 노드를 지워서 비용을 아끼는 방식입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="같은 Karpenter라도 앱과 빌드에 다른 정리 규칙을 두었다" src="https://nxtgen.co.kr/assets/images/06-958026f0f463aa58a920bb26e0ee0546.png" width="2000" height="940" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">같은 Karpenter라도 앱과 빌드에 다른 정리 규칙을 두었다</span></span>
<em>같은 Karpenter라도 앱과 빌드에 다른 정리 규칙을 두었다</em></p>
<p>CI 노드풀은 노드가 완전히 비었을 때만 정리하는 WhenEmpty로 두고, 러너 파드에는 건드리지 말라는 어노테이션(karpenter.sh/do-not-disrupt)을 한 번 더 걸었습니다. job이 끝나서 파드가 사라지면 노드가 비고, 그때 비로소 노드가 회수되는 흐름입니다. 인스턴스는 빌드처럼 CPU를 꾸준히 쓰는 작업에 맞게 컴퓨팅 최적화(c)와 범용(m) 계열로만 받게 했습니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="affinity가-아니라-taint로-막았습니다">affinity가 아니라 taint로 막았습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#affinity%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-taint%EB%A1%9C-%EB%A7%89%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="affinity가 아니라 taint로 막았습니다에 대한 직접 링크" title="affinity가 아니라 taint로 막았습니다에 대한 직접 링크" translate="no">​</a></h3>
<p>CI 노드를 따로 만들었다면, 그 노드에는 러너만 올라와야 합니다. 앱 파드가 하나라도 CI 노드에 올라오면 그 노드는 영원히 비지 않고, 비지 않으니 회수도 안 됩니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="방향이 반대다. 막아야 하는 쪽이 노드라면 taint가 맞다" src="https://nxtgen.co.kr/assets/images/07-31316e49c48bc7fa14ec1c1c49504cd4.png" width="2000" height="920" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">방향이 반대다. 막아야 하는 쪽이 노드라면 taint가 맞다</span></span>
<em>방향이 반대다. 막아야 하는 쪽이 노드라면 taint가 맞다</em></p>
<p>처음에는 기존 앱들처럼 affinity로 처리하려고 했습니다. 그런데 affinity는 파드가 어느 노드로 가고 싶은지를 말하는 설정이라서, 반대 방향, 그러니까 다른 파드가 CI 노드에 올라오는 걸 막지는 못합니다. 그래서 CI 노드에 taint를 걸고, 러너 파드에만 그 taint를 견디는 toleration을 주는 방식으로 정리했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="처음-옮긴-빌드는-13분이-걸렸습니다">처음 옮긴 빌드는 13분이 걸렸습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%B2%98%EC%9D%8C-%EC%98%AE%EA%B8%B4-%EB%B9%8C%EB%93%9C%EB%8A%94-13%EB%B6%84%EC%9D%B4-%EA%B1%B8%EB%A0%B8%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="처음 옮긴 빌드는 13분이 걸렸습니다에 대한 직접 링크" title="처음 옮긴 빌드는 13분이 걸렸습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>첫 번째로 옮긴 건 사내 문서 사이트의 빌드였습니다. 이 사이트는 원래 AWS Amplify가 빌드부터 호스팅까지 다 맡고 있었는데, 빌드만 ARC로 옮기고 Amplify는 호스팅만 하도록 역할을 나눴습니다. 깃 액션 시간을 쓰던 서비스들은 그 다음 차례로 잡았습니다. 영향이 적은 것부터 옮겨보면서 러너를 먼저 검증하고 싶었기 때문입니다.</p>
<p>그런데 옮기고 나서 처음 돌려보니 배포 한 번에 13분이 걸렸습니다. 예상보다 훨씬 길어서 당황했는데요. 단계별로 시간을 쪼개보니 원인은 빌드가 아니었습니다. ARC 기본 러너 이미지는 GitHub이 운영하는 ubuntu-latest와 달리 거의 빈 이미지입니다. 그래서 워크플로우가 돌 때마다 Node, zip, AWS CLI 같은 도구를 매번 인터넷에서 새로 받아 설치하고 있었습니다.</p>
<table><thead><tr><th>단계</th><th>기본 이미지</th><th>커스텀 이미지</th></tr></thead><tbody><tr><td>Node 설정</td><td>11초</td><td>1초</td></tr><tr><td>zip 설치</td><td>10초</td><td>0초</td></tr><tr><td>AWS CLI 설치</td><td>516초</td><td>단계 삭제</td></tr><tr><td>npm ci</td><td>34초</td><td>35초</td></tr><tr><td>빌드</td><td>130초</td><td>132초</td></tr><tr><td>전체</td><td>약 13분</td><td>약 3분 40초</td></tr></tbody></table>
<p>AWS CLI 설치 하나가 8분 넘게 걸리고 있었습니다. 그래서 자주 쓰는 도구를 미리 넣은 러너 이미지를 따로 만들어 저희 ECR에 올리고, 러너가 그 이미지로 뜨게 바꿨습니다. 빌드 자체(npm ci, 빌드)는 거의 그대로인데 전체 시간이 13분에서 3분 40초로 줄었습니다.</p>
<p>다만 이 숫자는 조심해서 읽어야 합니다. ARC를 도입해서 빌드가 3배 빨라졌다는 뜻이 아니라, ARC로 옮기면서 생긴 지연을 원인을 찾아 되돌린 숫자입니다. 저는 느린 이유가 빌드가 아니라 빌드 전 준비 과정이었다는 걸 단계별 시간을 보고 나서야 알게 된 점이 더 의미가 있었다고 생각합니다.</p>
<p>이미지를 바꿀 때 하나 조심한 점이 있습니다. 러너 설정은 ArgoCD가 main 브랜치를 기준으로 자동 반영하기 때문에, 새 이미지 태그가 담긴 설정이 이미지보다 먼저 main에 들어가면 없는 이미지로 러너가 뜨면서 모든 레포지토리의 CI가 같이 멈춥니다. 그래서 이미지를 먼저 ECR에 올리고 태그가 있는 걸 확인한 다음에 설정을 머지하는 순서로 고정했습니다.[3]</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="대시보드를-붙이고-나서-보인-것">대시보드를 붙이고 나서 보인 것<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C%EB%A5%BC-%EB%B6%99%EC%9D%B4%EA%B3%A0-%EB%82%98%EC%84%9C-%EB%B3%B4%EC%9D%B8-%EA%B2%83" class="hash-link" aria-label="대시보드를 붙이고 나서 보인 것에 대한 직접 링크" title="대시보드를 붙이고 나서 보인 것에 대한 직접 링크" translate="no">​</a></h2>
<p>러너는 job마다 생겼다 사라지기 때문에, 자원을 얼마나 쓰는지가 그냥은 잘 안 보입니다. 그래서 러너 파드와 CI 노드의 사용량, 어떤 인스턴스 타입이 떴는지를 보는 러너 전용 대시보드를 따로 만들었습니다. 처음 문제였던 "보이지 않는다"를 채우는 첫 단계이기도 합니다.</p>
<p>대시보드로 확인해 보니 러너 파드는 CPU를 0.5코어 요청하고 있었는데, 실제로는 최대 1.96코어를 쓰고 있었습니다. 요청의 4배 정도입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="대시보드에 찍힌 러너 job 두 건. 최대 1.96코어, 평균도 1.2코어 안팎으로 요청량 0.5코어를 크게 넘는다" src="https://nxtgen.co.kr/assets/images/08-03794696742839f797a58e8e81241ea4.png" width="1123" height="107" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">대시보드에 찍힌 러너 job 두 건. 최대 1.96코어, 평균도 1.2코어 안팎으로 요청량 0.5코어를 크게 넘는다</span></span>
<em>대시보드에 찍힌 러너 job 두 건. 최대 1.96코어, 평균도 1.2코어 안팎으로 요청량 0.5코어를 크게 넘는다</em></p>
<table><thead><tr><th>항목</th><th>요청(request)</th><th>실사용 최대</th></tr></thead><tbody><tr><td>CPU</td><td>0.5 core</td><td>1.96 core</td></tr><tr><td>메모리</td><td>1 GiB (상한 4 GiB)</td><td>2.35 GiB</td></tr></tbody></table>
<p>Karpenter는 실제 사용량이 아니라 파드가 요청한 양을 보고 노드 크기를 정합니다. 처음에는 요청량을 0.5코어로 최소한만 잡고 시작했고, 그에 맞춰 가장 작은 2코어 노드가 떴습니다. CPU 상한(limit)은 두지 않아서 러너는 그 노드의 CPU를 최대 1.96코어까지 썼습니다.[4] 커스텀 이미지 이후에도 npm ci와 빌드가 로컬 PC보다 느린 편인데, 2코어 노드에서 돌고 있는 것과 이어져 있다고 보고 있습니다. 최소 사양으로 실사용량을 확인했으니, 다음 단계는 요청량을 실사용 근처로 올려 더 큰 노드에서 돌려 보는 최적화입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="아직은-길다">아직은 길다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%95%84%EC%A7%81%EC%9D%80-%EA%B8%B8%EB%8B%A4" class="hash-link" aria-label="아직은 길다에 대한 직접 링크" title="아직은 길다에 대한 직접 링크" translate="no">​</a></h2>
<p>솔직하게 말하면 저는 3분 40초도 길다고 생각합니다. 이 시간 안에는 빌드만 있는 게 아니라, Karpenter가 노드를 새로 만들고 그 위에 러너 파드가 뜨는 시간까지 같이 들어가 있습니다. 빌드가 없을 때 비용을 0으로 두려고 대기 러너를 0개로 운영하고 있기 때문입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="지금은 job마다 노드와 파드를 새로 만들고, 끝나면 지운다. 시간의 상당 부분이 빌드 바깥에 있다" src="https://nxtgen.co.kr/assets/images/09-c1c159a1237ec204668730aa2821f2e4.png" width="2000" height="1070" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">지금은 job마다 노드와 파드를 새로 만들고, 끝나면 지운다. 시간의 상당 부분이 빌드 바깥에 있다</span></span>
<em>지금은 job마다 노드와 파드를 새로 만들고, 끝나면 지운다. 시간의 상당 부분이 빌드 바깥에 있다</em></p>
<p>실제로 써 보니 이 대기 시간이 불편한 편입니다. 그래서 노드를 바로 지우지 않고 남겨두는 방법이나 빌드 캐시를 붙이는 방법을 앞으로의 개선 사항으로 생각하고 있습니다. 다만 아직 어떻게 하겠다고 정한 단계는 아니고, 쓰는 레포지토리가 더 늘어나면 그때 비용과 같이 놓고 판단하려고 합니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="러너를-vpc-안에-두면-통신비도-손볼-수-있습니다">러너를 VPC 안에 두면 통신비도 손볼 수 있습니다<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%9F%AC%EB%84%88%EB%A5%BC-vpc-%EC%95%88%EC%97%90-%EB%91%90%EB%A9%B4-%ED%86%B5%EC%8B%A0%EB%B9%84%EB%8F%84-%EC%86%90%EB%B3%BC-%EC%88%98-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="러너를 VPC 안에 두면 통신비도 손볼 수 있습니다에 대한 직접 링크" title="러너를 VPC 안에 두면 통신비도 손볼 수 있습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>러너를 저희 VPC로 가져오면서 같이 보게 된 점이 하나 더 있습니다. 러너는 private 서브넷에 있어서, AWS 서비스를 부를 때 기본적으로 NAT 게이트웨이를 지나 나갔다가 다시 AWS로 들어옵니다. NAT는 지나가는 데이터 GB당 처리 요금이 붙습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="ECR 이미지 레이어의 실제 데이터는 S3에 있어서, S3 Gateway 엔드포인트만 있어도 pull 트래픽 상당 부분이 NAT를 피한다" src="https://nxtgen.co.kr/assets/images/10-ac28415897ad10e6001ad08718039737.png" width="2000" height="1120" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">ECR 이미지 레이어의 실제 데이터는 S3에 있어서, S3 Gateway 엔드포인트만 있어도 pull 트래픽 상당 부분이 NAT를 피한다</span></span>
<em>ECR 이미지 레이어의 실제 데이터는 S3에 있어서, S3 Gateway 엔드포인트만 있어도 pull 트래픽 상당 부분이 NAT를 피한다</em></p>
<p>다행히 용량이 큰 쪽은 이미 정리되어 있습니다. 컨테이너 이미지를 이루는 레이어는 ECR 뒤편의 S3에 저장되는데, 저희 VPC에는 S3 Gateway 엔드포인트가 있어서 이 트래픽은 NAT를 거치지 않습니다. 남은 건 ECR API, ECR Docker, STS 호출입니다. 이쪽도 인터페이스 엔드포인트를 두면 AWS 내부로만 흐르게 할 수 있지만, 시간당 고정 요금이 붙어서 트래픽이 적은 지금은 NAT가 더 쌀 수 있습니다. 빌드가 더 늘어나면 비용을 비교해서 바꿀 예정입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="왜-이렇게-골랐는지">왜 이렇게 골랐는지<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%99%9C-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A8%EB%9E%90%EB%8A%94%EC%A7%80" class="hash-link" aria-label="왜 이렇게 골랐는지에 대한 직접 링크" title="왜 이렇게 골랐는지에 대한 직접 링크" translate="no">​</a></h2>
<p>구축하면서 막혔던 지점들도 물론 있었습니다. 다만 대부분은 설정을 고치면 끝나는 문제였기 때문에, 이 글에서는 그보다 각 지점에서 왜 이쪽을 골랐는지를 정리해두는 게 더 쓸모가 있을 것 같습니다.</p>
<table><thead><tr><th>선택 지점</th><th>고른 것</th><th>대신 고려한 것</th><th>이유</th></tr></thead><tbody><tr><td>ARC 모드</td><td>러너 스케일 셋</td><td>구버전 RunnerDeployment</td><td>GitHub이 현재 지원하는 방식이고, 구버전은 레거시로 분류됨</td></tr><tr><td>인증</td><td>GitHub App</td><td>개인 토큰(PAT)</td><td>권한 범위를 좁힐 수 있고 사람 계정에 묶이지 않음</td></tr><tr><td>러너 수명</td><td>job 하나에 파드 하나</td><td>상주 러너</td><td>job 사이에 상태나 크리덴셜이 남지 않음</td></tr><tr><td>노드</td><td>전용 노드풀, taint, spot</td><td>앱 노드풀 공유</td><td>빌드 중 evict 방지, 빈 노드 회수</td></tr><tr><td>대기 러너</td><td>0개</td><td>1개에서 2개</td><td>처음에는 비용 0을 우선함. 개선 사항으로 고려 중</td></tr><tr><td>Docker 빌드</td><td>DinD</td><td>Kaniko, BuildKit</td><td>기존 워크플로우를 고치지 않고 시작하기 위해</td></tr><tr><td>ECR 인증</td><td>기존 OIDC 유지</td><td>IRSA로 바로 전환</td><td>레포지토리 쪽 변경을 최소화</td></tr><tr><td>레지스트리 인증 위치</td><td>워크플로우</td><td>러너에 권한 부여</td><td>한 레포의 권한이 다른 레포 빌드에 새지 않게</td></tr><tr><td>OS</td><td>리눅스 빌드만 ARC로</td><td>윈도우, 맥도 이전</td><td>저희 클러스터는 리눅스 노드만 운영하고, 맥은 쿠버네티스 노드로 쓸 수 없음</td></tr></tbody></table>
<p>마지막 줄은 처음 문제로 다시 돌아가는 부분입니다. 윈도우나 맥 빌드는 저희 클러스터로 옮길 수가 없습니다. 하지만 리눅스 빌드를 저희 쪽으로 빼두면, 앞으로 윈도우나 맥 빌드가 붙더라도 공용 시간을 그쪽에 남겨둘 수 있습니다. 시간이 가장 비싸게 차감되는 빌드를 위해서 공용 지갑을 비워두는 효과입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="대신-내준-것">대신 내준 것<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EB%8C%80%EC%8B%A0-%EB%82%B4%EC%A4%80-%EA%B2%83" class="hash-link" aria-label="대신 내준 것에 대한 직접 링크" title="대신 내준 것에 대한 직접 링크" translate="no">​</a></h2>
<p>정직하게 적자면 이 구성이 싸지는 않습니다. 빌드가 없으면 노드 비용은 0이지만, 대기 러너를 두게 되면 그 비용이 더해지고, 앞에서 본 통신비나 러너를 운영하는 사람의 시간도 비용입니다. 소규모 팀 기준으로는 GitHub 러너를 그냥 쓰는 것과 비슷하거나 조금 더 나올 수 있습니다. 그래서 저는 이 구성의 가치가 비용 절감이 아니라 통제에 있다고 봅니다. 빌드가 VPC 안에서 돌고, 사용량이 저희 대시보드에 보이고, 환경을 저희가 정할 수 있다는 점입니다.</p>
<p>보안 쪽에도 내준 게 하나 있습니다. Docker 이미지를 빌드하기 위해서 러너 옆에 DinD(Docker in Docker) 컨테이너를 privileged로 띄우고 있는데, 이건 사실상 노드의 root 권한과 같습니다.[5] 그래서 CI 노드를 앱 노드와 taint로 분리해 두고, CI 노드의 메타데이터 접근 홉 제한(IMDS hop limit)을 1로 걸어서 DinD 안의 컨테이너가 노드의 IAM 자격 증명을 꺼내 쓰지 못하게 막아두었습니다. 그리고 이 러너는 사내 private 레포지토리 전용으로 쓰는 걸 원칙으로 두고 있습니다. public 레포지토리에 연결하면 외부에서 보낸 PR 코드가 저희 VPC 안에서 실행될 수 있기 때문입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="지금-어디까지-왔나">지금 어디까지 왔나<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%A7%80%EA%B8%88-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%99%94%EB%82%98" class="hash-link" aria-label="지금 어디까지 왔나에 대한 직접 링크" title="지금 어디까지 왔나에 대한 직접 링크" translate="no">​</a></h2>
<p>솔직하게 적으면 아직 첫 단계입니다. 지금 ARC로 옮겨서 실제로 돌고 있는 건 사내 문서 사이트 빌드 하나이고, 깃 액션 시간을 쓰던 서비스들은 차례로 옮길 예정입니다. 그래서 공용 시간이 실제로 얼마나 아껴지는지는 아직 숫자로 말할 단계가 아닙니다.</p>
<p>앞으로 해볼 것들은 이 정도입니다. 깃 액션 시간을 쓰던 서비스들을 옮기는 일, 대기 러너와 캐시로 체감 시간을 줄이는 일, VPC 엔드포인트로 통신비를 정리하는 일, 레포지토리별 사용 시간과 실패율을 대시보드에 같이 올리는 일, 그리고 DinD를 rootless 빌드로 바꾸는 일입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="정리하면">정리하면<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%A0%95%EB%A6%AC%ED%95%98%EB%A9%B4" class="hash-link" aria-label="정리하면에 대한 직접 링크" title="정리하면에 대한 직접 링크" translate="no">​</a></h2>
<p>이번 작업은 깃 액션 시간이 바닥난 일에서 시작했지만, 하다 보니 더 중요했던 건 빌드를 저희가 볼 수 있게 된 점이었던 것 같습니다. 깃 액션 시간이 조직 전체의 공용 지갑이라는 것도, 옮긴 빌드가 13분 걸린 이유가 빌드가 아니었다는 것도, 그걸 고친 3분 40초가 아직 길다는 것도 모두 숫자를 보고 나서야 알았습니다.</p>
<p>그리고 Karpenter 같은 오토스케일러 위에 빌드를 올릴 때는, 빌드가 옮길 수 없는 워크로드라는 걸 먼저 놓고 규칙을 짜는 게 맞다고 생각하게 되었습니다. 정리 규칙, taint, 요청량이 전부 그 한 가지에서 나왔습니다.</p>
<p>여기까지가 저희가 깃 액션 시간이 바닥난 일에서 시작해서 러너를 저희 EKS로 가져오게 된 과정이었습니다. 읽어주셔서 감사합니다.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] GitHub Actions의 무료 시간과 OS별 차감 배수(리눅스 1배, 윈도우 2배, 맥 10배)는 GitHub 요금 정책에 따라 바뀔 수 있는 값입니다. 도입 시점에 GitHub 공식 요금 문서를 다시 확인하시는 게 좋습니다. public 레포지토리는 GitHub 러너 사용이 무료라서 이 문제는 private 레포지토리에 해당합니다.</p>
<p>[2] self-hosted 러너가 모든 공급망 위험을 막아주지는 않습니다. 외부 Action은 태그 대신 커밋 SHA로 고정해야 하고, 개발자 단말의 크리덴셜 관리는 인프라와 별개의 영역입니다. 저희는 사내 가이드에서 외부 Action을 SHA로 고정하도록 안내하고 있습니다.</p>
<p>[3] 이미지 태그는 커밋 SHA가 아니라 Dockerfile 내용의 해시로 붙였습니다. Dockerfile이 바뀌지 않은 커밋에서는 같은 태그가 나와서, 불필요하게 이미지를 다시 빌드하지 않게 됩니다.</p>
<p>[4] Karpenter는 스케줄링이 안 된 파드들의 요청량(requests)을 합쳐서 그걸 담을 수 있는 가장 싼 인스턴스를 고릅니다. 요청량이 실사용보다 작으면 노드가 작게 잡히고, 같은 노드에 job이 여러 개 몰리면 서로 CPU를 뺏게 됩니다.</p>
<p>[5] privileged 컨테이너는 호스트의 장치와 커널 기능에 거의 제한 없이 접근할 수 있습니다. 그래서 그 컨테이너 안에서 실행되는 코드가 노드 전체에 영향을 줄 수 있습니다. DinD가 Docker 데몬을 띄우려면 이 권한이 필요합니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="참고">참고<a href="https://nxtgen.co.kr/blog/2026/10/06/arc-runner-on-eks#%EC%B0%B8%EA%B3%A0" class="hash-link" aria-label="참고에 대한 직접 링크" title="참고에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class="">Actions Runner Controller, GitHub Docs</li>
<li class="">Karpenter Disruption (Consolidation, do-not-disrupt), Karpenter Docs</li>
<li class="">About billing for GitHub Actions, GitHub Docs</li>
<li class="">Security hardening for GitHub Actions, GitHub Docs</li>
<li class="">Amazon ECR interface VPC endpoints (AWS PrivateLink), AWS Docs</li>
</ul>
<p>이 글의 수치와 구성은 실제 운영 과정에서 확인한 것입니다. 사내 레포지토리 이름, 계정 정보, 내부 대역은 익명화했습니다.</p>]]></content:encoded>
            <category>ci-cd</category>
            <category>github-actions</category>
            <category>arc</category>
            <category>eks</category>
            <category>karpenter</category>
        </item>
        <item>
            <title><![CDATA[결정 모델(Jev)의 쓸모는 정답률보다 모를 때 물러서는 방식에 있다]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain</guid>
            <pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[글을 쓰지 않고 판단만 돌려주는 결정 모델 jev를 분류 과제 120건에서 추론형 LLM과 나란히 돌려 봤습니다. 정답률은 LLM에 조금 못 미쳤지만 모를 때 물러서는 모양이 좋았고 같은 입력에는 같은 답을 내서, LLM을 대신하는 자리보다 LLM 앞에 세우는 자리가 맞았습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>글을 쓰지 않고 판단만 돌려주는 모델이 나왔습니다. TypeSafe의 jev입니다. 에이전트 안의 LLM 호출 중에서 이런 모델로 옮겨 앉힐 수 있는 자리가 어디인지 훑어보고, 가장 전형적인 분류 과제 하나에서 추론형 LLM과 나란히 돌려 봤습니다. 정답률은 LLM에 조금 못 미쳤습니다. 대신 모를 때 물러서는 모양이 좋았고, 같은 입력에는 같은 답을 냈습니다. 그래서 LLM을 대신하는 자리보다는 LLM 앞에 세우는 자리가 맞다고 봤습니다.</p>
</blockquote>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-e59e64effe060ce2150c1541abcbe3c3.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="글을-쓰지-않는-모델">글을 쓰지 않는 모델<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EA%B8%80%EC%9D%84-%EC%93%B0%EC%A7%80-%EC%95%8A%EB%8A%94-%EB%AA%A8%EB%8D%B8" class="hash-link" aria-label="글을 쓰지 않는 모델에 대한 직접 링크" title="글을 쓰지 않는 모델에 대한 직접 링크" translate="no">​</a></h2>
<p>에이전트를 만들다 보면 모델 호출의 상당수가 사실은 글을 쓰는 일이 아니더라고요. 이 요청이 환불인지 재예약인지, 두 이름이 같은 회사인지, 이 문장이 정책에 걸리는지를 묻는 것들입니다. 답은 보기 몇 개 중 하나인데도 LLM에 JSON으로 달라고 부탁하고, 파싱하고, 보기 밖의 답이 오면 버리는 코드를 매번 짜게 되죠. jev는 바로 이 자리를 노린 모델입니다. 텍스트를 생성하지 않고, 판단할 내용(state)과 질문을 받아 타입이 정해진 답과 확률을 돌려줍니다[1]. 스스로를 System One 모델이라고 부르는데, 빠르고 직관적인 사고와 느리고 숙고하는 사고를 나눈 카너먼의 구분에서 빠른 쪽을 맡겠다는 뜻입니다[2].</p>
<p>질문 종류는 셋입니다.</p>
<table><thead><tr><th>종류</th><th>묻는 것</th><th>돌려주는 것</th></tr></thead><tbody><tr><td>Choice</td><td>보기 중 하나를 고르기</td><td>고른 보기, 보기별 확률, 확신도</td></tr><tr><td>Score</td><td>순서 있는 등급 중 어디쯤인가</td><td>점수, 등급별 확률, 확신도</td></tr><tr><td>Noul</td><td>예인가 아니오인가</td><td>예일 확률(0-1)</td></tr></tbody></table>
<p>한 요청에 질문 여러 개를 같은 입력에 대해 한꺼번에 물을 수 있고, 답은 항상 우리가 준 보기 안에서만 나옵니다. 가격은 입력 100만 토큰당 0.042달러이고 출력은 받지 않습니다. 한계도 문서에 솔직하게 적혀 있습니다. 텍스트만 받고, 영어가 기본이라 한중일 문자를 포함한 다른 언어는 처리는 하지만 같은 수준은 아니라고 합니다. 셈과 개수 세기에 약하고, 이중 부정이나 돌려 말한 조건은 덜 믿을 만하고, 판단과 무관한 내용이 입력에 많이 섞이면 정확도가 떨어진다고 해요[3]. 결국에는 좁고 닫힌 질문에, 짧은 입력을 보고 빠르게 답하는 모델입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="llm-호출을-다시-세어-보면">LLM 호출을 다시 세어 보면<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#llm-%ED%98%B8%EC%B6%9C%EC%9D%84-%EB%8B%A4%EC%8B%9C-%EC%84%B8%EC%96%B4-%EB%B3%B4%EB%A9%B4" class="hash-link" aria-label="LLM 호출을 다시 세어 보면에 대한 직접 링크" title="LLM 호출을 다시 세어 보면에 대한 직접 링크" translate="no">​</a></h2>
<p>새 모델이 나오면 저는 먼저 지금 돌아가는 에이전트의 LLM 호출을 하나씩 꺼내 봅니다. 이번에도 그렇게 했는데, 꺼내 놓고 보니 모양이 크게 셋으로 갈리더라고요. 기준은 네 가지였습니다.</p>
<p>보기가 닫혀 있는가, 셈이 필요 없는가, 텍스트만으로 되는가, 도구를 쓰며 여러 단계를 밟지 않고 한 번에 판단하는가.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="LLM 호출을 jev 조건에 대 본 결과. 분류와 라우팅, 이름과 엔티티 매칭, 해당 여부 확인과 가드레일은 잘 맞고, 등급 평가, 제외 조건이 핵심인 판단, 규칙이 놓친 것만 보조 분류하는 자리는 조건부이며, 도구를 쓰는 다단계 추론, 이미지 판독, 숫자 추출과 계산, 문장 생성은 맞지 않는다" src="https://nxtgen.co.kr/assets/images/02-8cdd292250d2b0da4efb9cc65cf414e3.png" width="1400" height="603" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">LLM 호출을 jev 조건에 대 본 결과. 분류와 라우팅, 이름과 엔티티 매칭, 해당 여부 확인과 가드레일은 잘 맞고, 등급 평가, 제외 조건이 핵심인 판단, 규칙이 놓친 것만 보조 분류하는 자리는 조건부이며, 도구를 쓰는 다단계 추론, 이미지 판독, 숫자 추출과 계산, 문장 생성은 맞지 않는다</span></span></p>
<p>잘 맞는 자리는 분류와 라우팅, 이름이나 엔티티가 같은 대상인지 보는 매칭, 어떤 규칙이 이 입력에 정말 적용되는지 다시 보는 확인이었습니다. 셋 다 지금은 LLM을 한 번 불러 보기 중 하나나 예/아니오를 받는 구조라 그대로 옮겨 앉힐 수 있어요. 가드레일처럼 입력이나 출력을 거르는 자리도 같은 모양입니다.</p>
<p>조건부인 자리도 있었습니다. 등급을 매기는 평가는 Score로 물을 수는 있지만, 판단에 참고할 사례나 기준을 입력에 같이 넣어야 해서 입력이 길어집니다. 무엇을 제외하느냐가 핵심인 판단, 예를 들어 이 대상에 포함되지만 이런 경우는 뺀다는 식의 조건은 jev 문서가 스스로 약하다고 밝힌 영역이라 좀 불안합니다.</p>
<p>맞지 않는 자리는 분명했어요. 도구를 부르며 근거를 찾아가는 다단계 추론, 이미지나 스캔 문서 판독, 숫자를 읽고 합을 맞추는 일, 사람이 읽을 문장을 쓰는 일입니다. 결국에는 에이전트가 하던 일 가운데 사실상 분류였던 것들만 추려 낸 셈이죠. 추려 보면 생각보다 그 몫이 작지 않습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="무엇으로-재-봤나">무엇으로 재 봤나<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EB%AC%B4%EC%97%87%EC%9C%BC%EB%A1%9C-%EC%9E%AC-%EB%B4%A4%EB%82%98" class="hash-link" aria-label="무엇으로 재 봤나에 대한 직접 링크" title="무엇으로 재 봤나에 대한 직접 링크" translate="no">​</a></h2>
<p>시험 과제는 가장 전형적인 모양으로 골랐습니다. 짧은 텍스트 한 줄을 받아 열한 개 카테고리 중 하나로 나누는 분류입니다. 입력에는 분류 대상 설명과 함께 원래 붙어 있던 라벨 같은 메타데이터가 들어 있는데, 실무 데이터라 이 메타데이터가 내용과 어긋나는 경우가 되게 많습니다. 그래서 메타데이터는 참고만 하고 내용을 보고 판단해야 하죠. 카테고리 정의도 서로 조금씩 겹칩니다. 외부 강연료는 강연 카테고리에도, 외부 위탁 카테고리에도 걸칠 수 있는 식이에요.</p>
<p>이 과제의 원래 구조는 흔한 세 단입니다. 규칙으로 가를 수 있으면 코드가 바로 정하고, 못 가른 것만 LLM에 묻고, LLM도 모르면 메타데이터 기반의 결정론적 기본값으로 내려갑니다. LLM은 GPT-6 계열의 추론 모델이고, 확신이 없으면 null을 내도록 지시해 뒀습니다. 결국에는 두 번째 단을 jev로 바꿀 수 있느냐가 이번 질문이었습니다.</p>
<p>실제 데이터에서 중복을 뺀 120건을 뽑고, 정답 라벨은 한 건씩 직접 달았습니다. 여기서 좀 고민이 됐는데, 애매한 건을 답 하나로 못 박으면 채점이 제 취향을 재는 일이 됩니다. 그래서 정답을 허용 답의 목록으로 두고, 여러 카테고리가 섞였거나 내용이 부족한 건은 판단불가도 정답으로 인정했습니다. 정답이 하나로 확정되는 건은 66개였고, 이것들을 확정 건으로 따로 집계했습니다.</p>
<p>비교 대상은 세 갈래입니다. 원래 구조 그대로의 GPT-6, 판단불가를 보기에 넣은 jev Choice, 카테고리마다 예/아니오를 따로 묻는 jev Noul입니다. jev는 질문과 보기 설명을 한국어로 준 판과 영어로 준 판을 따로 돌렸고, 같은 입력을 두 번씩 돌려 반복했을 때 같은 답이 나오는지도 봤습니다. 호출은 pydantic-ai의 TypeSafe 연동으로 했습니다. 출력 타입의 필드 설명이 질문이 되고, 보기마다 붙인 설명이 판단 기준이 되는 방식이에요[4].</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="같은 애매한 항목을 두 방식으로 물었을 때. Choice는 질문 하나에 보기 확률의 합이 1이라 카테고리 A 0.55로 몰려 오답을 내고, Noul은 보기마다 따로 물어 가장 높은 예가 0.24에 그쳐 판단을 보류한다" src="https://nxtgen.co.kr/assets/images/03-8175c658fbb3ad5828a7bf270155865f.png" width="1400" height="632" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">같은 애매한 항목을 두 방식으로 물었을 때. Choice는 질문 하나에 보기 확률의 합이 1이라 카테고리 A 0.55로 몰려 오답을 내고, Noul은 보기마다 따로 물어 가장 높은 예가 0.24에 그쳐 판단을 보류한다</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="숫자부터">숫자부터<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EC%88%AB%EC%9E%90%EB%B6%80%ED%84%B0" class="hash-link" aria-label="숫자부터에 대한 직접 링크" title="숫자부터에 대한 직접 링크" translate="no">​</a></h2>
<table><thead><tr><th>방식</th><th>정확도(전체)</th><th>정확도(확정 건)</th><th>답한 비율</th><th>답한 것 중 정답</th><th>반복 일치</th><th>건당 지연</th></tr></thead><tbody><tr><td>GPT-6 단독</td><td>96.7-99.2%</td><td>95.5-98.5%</td><td>75-88%</td><td>100%</td><td>80.0%</td><td>0.9초</td></tr><tr><td>jev Choice 한국어</td><td>90.0-91.7%</td><td>98.5%</td><td>90-92%</td><td>91-93%</td><td>97.5-99.2%</td><td>0.2초</td></tr><tr><td>jev Choice 영어</td><td>85.0%</td><td>95.5%</td><td>91-92%</td><td>84%</td><td>99.2%</td><td>0.2초</td></tr><tr><td>jev Noul 한국어</td><td>94.2-95.8%</td><td>97.0-98.5%</td><td>69-73%</td><td>98-99%</td><td>96.7%</td><td>0.2초</td></tr><tr><td>jev Noul 영어</td><td>90.0%</td><td>86.4%</td><td>72%</td><td>94%</td><td>99.2%</td><td>0.2초</td></tr></tbody></table>
<p>범위는 두 회차의 값이고, GPT-6의 지연은 여러 건을 묶어 한 번 부른 시간을 건수로 나눈 값입니다[5].</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="답한 비율과 답한 것 중 정답 비율의 관계. GPT-6는 81% 답해 전부 맞혔고, Choice 한국어는 91% 답해 92% 맞혔고, Noul 한국어는 71% 답해 98% 맞혔으며, Noul 한국어에 GPT-6 폴백을 붙이면 86% 답해 99% 맞혔다" src="https://nxtgen.co.kr/assets/images/04-35500c644295c6039a274a8b7f337f37.png" width="1400" height="778" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">답한 비율과 답한 것 중 정답 비율의 관계. GPT-6는 81% 답해 전부 맞혔고, Choice 한국어는 91% 답해 92% 맞혔고, Noul 한국어는 71% 답해 98% 맞혔으며, Noul 한국어에 GPT-6 폴백을 붙이면 86% 답해 99% 맞혔다</span></span></p>
<p>GPT-6는 답한 것 중에는 하나도 틀리지 않았습니다. 대신 많게는 넷 중 하나꼴로 판단을 미뤘고, 미룬 것 중에는 사람이 보면 바로 답이 나오는 쉬운 건도 있었어요. jev Choice는 거의 다 답했는데 그만큼 틀린 답도 냈고, Noul은 GPT-6보다 조금 더 미루는 대신 답한 것의 98-99%를 맞혔습니다. 확정 건만 놓고 보면 세 방식이 거의 같습니다. 결국에는 차이가 쉬운 건이 아니라 애매한 건을 어떻게 다루느냐에서 났습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="choice는-확신도와-같이-봐야-한다">Choice는 확신도와 같이 봐야 한다<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#choice%EB%8A%94-%ED%99%95%EC%8B%A0%EB%8F%84%EC%99%80-%EA%B0%99%EC%9D%B4-%EB%B4%90%EC%95%BC-%ED%95%9C%EB%8B%A4" class="hash-link" aria-label="Choice는 확신도와 같이 봐야 한다에 대한 직접 링크" title="Choice는 확신도와 같이 봐야 한다에 대한 직접 링크" translate="no">​</a></h2>
<p>Choice의 오답을 열어 보니 거의 다 애매한 건이었습니다. 두 카테고리의 정의에 모두 걸치거나, 어느 정의에도 딱 맞지 않는 것들이에요. 사람이 봐도 답 하나로 떨어지지 않습니다. 판단불가라는 보기를 줬는데도 잘 고르지 않더라고요. 보기 설명이 조금이라도 걸리는 카테고리가 있으면 그쪽으로 확률이 몰렸습니다.</p>
<p>대신 확신도는 쓸 만했습니다. 한국어 Choice 1회차에서 확신도 0.9 이상인 63건은 전부 맞았고, 0.5 미만인 22건은 64%만 맞았습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="jev Choice 한국어의 확신도 구간별 정답 비율. 0.5 미만 64%(22건), 0.5-0.7 100%(5건), 0.7-0.9 90%(20건), 0.9 이상 100%(63건)" src="https://nxtgen.co.kr/assets/images/05-37c735e6322886bbdd1486901259cd9a.png" width="1400" height="591" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">jev Choice 한국어의 확신도 구간별 정답 비율. 0.5 미만 64%(22건), 0.5-0.7 100%(5건), 0.7-0.9 90%(20건), 0.9 이상 100%(63건)</span></span></p>
<p>같은 1회차에서 확신도 0.5 미만을 판단불가로 돌리면 정확도 95.0%, 답한 비율 73%, 답한 것 중 정답 97.7%가 됩니다. GPT-6와 비슷한 성향이 되는 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="noul로-바꿔-물었더니-보류가-저절로-나왔다">Noul로 바꿔 물었더니 보류가 저절로 나왔다<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#noul%EB%A1%9C-%EB%B0%94%EA%BF%94-%EB%AC%BC%EC%97%88%EB%8D%94%EB%8B%88-%EB%B3%B4%EB%A5%98%EA%B0%80-%EC%A0%80%EC%A0%88%EB%A1%9C-%EB%82%98%EC%99%94%EB%8B%A4" class="hash-link" aria-label="Noul로 바꿔 물었더니 보류가 저절로 나왔다에 대한 직접 링크" title="Noul로 바꿔 물었더니 보류가 저절로 나왔다에 대한 직접 링크" translate="no">​</a></h2>
<p>Noul은 보기 하나하나에 대해 이 항목이 이 카테고리에 해당하는가를 따로 묻습니다. 카테고리가 여섯 개면 질문 여섯 개가 한 요청에 같이 갑니다. 저는 예일 확률이 가장 높은 카테고리를 답으로 삼고, 그 확률이 0.5에 못 미치면 판단불가로 뒀습니다.</p>
<p>결과는 확연히 달랐습니다. 한국어 Noul 1회차의 오답 7건 중 6건이 판단 보류였고, 엉뚱한 답은 1건뿐이었어요. 그 1건도 단가와 횟수만 있고 무엇에 대한 건지 적혀 있지 않은 항목입니다. 보류한 건들은 메타데이터가 내용과 어긋나 있거나 카테고리 경계에 걸친 것들이었는데, 원래 구조에서는 이런 건이 결정론적 기본값으로 넘어가니 피해가 크지 않습니다. Choice에서 틀리던 애매한 건들은 Noul에서 대부분 맞거나 보류로 바뀌었고요.</p>
<p>왜 이런 차이가 날까 생각해 보면 구조 때문입니다. Choice는 보기끼리 확률을 나눠 가져서 합이 1이 되니, 어딘가에는 몰리게 돼 있죠. Noul은 보기마다 따로 묻기 때문에 모든 보기에 아니오라고 할 수 있어요. 어느 보기에도 확신이 없는 건은 모든 확률이 낮게 나오고, 그게 곧 보류 신호가 됩니다. 모르는 것을 모른다고 말할 자리가 질문 구조 안에 있는 겁니다.</p>
<p>반대로 기대했다가 안 된 것도 있었습니다. 예가 둘 이상 나온 건이 10개였는데, 이걸로 여러 카테고리가 섞인 건을 가려낼 수 있지 않을까 했거든요. 실제로는 카테고리 정의가 서로 겹쳐서 두 곳에 다 예가 나온 경우가 대부분이었고, 진짜 섞인 건은 1건뿐이었습니다. 멀티 라벨 탐지기로 쓰려면 카테고리 정의부터 서로 배타적으로 다듬어야 하는 겁니다. 비용은 질문 수만큼 늘어서 Noul의 건당 입력 토큰이 Choice의 1.4배쯤이었지만, 120건 전체를 돌려도 1센트가 안 됩니다. 결국에는 판단불가를 보기로 주는 것보다 보기마다 따로 묻는 쪽이 보류를 더 정직하게 끌어냈습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="한국어로-묻는-게-나았다">한국어로 묻는 게 나았다<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%ED%95%9C%EA%B5%AD%EC%96%B4%EB%A1%9C-%EB%AC%BB%EB%8A%94-%EA%B2%8C-%EB%82%98%EC%95%98%EB%8B%A4" class="hash-link" aria-label="한국어로 묻는 게 나았다에 대한 직접 링크" title="한국어로 묻는 게 나았다에 대한 직접 링크" translate="no">​</a></h2>
<p>문서가 영어를 권하길래 질문과 보기 설명을 영어로 옮긴 판도 돌렸습니다. 판단할 입력 자체는 한국어 그대로 두고요. 결과는 Choice와 Noul 모두 한국어 판이 나았습니다. 영어 Choice는 같은 성격의 애매한 건들을 한 카테고리로 통째로 몰았고, 영어 Noul은 한국어 판이 맞힌 쉬운 건 몇 개를 보류하거나 이웃 카테고리로 틀렸어요. 입력은 한국어인데 질문만 영어면 모델이 두 언어 사이를 한 번 더 건너야 하니, 그 사이에서 뜻이 좀 새는 것 같습니다. 과제 하나에서 본 결과라 일반화하긴 이르지만, 한국어 입력을 다룬다면 질문도 한국어로 먼저 재 보는 게 맞겠습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="같은-입력에-같은-답이-나오는가">같은 입력에 같은 답이 나오는가<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EA%B0%99%EC%9D%80-%EC%9E%85%EB%A0%A5%EC%97%90-%EA%B0%99%EC%9D%80-%EB%8B%B5%EC%9D%B4-%EB%82%98%EC%98%A4%EB%8A%94%EA%B0%80" class="hash-link" aria-label="같은 입력에 같은 답이 나오는가에 대한 직접 링크" title="같은 입력에 같은 답이 나오는가에 대한 직접 링크" translate="no">​</a></h2>
<p>개인적으로 이번에 제일 크게 본 숫자는 이것입니다. 같은 120건을 두 번 돌렸을 때 같은 답을 낸 비율이 GPT-6는 80%, jev는 97-99%였습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="같은 120건을 두 번 돌렸을 때 같은 답을 낸 비율. GPT-6 단독 80.0%, Choice 한국어 97.5%, Choice 영어 99.2%, Noul 한국어 96.7%, Noul 영어 99.2%, Noul 한국어에 GPT-6 폴백을 붙이면 84.2%" src="https://nxtgen.co.kr/assets/images/06-976ba564777580529613e117ae0db67c.png" width="1400" height="607" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">같은 120건을 두 번 돌렸을 때 같은 답을 낸 비율. GPT-6 단독 80.0%, Choice 한국어 97.5%, Choice 영어 99.2%, Noul 한국어 96.7%, Noul 영어 99.2%, Noul 한국어에 GPT-6 폴백을 붙이면 84.2%</span></span></p>
<p>GPT-6의 흔들림은 대부분 답과 보류 사이에서 일어났습니다. 한 번은 카테고리를 답하고 다음에는 모르겠다고 하는 식입니다. 정확도로만 보면 둘 다 틀린 건 아니니 괜찮아 보이는데, 판정이 뒤로 이어지는 파이프라인에서는 사정이 좀 다릅니다. 사용자가 입력 일부만 고쳐서 다시 돌렸는데 손대지 않은 항목의 분류가 바뀌면, 그 뒤에 적용되는 처리와 결과 화면이 통째로 달라집니다. 사용자는 자기가 고친 것 때문인지 모델 때문인지 알 수가 없죠. 이런 흔들림을 막으려고 이전 결과를 재사용하는 조건을 따로 설계하는 팀이 많은데, 앞단의 모델 자체가 안정적이면 그 설계가 훨씬 단순해집니다.</p>
<p>판정 파이프라인에서 일관성은 정확도만큼 비싼 속성이다. 이번 시험에서 제일 오래 남는 건 이 문장일 것 같습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="폴백은-어떻게-짤-것인가">폴백은 어떻게 짤 것인가<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%ED%8F%B4%EB%B0%B1%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A7%A4-%EA%B2%83%EC%9D%B8%EA%B0%80" class="hash-link" aria-label="폴백은 어떻게 짤 것인가에 대한 직접 링크" title="폴백은 어떻게 짤 것인가에 대한 직접 링크" translate="no">​</a></h2>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="폴백을 붙인 분류 흐름. 규칙으로 먼저 가르고, 못 가른 건은 jev Noul이 약 7할을 확정한다. 보류한 약 3할만 LLM에 넘기고, LLM도 null이면 결정론적 기본값으로, 그래도 못 가르면 사람 검토로 보낸다" src="https://nxtgen.co.kr/assets/images/07-6e1acbfb27835f16acb9e64b665559f9.png" width="1400" height="392" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">폴백을 붙인 분류 흐름. 규칙으로 먼저 가르고, 못 가른 건은 jev Noul이 약 7할을 확정한다. 보류한 약 3할만 LLM에 넘기고, LLM도 null이면 결정론적 기본값으로, 그래도 못 가르면 사람 검토로 보낸다</span></span></p>
<p>jev Noul이 보류한 건만 GPT-6에 넘기는 폴백을 이미 받아 둔 결과로 조합해 봤습니다. 따로 다시 돌린 게 아니라 같은 회차의 두 결과를 건마다 이어 붙인 계산입니다. 두 회차 모두 확정 건 정확도가 100%였고, 전체 정확도는 98.3-99.2%로 GPT-6 단독과 같거나 조금 나았습니다. GPT-6를 부르는 건은 120개 중 33-37개로, 3할 정도로 줄었고요. 둘 다 답한 75건에서는 73건이 같은 답이었으니, 앞단의 판정이 뒷단과 크게 어긋나지도 않았습니다.</p>
<p>그런데 조합한 흐름의 반복 일치율은 84.2%였습니다. jev 단독의 96.7%에서 뚝 떨어진 거죠. LLM으로 넘긴 3할에서 LLM의 흔들림이 그대로 따라 들어온 겁니다. jev 단독일 때는 두 회차 모두 보류한 33건도 같은 답으로 셉니다. 폴백을 붙이면 이 33건이 LLM으로 넘어가는데, LLM이 두 회차에 같은 답을 낸 건 그중 16건뿐이었어요. 전체 120건에서는 80%가 같았는데, jev가 넘긴 어려운 건에서는 절반 아래로 떨어진 겁니다. LLM은 앞단이 어려워서 넘긴 바로 그 건들에서 가장 많이 흔들렸습니다. 정확도를 얻는 대신 일관성을 일부 내준 셈이라, 어디까지 LLM에 넘길지는 무엇을 더 아끼느냐로 정해야 합니다. 뒤에 결정론적 기본값이 하나 더 있는 구조라면, jev가 보류한 건을 LLM 없이 바로 기본값이나 사람 검토로 보내는 구성도 따져 볼 만합니다. 정확도는 조금 내주더라도 결과는 매번 같아지니까요.</p>
<p>폴백을 짜다가 하나 헛짚은 게 있었습니다. pydantic-ai에는 FallbackModel이 있고, jev 뒤에 LLM을 붙여 두면 확신 없는 결정을 넘겨준다는 설명이 있어서 확신도 기준만 주면 되는 줄 알았어요. 코드를 열어 보니 넘기는 기준인 decision_route_threshold는 도구가 붙어 있거나 출력 타입이 여러 개일 때, 어느 경로로 갈지 고르는 질문에만 걸리더라고요[6]. 출력 타입이 하나면 경로 질문 자체가 없어서, 필드의 확신도가 아무리 낮아도 넘어가지 않습니다. 그래서 확신도와 보류를 앱 코드에서 읽어 직접 분기했습니다. 결국에는 라이브러리의 폴백은 API 실패와 경로 선택용이고, 답이 불확실한 경우는 우리 코드가 다뤄야 합니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="삽질에서-건진-것">삽질에서 건진 것<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EC%82%BD%EC%A7%88%EC%97%90%EC%84%9C-%EA%B1%B4%EC%A7%84-%EA%B2%83" class="hash-link" aria-label="삽질에서 건진 것에 대한 직접 링크" title="삽질에서 건진 것에 대한 직접 링크" translate="no">​</a></h2>
<p>같은 걸 해 보실 분을 위해 막혔던 것들만 짧게 적어 둡니다.</p>
<ul>
<li class="">보기마다 설명을 붙이려면 Enum 멤버에 docstring을 달라고 안내돼 있는데, 허용 보기가 입력마다 다르면 동적으로 만들기가 어렵습니다. 필드 스키마를 const와 description 쌍의 목록(anyOf)으로 직접 주면 같은 효과가 납니다. Noul로 보기마다 묻는 형태는 dict[str, bool] 필드의 propertyNames에 같은 목록을 주면 됩니다.</li>
<li class="">실행 결과의 usage는 메서드가 아니라 속성입니다. 확신도와 보기별 확률은 응답의 provider_details 아래에 필드 이름을 키로 들어옵니다.</li>
<li class="">Noul의 확신도는 확률이 아니라 경계 0.5에서 얼마나 떨어졌는지를 0-1로 늘린 값입니다. 보기별 예 확률이 필요하면 probabilities 쪽을 읽어야 합니다.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결국에는-어디에-둘-것인가">결국에는 어디에 둘 것인가<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EA%B2%B0%EA%B5%AD%EC%97%90%EB%8A%94-%EC%96%B4%EB%94%94%EC%97%90-%EB%91%98-%EA%B2%83%EC%9D%B8%EA%B0%80" class="hash-link" aria-label="결국에는 어디에 둘 것인가에 대한 직접 링크" title="결국에는 어디에 둘 것인가에 대한 직접 링크" translate="no">​</a></h2>
<p>정리하면 jev를 넣는다고 정확도가 오르지는 않았습니다. 이 과제에서는 추론형 LLM이 1-3%p 앞섰고, 원래 묶어서 한 번 부르던 호출이라 비용 차이도 크지 않았습니다. 그래도 넣을 이유는 남는데, 그게 모를 때 물러서는 모양과 반복의 안정성입니다. 보기마다 따로 묻는 Noul을 앞단에 세워 확실한 것만 가르고, 나머지를 LLM이나 결정론적 기본값으로 넘기면 LLM 호출이 3할로 줄고, 넘기지 않은 7할은 매번 같은 답을 받습니다.</p>
<p>그래서 저는 결정 모델을 LLM을 대신하는 모델이 아니라 LLM 앞에 서는 모델로 보기로 했습니다. 호출량이 큰 라우팅이나 가드레일이라면 비용과 지연의 이득까지 더해질 거고, 이름 매칭이나 해당 여부 확인처럼 원래 예/아니오인 질문은 Noul에 더 자연스럽게 맞을 것 같습니다. 데이터가 밖으로 나가는 문제를 먼저 풀어야 한다는 것도 그대로고요. 판단만 하는 모델이 쓸모 있어지는 순간은 정답을 많이 맞힐 때가 아니라, 모르는 걸 모른다고 말하고 그 말을 매번 똑같이 할 때더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/30/decision-model-knows-when-to-abstain#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] TypeSafe 문서, Introduction. 텍스트 생성 없이 타입이 정해진 질문에 구조화된 답을 돌려주는 System One 모델로 소개하고, Choice, Score, Noul 세 가지 질문을 한 요청에서 섞어 쓸 수 있다고 설명합니다. <a href="https://docs.typesafe.ai/introduction" target="_blank" rel="noopener noreferrer" class="">https://docs.typesafe.ai/introduction</a></p>
<p>[2] Daniel Kahneman, Thinking, Fast and Slow (2011). 빠르고 자동적인 System 1과 느리고 숙고하는 System 2를 구분합니다.</p>
<p>[3] TypeSafe 문서, Model jaggedness: jev-1.13. 셈과 개수 세기, 숫자 보정, 이중 부정과 간접 조건, 판단과 무관한 내용이 많은 입력에서의 약점을 스스로 정리해 둔 페이지입니다. <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer" class="">https://docs.typesafe.ai/model-jaggedness/jev-1.13</a></p>
<p>[4] Pydantic AI 문서, TypeSafe (Jev). 설치, 모델 이름, 출력 타입의 필드와 보기 설명이 질문으로 바뀌는 방식, provider_details의 확신도가 정리돼 있습니다. 이번 시험은 pydantic-ai-slim 2.52.0으로 했습니다. <a href="https://pydantic.dev/docs/ai/models/typesafe/" target="_blank" rel="noopener noreferrer" class="">https://pydantic.dev/docs/ai/models/typesafe/</a></p>
<p>[5] 시험 조건. 실제 데이터 120건(정답이 하나로 확정되는 건 66개), 정답은 사람이 단 허용 답 목록, 방식마다 2회 반복. GPT-6는 원래 구조와 같은 프롬프트와 추론 강도로 입력 묶음마다 한 번 호출했고, jev는 동시 8요청으로 건마다 호출했습니다. jev의 건당 입력 토큰은 Choice 약 710, Noul 약 1,010이었습니다.</p>
<p>[6] pydantic-ai-slim 2.52.0의 pydantic_ai/models/decision.py. DecisionModel 설명에 경로 질문은 도구가 붙었거나 출력 타입이 여럿일 때 추가된다고 적혀 있고, 확신이 기준 미만인 경로 선택만 UnsureRoute로 넘깁니다.</p>]]></content:encoded>
            <category>jev</category>
            <category>typesafe</category>
            <category>llm</category>
            <category>ai-agent</category>
            <category>classification</category>
            <category>pydantic-ai</category>
        </item>
        <item>
            <title><![CDATA[Langfuse v3에서 v4로 올리기: 셀프호스팅 무중단 마이그레이션]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration</guid>
            <pubDate>Wed, 23 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EKS·ArgoCD·ClickHouse Cloud 환경에서 Langfuse를 v3에서 v4로 무중단 업그레이드한 기록입니다. 다섯 단계로 쪼갠 이유, 공식 가이드가 다루지 않는 Helm 차트 v2 전환과 ClickHouse Cloud GRANT 보정, 직접 밟은 함정과 정합성 검증 쿼리를 정리했습니다.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-33b31185d5d686d069a63a71784478aa.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<p>최근 저희 Langfuse를 v3.x에서 v4.x으로 올렸습니다. 환경은 EKS에 ArgoCD automated sync, ClickHouse Cloud, ClickHouse Managed PostgreSQL, 그리고 차트에 번들된 Redis(valkey)입니다. 공식 업그레이드 가이드 [1]가 안 다루는 것들, 특히 Helm 차트 메이저 전환이랑 ArgoCD, ClickHouse Cloud에서 실제로 걸렸던 지점 위주로 정리했습니다. 구성이 저희랑 달라도 체크리스트랑 검증 쿼리는 그대로 쓰실 수 있을 거예요. 결국에는 직접 해보고 걸린 것들을 적은 글입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="요약">요약<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#%EC%9A%94%EC%95%BD" class="hash-link" aria-label="요약에 대한 직접 링크" title="요약에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class="">서버 업그레이드를 다섯 단계(최신 v3, 차트 v2, v4 legacy, dual, backfill)로 쪼개고 단계마다 PR과 sync를 분리했습니다. 전체 3시간쯤 걸렸고 서비스 중단은 없었습니다.</li>
<li class="">v4 스키마 마이그레이션 자체는 첫 Pod 기동 60초 안에 끝났습니다. 시간을 잡아먹은 건 기존 설정에 잠복해 있던 오류 세 개였어요.</li>
<li class="">가이드가 요구하는 background migration 0건 조건의 본질은 v4가 DROP하는 소스 테이블의 row count입니다. 직접 세어보셔야 해요.</li>
<li class="">ClickHouse Cloud는 가이드의 GRANT 목록에서 하나를 못 주고(SYSTEM MERGES), 하나는 더 넓게 줘야 합니다(system.mutations 테이블 전체 SELECT).</li>
<li class="">차트 v1에서 v2로 갈 때 번들 Redis가 StatefulSet에서 Deployment로 바뀝니다. Recreate 전략이랑 큐 드레인이 필요합니다.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-v4에서-뭐가-바뀌었고-왜-올려야-하나">1. v4에서 뭐가 바뀌었고 왜 올려야 하나<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#1-v4%EC%97%90%EC%84%9C-%EB%AD%90%EA%B0%80-%EB%B0%94%EB%80%8C%EC%97%88%EA%B3%A0-%EC%99%9C-%EC%98%AC%EB%A0%A4%EC%95%BC-%ED%95%98%EB%82%98" class="hash-link" aria-label="1. v4에서 뭐가 바뀌었고 왜 올려야 하나에 대한 직접 링크" title="1. v4에서 뭐가 바뀌었고 왜 올려야 하나에 대한 직접 링크" translate="no">​</a></h2>
<p>v3는 트레이스랑 옵저베이션을 traces, observations 두 테이블에 따로 저장합니다. v4는 OpenTelemetry 모델대로 트레이스 루트까지 span으로 취급하고 events_full 한 테이블에 저장합니다. 이 단일 모델 위에 ngram 인덱스가 올라가니까 입력이랑 출력 본문을 풀텍스트 서치할 수 있게 됐고, Observations API v2랑 Metrics API v2도 이 테이블만 읽어서 빨라졌습니다. 모니터, 알림, 필터 서치바 같은 것들도 v4 전용이죠 [1].</p>
<p>올려야 하는 실질적인 이유는 좀 단순합니다. v3 계열에는 더 이상 기능이 안 붙어요. 대가는 컷오버 이후에 생깁니다. Python SDK v2 이하랑 JS SDK v3 이하는 인제스천에서 거부되고, traces, observations, scores, metrics의 v1 읽기 API는 404를 냅니다. trace 단위 LLM-as-a-judge 평가자랑 구 익스포트도 멈춰요. 그래서 v4는 legacy, dual, events_only 세 가지 write mode를 주고 컷오버 시점을 운영자가 고르게 해둔 것들입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-시작-전에-확인할-것들">2. 시작 전에 확인할 것들<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#2-%EC%8B%9C%EC%9E%91-%EC%A0%84%EC%97%90-%ED%99%95%EC%9D%B8%ED%95%A0-%EA%B2%83%EB%93%A4" class="hash-link" aria-label="2. 시작 전에 확인할 것들에 대한 직접 링크" title="2. 시작 전에 확인할 것들에 대한 직접 링크" translate="no">​</a></h2>
<p>가이드의 전제조건에 저희 환경에서 추가로 확인한 항목을 더했습니다.</p>
<table><thead><tr><th>항목</th><th>기준</th><th>확인 방법</th></tr></thead><tbody><tr><td>ClickHouse 버전</td><td>25.12 이상, 26.4 권장</td><td>SELECT version()</td></tr><tr><td>PostgreSQL / Redis</td><td>15 이상 / 7.0 이상</td><td>SELECT version(), INFO server</td></tr><tr><td>CH 스키마 버전</td><td>최신 v3 기준 37</td><td>SELECT version, dirty FROM schema_migrations ORDER BY sequence DESC LIMIT 1</td></tr><tr><td>background migration</td><td>finished_at IS NULL 0건</td><td>아래 SQL</td></tr><tr><td>v4가 DROP하는 소스 테이블</td><td>전부 0건이거나 CH로 복사 완료</td><td>아래 SQL</td></tr><tr><td>ClickHouse GRANT</td><td>가이드 목록 + Cloud 보정</td><td>4절 참고</td></tr><tr><td>백업</td><td>PG dump, CH 백업</td><td>pg_dump -Fc, Cloud 자동 백업 확인</td></tr><tr><td>producer 인벤토리</td><td>SDK 종류와 버전</td><td>최신 v3부터 CH에 ingestion_sdk 컬럼이 생김</td></tr><tr><td>consumer 인벤토리</td><td>구 테이블을 직접 읽는 곳</td><td>Grafana, 사내 SQL 도구, deprecated API 사용처</td></tr></tbody></table>
<p>background migration이랑 소스 테이블은 같이 보셔야 합니다. 저희 배포에는 초기 부트스트랩 때 ClickHouse 테이블이 아직 없어서 실패한 채로 멈춘 항목이 두 건 있었거든요. 근데 이 둘이 옮기려던 소스가 정확히 v4 첫 부팅에서 DROP되는 테이블이더라고요. v4의 PG 마이그레이션은 이 행들을 삭제하고 소스를 지웁니다. 그러니까 행 상태랑 무관하게 소스에 데이터가 남았는지 직접 세어야 하는 것들입니다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">-- PostgreSQL</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> failed_at</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> failed_reason </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> background_migrations </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> finished_at </span><span class="token operator" style="color:#393A34">IS</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">NULL</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">*</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> dataset_run_items</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">-- ClickHouse</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'event_log'</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> event_log</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">UNION</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALL</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'dataset_run_items'</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> dataset_run_items</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">UNION</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALL</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'project_environments'</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> project_environments</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>최신 v3(3.224.1 이상)로 올리면 20260701_v4_step_1부터 5까지 다섯 행이 finished_at IS NULL로 나타납니다. 이건 envGate로 잠겨 있는 v4 backfill 체인이라 정상이고, Retry 버튼은 누르시면 안 돼요. UI에서 실패 항목을 Retry하려면 ADMIN_API_KEY 환경변수가 있어야 하고, 워커는 기동할 때만 큐를 훑으니까 Retry 뒤에 워커 Pod 하나를 재생성해야 실행됩니다. 결국에는 여기서 세어본 숫자가 이 마이그레이션의 안전장치였습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-단계별로-어떻게-갔나">3. 단계별로 어떻게 갔나<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#3-%EB%8B%A8%EA%B3%84%EB%B3%84%EB%A1%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B0%94%EB%82%98" class="hash-link" aria-label="3. 단계별로 어떻게 갔나에 대한 직접 링크" title="3. 단계별로 어떻게 갔나에 대한 직접 링크" translate="no">​</a></h2>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="다섯 단계" src="https://nxtgen.co.kr/assets/images/02-c61eef1175edf5c61d73e7ff9951dd95.png" width="1400" height="104" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">다섯 단계</span></span></p>
<p>원칙은 되돌리기 싼 변경부터 한 번에 한 가지였습니다. 예전에 프로젝트 몇 개에서 빅뱅으로 올려봤는데 문제가 터지면 어느 변경이 원인인지를 못 가르더라고요. 그래서 단계마다 머지 전에 helm template으로 렌더해서 현재 main 렌더랑 diff를 떴습니다. 뭐가 바뀌는지를 미리 알고 들어가는 것들이 중요합니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1단계-최신-v3로">1단계. 최신 v3로<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#1%EB%8B%A8%EA%B3%84-%EC%B5%9C%EC%8B%A0-v3%EB%A1%9C" class="hash-link" aria-label="1단계. 최신 v3로에 대한 직접 링크" title="1단계. 최신 v3로에 대한 직접 링크" translate="no">​</a></h3>
<p>이미지 3.225.8, 차트 1.5.41이에요. 가이드의 전제조건인 latest v3를 맞추는 단계입니다. ClickHouse 스키마가 34에서 37로 올라가고, 최신 v3부터는 observations에 ingestion_sdk_name, ingestion_sdk_version 컬럼이 생겨서 producer 인벤토리가 됩니다.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">langfuse</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">tag</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> 3.225.8</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">additionalEnv</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ADMIN_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">valueFrom</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">secretKeyRef</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">backing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> admin</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">api</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">key</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">optional</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2단계-차트-v2로-앱은-v3-고정">2단계. 차트 v2로, 앱은 v3 고정<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#2%EB%8B%A8%EA%B3%84-%EC%B0%A8%ED%8A%B8-v2%EB%A1%9C-%EC%95%B1%EC%9D%80-v3-%EA%B3%A0%EC%A0%95" class="hash-link" aria-label="2단계. 차트 v2로, 앱은 v3 고정에 대한 직접 링크" title="2단계. 차트 v2로, 앱은 v3 고정에 대한 직접 링크" translate="no">​</a></h3>
<p>차트 2.x는 Bitnami 서브차트를 전부 버립니다. PostgreSQL, ClickHouse, S3가 외부면 실제 교체는 번들 Redis 하나예요. 공식 Helm 문서에도 차트를 옮기는 동안 이미지 태그를 현재 v3에 고정하라고 적혀 있습니다 [2]. 앱 메이저랑 차트 메이저를 같은 창에서 안 겪으려는 겁니다.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">auth</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">existingSecret</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">backing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">existingSecretPasswordKey</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">password</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">usersExistingSecret</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">redis</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">acl   </span><span class="token comment" style="color:#999988;font-style:italic"># 키 이름 default, 값은 redis-password와 동일</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">deploymentStrategy</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Recreate                </span><span class="token comment" style="color:#999988;font-style:italic"># RWO PVC라 RollingUpdate면 Multi-Attach로 멈춤</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">valkeyConfig</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">|</span><span class="token scalar string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">    maxmemory-policy noeviction</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">dataStorage</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">keepPvc</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">clickhouse</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">cluster</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">enabled</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain">                             </span><span class="token comment" style="color:#999988;font-style:italic"># v1의 clusterEnabled에서 이름이 바뀜</span><br></div></code></pre></div></div>
<p>여기서 좀 걸리는 것들이 세 개 있습니다. existingSecret을 쓰면 usersExistingSecret은 기본 이름(langfuse-redis-auth)이 아닌 별도 Secret이어야 렌더가 통과해요. 차트가 자동 생성하는 Secret은 helm lookup 기반이라 ArgoCD의 helm template에서는 sync마다 비밀번호가 바뀌니까 쓰면 안 되고요. v1 차트가 만들던 NetworkPolicy는 v2에 없어서 extraManifests로 복원해야 합니다.</p>
<p>배포는 큐를 비운 다음에 했습니다. web을 0으로 내려서 인제스천을 멈추고, 구 valkey에서 bull:<em>:wait<!-- -->랑 bull:</em>:active<!-- --> 합이 0인 걸 확인하고 머지했습니다. delayed에 남는 것들은 인테그레이션의 반복 스케줄러라 워커가 다시 뜨면 재등록되니까 무시해도 됩니다. upstream 마이그레이션 스크립트도 같은 기준이더라고요 [3]. valkey랑 워커가 Ready 되면 web을 복구하고 고아가 된 구 PVC를 지웠습니다. 결국에는 큐만 비어 있으면 in-place로 되게 조용히 넘어가요.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3단계-v4-이미지-legacy-모드">3단계. v4 이미지, legacy 모드<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#3%EB%8B%A8%EA%B3%84-v4-%EC%9D%B4%EB%AF%B8%EC%A7%80-legacy-%EB%AA%A8%EB%93%9C" class="hash-link" aria-label="3단계. v4 이미지, legacy 모드에 대한 직접 링크" title="3단계. v4 이미지, legacy 모드에 대한 직접 링크" translate="no">​</a></h3>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">langfuse</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">tag</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> 4.41.0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">additionalEnv</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> LANGFUSE_MIGRATION_V4_WRITE_MODE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">value</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> legacy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">value</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> dual_write</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">value</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'false'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">value</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'false'</span><br></div></code></pre></div></div>
<p>legacy는 v4 이미지가 v3처럼 동작하는 모드입니다. 첫 Pod가 PG 마이그레이션 14개랑 CH 마이그레이션 38부터 49까지를 적용하는데 저희는 60초 안에 끝났습니다. 레거시 테이블 일곱 개가 사라지고 events_full, events_core, observations_batch_staging이 생깁니다. 스키마 변경만 따로 검증하는 단계라서 문제가 나면 원인이 스키마 쪽이라는 걸 바로 알 수 있는 게 되게 좋아요.</p>
<p>HISTORIC_BACKFILL을 명시적으로 false로 두는 게 중요합니다. v4 기본값이 true고 backfill은 정확히 한 번만 도니까, dual write 시작 전에 돌아버리면 그 사이 데이터가 새 테이블에서 영영 빠집니다. 결국에는 이 플래그 한 줄이 legacy 단계의 전부입니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="4단계-dual-모드">4단계. dual 모드<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#4%EB%8B%A8%EA%B3%84-dual-%EB%AA%A8%EB%93%9C" class="hash-link" aria-label="4단계. dual 모드에 대한 직접 링크" title="4단계. dual 모드에 대한 직접 링크" translate="no">​</a></h3>
<p>WRITE_MODE만 dual로 바꿉니다. 구 테이블이랑 신 테이블에 동시에 기록하고, UI는 PREVIEW_OPT_IN이 false인 동안 v3 그대로예요. 확인은 두 가지입니다. 전환 이후 들어온 observations 수랑 events_full의 span 수가 같은지, 그리고 워커의 전파 헬스입니다.</p>
<div class="language-http codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-http codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">GET http://&lt;worker&gt;:3030/api/health?failIfEventPropagationStuck=true</span><br></div></code></pre></div></div>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="dual write와 backfill" src="https://nxtgen.co.kr/assets/images/03-d48f8f97ef620ca99b7cd35436cc1483.png" width="1400" height="212" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">dual write와 backfill</span></span></p>
<p>Python SDK 4.7 이상, JS SDK 5.4 이상, x-langfuse-ingestion-version: 4 헤더를 붙인 OTel은 events_full에 직접 기록됩니다. 그 이하 SDK는 observations_batch_staging을 거쳐서 15분쯤 뒤에 전파되고요. 저희는 staging이 계속 0이라 전파가 안 되는 줄 알았거든요. 아니죠, 저희 에이전트가 쓰는 Python SDK가 4.15.1이라 애초에 직접 기록 경로였던 겁니다. producer 인벤토리는 이 시점부터 events_full에서 바로 뽑을 수 있는 것들이에요.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> project_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ingestion_sdk_name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ingestion_sdk_version</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> events_full </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> start_time </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">now</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INTERVAL</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">7</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">DAY</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">GROUP</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">BY</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="5단계-preview-허용-backfill-컷오버">5단계. preview 허용, backfill, 컷오버<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#5%EB%8B%A8%EA%B3%84-preview-%ED%97%88%EC%9A%A9-backfill-%EC%BB%B7%EC%98%A4%EB%B2%84" class="hash-link" aria-label="5단계. preview 허용, backfill, 컷오버에 대한 직접 링크" title="5단계. preview 허용, backfill, 컷오버에 대한 직접 링크" translate="no">​</a></h3>
<p>dual이 정상이면 ALLOW_PREVIEW_OPT_IN을 true로, HISTORIC_BACKFILL을 true로 바꿉니다. 워커가 재기동하면서 v4_step_1부터 4까지 순서대로 돕니다. 끝나면 정합성을 확인하고, 컷오버는 전환 플래그 네 개를 제거하면서(= events_only, direct) LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES를 true로 둬서 backfill 스크래치 테이블을 정리합니다. 컷오버는 구 테이블을 직접 읽는 consumer를 전부 events_full로 옮긴 다음에 해야 해요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-저희가-밟은-함정과-해결">4. 저희가 밟은 함정과 해결<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#4-%EC%A0%80%ED%9D%AC%EA%B0%80-%EB%B0%9F%EC%9D%80-%ED%95%A8%EC%A0%95%EA%B3%BC-%ED%95%B4%EA%B2%B0" class="hash-link" aria-label="4. 저희가 밟은 함정과 해결에 대한 직접 링크" title="4. 저희가 밟은 함정과 해결에 대한 직접 링크" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="node-24-힙-상한으로-web-pod-크래시루프">Node 24 힙 상한으로 web Pod 크래시루프<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#node-24-%ED%9E%99-%EC%83%81%ED%95%9C%EC%9C%BC%EB%A1%9C-web-pod-%ED%81%AC%EB%9E%98%EC%8B%9C%EB%A3%A8%ED%94%84" class="hash-link" aria-label="Node 24 힙 상한으로 web Pod 크래시루프에 대한 직접 링크" title="Node 24 힙 상한으로 web Pod 크래시루프에 대한 직접 링크" translate="no">​</a></h3>
<p>증상은 FATAL ERROR: Reached heap limit, exit 134입니다. Node 24는 cgroup 1Gi에서 V8 힙 상한을 560MB쯤으로 잡는데, 최신 v3 web은 기동만으로 RSS 544-614Mi를 써요. web 메모리를 1Gi/2Gi로 올리고 NODE_OPTIONS에 --max-old-space-size-percentage=75를 주니까 힙 상한이 1632MB가 됐습니다. Langfuse 이미지 빌드 단계도 같은 플래그를 씁니다 [4]. 올리기 전에 기존 Pod에서 heap_size_limit을 재보면 미리 잡을 수 있는 것들이에요.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="소형-spot-노드에서-probe-사망-hpa-교착">소형 spot 노드에서 probe 사망, HPA 교착<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#%EC%86%8C%ED%98%95-spot-%EB%85%B8%EB%93%9C%EC%97%90%EC%84%9C-probe-%EC%82%AC%EB%A7%9D-hpa-%EA%B5%90%EC%B0%A9" class="hash-link" aria-label="소형 spot 노드에서 probe 사망, HPA 교착에 대한 직접 링크" title="소형 spot 노드에서 probe 사망, HPA 교착에 대한 직접 링크" translate="no">​</a></h3>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="인과 체인" src="https://nxtgen.co.kr/assets/images/04-7d2be5d3a06436a316e8e0f2970e7b9c.png" width="1400" height="195" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">인과 체인</span></span></p>
<p>사실 이게 제일 문제였습니다. web 한 대만 exit 137을 반복하면서 롤아웃이 안 끝나는 거죠. Karpenter가 띄운 2 vCPU 노드에서는 기동이 60초를 넘겨서 차트 기본 liveness(20초 지연, 5초 타임아웃, 3회)에 죽어요. 재시작 때마다 CPU가 request 대비 900% 넘게 튀고, HPA가 그 버스트로 추천을 갱신해서 max에 고정되고, 불량 Pod가 maxUnavailable 1을 점유해서 구 Pod 교체가 멈춥니다. liveness를 60초/10초/6회, readiness를 20초/10초/6회로 풀면 해결됩니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="argocd-selfheal-환경에서-큐-드레인">ArgoCD selfHeal 환경에서 큐 드레인<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#argocd-selfheal-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%81%90-%EB%93%9C%EB%A0%88%EC%9D%B8" class="hash-link" aria-label="ArgoCD selfHeal 환경에서 큐 드레인에 대한 직접 링크" title="ArgoCD selfHeal 환경에서 큐 드레인에 대한 직접 링크" translate="no">​</a></h3>
<p>root-app이 자기 자신을 관리하는 구조면 클러스터에서 auto-sync를 꺼도 되돌려집니다. 대신 web Deployment에 replicas 필드가 없으면 kubectl scale이 diff에 안 잡히고, replicas가 0이면 HPA도 개입을 안 해요. 실패한 sync 오퍼레이션이 재시도 중이면 새 리비전이 시작을 안 하니까 UI에서 Terminate하셔야 합니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="clickhouse-cloud의-grant">ClickHouse Cloud의 GRANT<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#clickhouse-cloud%EC%9D%98-grant" class="hash-link" aria-label="ClickHouse Cloud의 GRANT에 대한 직접 링크" title="ClickHouse Cloud의 GRANT에 대한 직접 링크" translate="no">​</a></h3>
<p>가이드의 GRANT는 default 데이터베이스 기준이니까 실제 DB 이름으로 바꾸세요. SYSTEM MERGES는 Cloud의 admin 계정도 못 주는데, Langfuse가 SharedMergeTree를 감지하면 ALTER TABLE MODIFY SETTING으로 우회하니까 없어도 됩니다. 반대로 워커가 clusterAllReplicas로 system.mutations를 읽어서 가이드의 컬럼 단위 SELECT로는 부족하고 테이블 전체 SELECT랑 SHOW COLUMNS가 있어야 돌더라고요.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">DROP</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">VIEW</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALTER</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ADD</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INDEX</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALTER</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">DROP</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INDEX</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALTER</span><span class="token plain"> MATERIALIZE </span><span class="token keyword" style="color:#00009f">INDEX</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">database</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">table</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">partition</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> partition_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> active</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">rows</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> system</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">parts </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SHOW</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">COLUMNS</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> system</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mutations </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">database</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">engine</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> system</span><span class="token punctuation" style="color:#393A34">.</span><span class="token keyword" style="color:#00009f">tables</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> system</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">processes </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> system</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">query_log</span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALTER</span><span class="token plain"> SETTINGS </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">observations_pid_tid_sorting </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">READ</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> REMOTE </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">GRANT</span><span class="token plain"> CLUSTER </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token punctuation" style="color:#393A34">.</span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TO</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="backfill이-오래-걸리는-이유">backfill이 오래 걸리는 이유<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#backfill%EC%9D%B4-%EC%98%A4%EB%9E%98-%EA%B1%B8%EB%A6%AC%EB%8A%94-%EC%9D%B4%EC%9C%A0" class="hash-link" aria-label="backfill이 오래 걸리는 이유에 대한 직접 링크" title="backfill이 오래 걸리는 이유에 대한 직접 링크" translate="no">​</a></h3>
<p>query_log를 보니까 백필 쿼리 58개의 실행 시간 합은 8.9분(평균 9초, 최대 47초)이고, 나머지는 워커가 청크 50개마다 쿼리를 던지고 query_log를 폴링하는 페이스더라고요. 청크당 1분쯤이라 작은 데이터일수록 오버헤드 비중이 큽니다. 진행 상황은 background_migrations의 state 컬럼이랑 워커 로그의 Completed chunk N/M으로 볼 수 있어요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-정합성-검증">5. 정합성 검증<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#5-%EC%A0%95%ED%95%A9%EC%84%B1-%EA%B2%80%EC%A6%9D" class="hash-link" aria-label="5. 정합성 검증에 대한 직접 링크" title="5. 정합성 검증에 대한 직접 링크" translate="no">​</a></h2>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">-- 트레이스: 구 테이블과 events_full의 distinct trace 수가 같아야 함</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> countDistinct</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> traces FINAL </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> is_deleted </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">       </span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> countDistinct</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">trace_id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> events_full</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">-- 옵저베이션: events_full에 없는 것이 0건이어야 함</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> observations FINAL </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> is_deleted </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">AND</span><span class="token plain"> id </span><span class="token operator" style="color:#393A34">NOT</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">IN</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> span_id </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> events_full</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">-- 프로젝트별 비교 (부모 없는 최상위 observation은 parent_span_id가 비어 있으므로 non-root 필터에 주의)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> project_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> countDistinct</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> observations FINAL </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> is_deleted </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">GROUP</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">BY</span><span class="token plain"> project_id</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>저희 결과는 트레이스는 같고, 누락 옵저베이션 0건이었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="6-롤백">6. 롤백<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#6-%EB%A1%A4%EB%B0%B1" class="hash-link" aria-label="6. 롤백에 대한 직접 링크" title="6. 롤백에 대한 직접 링크" translate="no">​</a></h2>
<p>컷오버 전이면 v4 web 컨테이너 안에서 ClickHouse 스키마를 37로 되감은 다음에 이미지를 v3로 내립니다. 이미지만 바꾸면 안 돼요. PostgreSQL은 최신 v3가 v4 스키마에서 동작하니까 손대지 않습니다 [1].</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">cd /app/packages/shared</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">migrate -source file://clickhouse/migrations/clustered \</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  -database "${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&amp;password=${CLICKHOUSE_PASSWORD}&amp;database=langfuse&amp;x-multi-statement=true&amp;x-cluster-name=default&amp;x-migrations-table-engine=ReplicatedMergeTree" \</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  goto 37</span><br></div></code></pre></div></div>
<p>ArgoCD selfHeal이 v4 desired state를 계속 재적용하니까, 롤백 전에 auto-sync를 멈출 수 있는 경로를 미리 확보해 두세요. 저희는 git에서 끄는 수밖에 없었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="7-컷오버-전에-남는-것들">7. 컷오버 전에 남는 것들<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#7-%EC%BB%B7%EC%98%A4%EB%B2%84-%EC%A0%84%EC%97%90-%EB%82%A8%EB%8A%94-%EA%B2%83%EB%93%A4" class="hash-link" aria-label="7. 컷오버 전에 남는 것들에 대한 직접 링크" title="7. 컷오버 전에 남는 것들에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class="">구 테이블을 직접 SQL로 읽는 도구를 events_full 기준으로 전환</li>
<li class="">외부 producer의 SDK 버전 확인 (dual 상태에서 events_full의 ingestion_sdk 컬럼으로)</li>
<li class="">컷오버 후 안정 기간을 두고 구 테이블 TRUNCATE 여부 결정 (되돌릴 수 없음)</li>
</ul>
<p>다시 한다면 두 가지를 먼저 하겠습니다. 이미지 올리기 전에 기존 Pod의 힙 상한이랑 기동 시간을 재는 것, 그리고 렌더 결과에서 중복 env를 자동으로 잡는 검사를 두는 것입니다. 스키마 마이그레이션은 가이드대로 하면 됩니다. 결국에는 시간을 잡아먹는 건 언제나 그 주변에 있는 것들이더라고요. 이상입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/23/langfuse-v3-to-v4-migration#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] Langfuse, Upgrade Langfuse v3 to v4 (self-hosting guide): <a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4" target="_blank" rel="noopener noreferrer" class="">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4</a></p>
<p>[2] Langfuse, Kubernetes (Helm) deployment guide. 차트 v1에서 v2로 옮기는 동안 langfuse.image.tag를 현재 v3 이미지에 고정하라는 안내: <a href="https://langfuse.com/self-hosting/deployment/kubernetes-helm" target="_blank" rel="noopener noreferrer" class="">https://langfuse.com/self-hosting/deployment/kubernetes-helm</a></p>
<p>[3] langfuse-k8s examples/upgrade-v1-to-v2. 번들 Redis는 데이터 복사 없이 비운 채 재배포하며 wait, active, delayed 큐를 드레인 기준으로 삼는다: <a href="https://github.com/langfuse/langfuse-k8s/tree/main/examples/upgrade-v1-to-v2" target="_blank" rel="noopener noreferrer" class="">https://github.com/langfuse/langfuse-k8s/tree/main/examples/upgrade-v1-to-v2</a></p>
<p>[4] Langfuse web/Dockerfile (v3.225.8). 빌드 단계에서 NODE_OPTIONS에 max-old-space-size-percentage=75를 사용: <a href="https://github.com/langfuse/langfuse/blob/v3.225.8/web/Dockerfile" target="_blank" rel="noopener noreferrer" class="">https://github.com/langfuse/langfuse/blob/v3.225.8/web/Dockerfile</a></p>]]></content:encoded>
            <category>langfuse</category>
            <category>clickhouse</category>
            <category>kubernetes</category>
            <category>argocd</category>
            <category>helm</category>
            <category>migration</category>
        </item>
        <item>
            <title><![CDATA[조직의 지식 그래프를 만든 이유]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph</guid>
            <pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AI가 보편적 지식을 다 흡수하는 시대에 진짜 moat는 무엇인가. 종이와 도서관 이야기에서 출발해, 퇴사한 분께 여러 번 연락을 드려야 했던 인수인계 경험을 거쳐, 조직의 개별적 지식을 사람과 AI가 함께 읽는 지식 그래프로 남기려고 OKF와 nxtgen-ax를 만든 과정을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>AI는 세상의 보편적인 지식을 거의 다 흡수하고 있는데, 정작 저희 외할머니 생신은 모릅니다.
회사도 똑같았어요. 퇴사하신 분 프로젝트를 맡고 나서, 이미 떠난 분께 여러 번 연락을 드려야 했습니다.
조직의 지식을 사람과 AI가 같이 읽을 수 있는 형태로 남기려고 만든 것들에 대한 이야기입니다.</p>
</blockquote>
<p>안녕하세요. NxtCloud에서 nxtgen-ax를 만들고 있는 Brad입니다. 이번 글은 코드 이야기라기보다는 왜 이걸 만들게 됐는지에 대한 이야기에 가까워요. 정리를 해보자면 결국 "AI가 보편적인 지식을 다 흡수하면, 그 다음에 남는 건 뭘까"라는 질문 하나에서 출발했습니다.</p>
<img src="https://nxtgen.co.kr/assets/images/01-a055ca88cf85af820a23f52b4081e299.jpg" alt="팔짱을 낀 Brad의 픽셀 아트 자화상" width="280" class="no-zoom">
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="인간이-여기까지-온-이유는-종이라고-생각을-해요">인간이 여기까지 온 이유는 종이라고 생각을 해요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EC%9D%B8%EA%B0%84%EC%9D%B4-%EC%97%AC%EA%B8%B0%EA%B9%8C%EC%A7%80-%EC%98%A8-%EC%9D%B4%EC%9C%A0%EB%8A%94-%EC%A2%85%EC%9D%B4%EB%9D%BC%EA%B3%A0-%EC%83%9D%EA%B0%81%EC%9D%84-%ED%95%B4%EC%9A%94" class="hash-link" aria-label="인간이 여기까지 온 이유는 종이라고 생각을 해요에 대한 직접 링크" title="인간이 여기까지 온 이유는 종이라고 생각을 해요에 대한 직접 링크" translate="no">​</a></h2>
<p>LLM이 발전하는 걸 보면 지금의 AI는 아직 과도기라고 생각을 해요. 처음에는 다음 글자를 확률로 맞추는 기계였을지 몰라도, 발전이 가속화되면서 사람이 못 풀던 난제도 풀어내고 있잖아요. 반박할 수 없는 사실이고 누구나 느끼고 계실 거예요.</p>
<p>여기에 제 견해를 좀 풀어보면요.</p>
<p>인간이 세대를 거듭하면서 지금처럼 고지능 생물로 올 수 있었던 이유는 "종이"라고 생각합니다. 선대의 지식을 후대에 전달할 수 있게 되면서, 탐구할 수 있는 시간이 한 사람의 100년에서 끝나지 않게 됐어요. 더 나아가고자 하는 의지가 세대를 넘어서 이어진 거죠.</p>
<p>그 의지가 얇은 네모난 나무 조각 위에 잉크로 "기록"이 되면서, 후손들은 전기로 가는 차도 타보고 우주라는 미지의 공간에도 발을 들이고 세상의 근원이 어디인지에 대한 답도 적어나가고 있습니다.</p>
<p>종이가 모여서 책이 되고, 책이 모여서 도서관이 되고, 그 도서관에서 지식을 탐구하는 사람들의 학습의 장을 학교라고 부르잖아요. 사람은 학습할 수 있는 시간과 신체적인 한계가 있으니까, 학교에서 특정 분야를 배우고 그 지식으로 사회에 영향을 주는 사람을 전문가라고 부릅니다. 한 분야만 전문가가 돼도 우리 사회는 고급 인력이라고 하죠. 두 분야면 더할 나위가 없고요.</p>
<p><strong>그런데 인간의 신체적 한계를 넘어서 모든 분야의 지식을 습득한다면요?</strong></p>
<p>저희는 과연 "그것"을 뭐라고 부르게 될까요.</p>
<p>저는 그 역할을 지금 AI, LLM, AGI가 하고 있다고 생각을 합니다.</p>
<p>다만 어디까지나 "보편적" 지식이에요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="동굴 벽화에서 종이, 책, 도서관, 학교, 전문가로 이어지는 지식 전달의 흐름과, 그 끝에서 모든 분야를 흡수한 AI를 보며 &amp;quot;이게 뭐지?&amp;quot; 하고 갸웃하는 사람" src="https://nxtgen.co.kr/assets/images/02-91cb1e31917c6c7b9853bd5395cf637e.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">동굴 벽화에서 종이, 책, 도서관, 학교, 전문가로 이어지는 지식 전달의 흐름과, 그 끝에서 모든 분야를 흡수한 AI를 보며 &amp;quot;이게 뭐지?&amp;quot; 하고 갸웃하는 사람</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그러면-진짜-moat는-뭘까">그러면 진짜 moat는 뭘까<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EA%B7%B8%EB%9F%AC%EB%A9%B4-%EC%A7%84%EC%A7%9C-moat%EB%8A%94-%EB%AD%98%EA%B9%8C" class="hash-link" aria-label="그러면 진짜 moat는 뭘까에 대한 직접 링크" title="그러면 진짜 moat는 뭘까에 대한 직접 링크" translate="no">​</a></h2>
<p>그래서 고민을 좀 해봤어요. AI가 보편적인 지식을 전부 흡수한다면, 진짜 moat로는 뭐가 남을까.</p>
<p>AI가 제 삶에 들어와서 삶의 질을 올려주려면 보편적 지식만으로는 부족하다고 생각을 해요. 예를 들면 외할머니 생신이어서 안부 전화를 드리고 싶은데, AI는 보편적 지식만 흡수했으니까 저희 외할머니 전화번호도 모르고 생신이 언제인지도 모릅니다.</p>
<p>제가 본 방향은 이거예요. AI가 보편적 지식이 아니라 개인적 지식을 자유롭게 확인하고 활용할 수 있는 파이프라인을 깔아줘야 한다는 것.</p>
<p>한마디로 사람과 AI 에이전트가 같이 읽고 같이 업데이트할 수 있는 구조화된 지식 지도가 필요하다고 생각을 했습니다.</p>
<p>AI가 물이라면 구조는 유리컵이라고 생각을 해요. 물은 그 자체로 대단한데, 담을 그릇이 없으면 그냥 바닥에 쏟아집니다. 컵의 모양대로 담기는 거고요. 개인이나 조직의 지식도 똑같아서 아무리 좋은 모델이 와도 담아둘 구조가 없으면 그대로 흘러가 버려요.</p>
<p>물론 개별적인 지식을 담는 기술로 검색 증강 생성(RAG)이 있습니다. 근데 이건 어디까지나 의미적 유사도를 기반으로 하고요. 벡터 DB를 개인이나 팀이 직접 들고 가기에는 벤더 종속성도 있고, 일반인도 다룰 수 있어야 한다는 조건이나 무거움 같은 것들을 고려를 했어요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="해자(moat)로 둘러싸인 성 옆에서 &amp;quot;그래서 moat가 정확히 뭔데?&amp;quot; 하고 고민하는 사람" src="https://nxtgen.co.kr/assets/images/03-baf3299b6fa0fa484fc932da7b096eb8.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">해자(moat)로 둘러싸인 성 옆에서 &amp;quot;그래서 moat가 정확히 뭔데?&amp;quot; 하고 고민하는 사람</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="okf는-사실-별게-아니에요">OKF는 사실 별게 아니에요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#okf%EB%8A%94-%EC%82%AC%EC%8B%A4-%EB%B3%84%EA%B2%8C-%EC%95%84%EB%8B%88%EC%97%90%EC%9A%94" class="hash-link" aria-label="OKF는 사실 별게 아니에요에 대한 직접 링크" title="OKF는 사실 별게 아니에요에 대한 직접 링크" translate="no">​</a></h2>
<p>단편적인 유사도 검색을 넘어서 지식 그래프 형태로 표현할 수 있는 OKF(Open Knowledge Format)를 적용하면 이야기가 좀 달라진다고 봤습니다.</p>
<p>OKF는 사실 별게 아니에요. 마크다운 파일에 프론트매터 몇 줄 붙이고, 문서끼리 링크로 걸어둔 게 전부입니다. 대단한 기술이 들어간 것처럼 안 보이는 게 오히려 핵심이라고 생각을 해요.</p>
<p>사람이 그냥 열어서 읽을 수 있고, AI도 그 파일을 그대로 읽습니다. 특정 회사 서비스에 묶이지도 않고요. 임베딩을 다시 돌릴 필요도 없고, 검색이 틀렸을 때 왜 틀렸는지 못 들여다보는 블랙박스도 아닙니다. 이상하면 그냥 문서를 열어보면 끝이에요.</p>
<p>그리고 문서끼리 걸어둔 링크가 그대로 그래프가 됩니다. 외할머니 문서에서 생신으로, 가족 연락처로 이어지는 길이 눈에 보이는 형태로 남아요. AI는 그 길을 따라가면 되는 거고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="문서에 프론트매터를 붙이고 문서끼리 링크로 이으면 그래프가 되고, 사람과 AI가 같은 문서를 읽는다. 외할머니 문서에서 생신, 전화, 가족으로 이어지는 링크 예시" src="https://nxtgen.co.kr/assets/images/04-81611e579016ef934ec1dbbf44085e8e.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">문서에 프론트매터를 붙이고 문서끼리 링크로 이으면 그래프가 되고, 사람과 AI가 같은 문서를 읽는다. 외할머니 문서에서 생신, 전화, 가족으로 이어지는 링크 예시</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="개인이-아니라-조직의-개별적-지식이라면요">개인이 아니라 조직의 개별적 지식이라면요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EA%B0%9C%EC%9D%B8%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%A1%B0%EC%A7%81%EC%9D%98-%EA%B0%9C%EB%B3%84%EC%A0%81-%EC%A7%80%EC%8B%9D%EC%9D%B4%EB%9D%BC%EB%A9%B4%EC%9A%94" class="hash-link" aria-label="개인이 아니라 조직의 개별적 지식이라면요에 대한 직접 링크" title="개인이 아니라 조직의 개별적 지식이라면요에 대한 직접 링크" translate="no">​</a></h2>
<p>여기까지는 전부 제 개인 이야기예요. 제 외할머니 생신, 제 일정, 제가 남긴 기록.</p>
<p>그런데 여기서 한 단계 더 나가봤습니다.</p>
<p><strong>개인의 개별적 지식이 아니라, 조직의 개별적 지식이라면요?</strong></p>
<p>생각해보면 회사도 똑같아요. 아니 더 심합니다. 개인은 적어도 자기가 뭘 알고 있는지는 본인이 알잖아요. 조직은 그 지식이 여러 사람 머리에 쪼개져서 흩어져 있어서, 누가 뭘 아는지조차 조직 전체가 모릅니다.</p>
<p>AI한테 "우리 회사는 이 고객사를 어떻게 운영하고 있어?"라고 물어봐야 소용이 없어요. 보편적 지식 어디에도 저희 회사는 없으니까요. 저희 외할머니 전화번호를 모르는 것과 정확히 같은 이유입니다.</p>
<p>그리고 이건 그냥 좀 불편한 정도가 아니었어요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="개인은 자기 지식을 한 손에 들고 있지만, 조직의 지식은 여러 사람 머리에 쪼개져 있다. AI가 회사에 대해 물어봐도 흩어진 문서 앞에서 답을 못 찾는다" src="https://nxtgen.co.kr/assets/images/05-09d035f9bdaf0a57ed410ce1ce3d6685.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">개인은 자기 지식을 한 손에 들고 있지만, 조직의 지식은 여러 사람 머리에 쪼개져 있다. AI가 회사에 대해 물어봐도 흩어진 문서 앞에서 답을 못 찾는다</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="이미-퇴사하신-분께-계속-연락을-드렸습니다">이미 퇴사하신 분께 계속 연락을 드렸습니다<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EC%9D%B4%EB%AF%B8-%ED%87%B4%EC%82%AC%ED%95%98%EC%8B%A0-%EB%B6%84%EA%BB%98-%EA%B3%84%EC%86%8D-%EC%97%B0%EB%9D%BD%EC%9D%84-%EB%93%9C%EB%A0%B8%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="이미 퇴사하신 분께 계속 연락을 드렸습니다에 대한 직접 링크" title="이미 퇴사하신 분께 계속 연락을 드렸습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>제일 크게 겪은 건 퇴사한 직원의 프로젝트를 맡게 됐을 때였습니다.</p>
<p>인수인계 문서라는 걸 받긴 받았는데, 막상 프로젝트를 진행을 하다 보면 문서에 없는 것들이 계속 나왔어요. 이건 왜 이렇게 설정해둔 건지, 이 계정은 어디에 쓰이는 건지, 고객사랑 뭘 합의했었는지.</p>
<p>결국 이미 회사를 나간 분께 연락을 드렸습니다. 한 번도 아니고 여러 번이요.</p>
<p>그분 입장에서도 이미 떠난 회사 일로 연락 받는 게 반가울 리가 없고, 저도 매번 연락드리는 게 눈치가 보였어요. 그리고 답을 못 받으면 거기서 그냥 멈춥니다. 프로젝트가 사람 한 명의 기억에 묶여 있었던 거예요.</p>
<p>조직의 지식은 대부분 사람 머리 안에, 슬랙 스크롤 어딘가에, 누군가의 개인 노션 페이지 안에 흩어져 있습니다. 그래서 사람이 나가면 지식도 같이 나가요. 인수인계 문서 한 장에 옮겨지는 양은, 그분이 실제로 들고 있던 것의 일부밖에 안 됩니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="인수인계 문서를 받고도 물음표가 계속 뜨고, 슬랙으로 &amp;quot;혹시 이거 어떻게 설정했었죠?&amp;quot; 하고 퇴사한 분께 연락을 드리다 답이 끊기면 멈추는 과정" src="https://nxtgen.co.kr/assets/images/06-e6a50cd7574e8abee19db5c7a89fe597.jpg" width="1774" height="887" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">인수인계 문서를 받고도 물음표가 계속 뜨고, 슬랙으로 &amp;quot;혹시 이거 어떻게 설정했었죠?&amp;quot; 하고 퇴사한 분께 연락을 드리다 답이 끊기면 멈추는 과정</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그래서-만든-게-nxtgen-ax-예요">그래서 만든 게 nxtgen-ax 예요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%A7%8C%EB%93%A0-%EA%B2%8C-nxtgen-ax-%EC%98%88%EC%9A%94" class="hash-link" aria-label="그래서 만든 게 nxtgen-ax 예요에 대한 직접 링크" title="그래서 만든 게 nxtgen-ax 예요에 대한 직접 링크" translate="no">​</a></h2>
<p>제가 만들고 싶었던 건 이겁니다. 조직의 지식을 마크다운 문서로 쌓고, 그 문서들을 링크로 엮어서 조직 전체가 하나의 지식 그래프를 갖는 것. 그리고 그 그래프를 사람만 보는 게 아니라 AI가 같이 탐색하는 것.</p>
<p>동작은 단순해요.</p>
<p>문서는 각자의 로컬에 마크다운 파일로 저장이 됩니다. 서버 어딘가에만 있는 게 아니라 제 노트북 디스크에 실제 파일로 있어요. 그 문서들이 조직원들 사이에서 동기화가 되고요. 제가 쓴 문서는 팀원 로컬에도 같은 파일로 내려갑니다. 결국 조직 구성원 전원이 같은 지식 그래프를 각자 로컬에 하나씩 들고 있게 되는 거예요. AI는 이 로컬 파일을 그대로 읽습니다. 중간에 인덱싱 서버나 벡터 DB를 거치지 않고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="각자의 노트북에 있는 마크다운 문서가 링크로 엮여 하나의 지식 그래프가 되고, 구성원 사이에 동기화되며, AI가 그 그래프를 그대로 읽는다. 서버는 필요 없다" src="https://nxtgen.co.kr/assets/images/07-71a48b9f7f1492f6aacb1c414c409236.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">각자의 노트북에 있는 마크다운 문서가 링크로 엮여 하나의 지식 그래프가 되고, 구성원 사이에 동기화되며, AI가 그 그래프를 그대로 읽는다. 서버는 필요 없다</span></span></p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="nxtgen-ax의 그래프 뷰. 노드 1106개, 링크 1749개가 계층(parent), 관련(related), 본문 링크로 구분되어 보인다" src="https://nxtgen.co.kr/assets/images/08-d9723bf9a9147ed6e17c8620fdc5ec1f.jpg" width="1760" height="1100" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">nxtgen-ax의 그래프 뷰. 노드 1106개, 링크 1749개가 계층(parent), 관련(related), 본문 링크로 구분되어 보인다</span></span>
<em>실제 nxtgen-ax 애플리케이션의 그래프 뷰</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="물론-알아서-되는-건-아니에요">물론 알아서 되는 건 아니에요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EB%AC%BC%EB%A1%A0-%EC%95%8C%EC%95%84%EC%84%9C-%EB%90%98%EB%8A%94-%EA%B1%B4-%EC%95%84%EB%8B%88%EC%97%90%EC%9A%94" class="hash-link" aria-label="물론 알아서 되는 건 아니에요에 대한 직접 링크" title="물론 알아서 되는 건 아니에요에 대한 직접 링크" translate="no">​</a></h2>
<p>사람이 문서를 정리하는 노력은 여전히 필요합니다. 어디에 둘지, 제목을 뭐라고 붙일지, 어떤 문서랑 연결해둘지.</p>
<p>이걸 안 하면 지식 그래프가 아니라 그냥 MD 파일 더미가 돼요. 폴더를 열었는데 파일 200개가 아무 관계 없이 쌓여 있으면 사람도 못 찾고 AI도 못 찾겠죠? 링크가 없으면 그래프도 없습니다.</p>
<p>근데 생각해보면 이건 새로 생긴 일이 아니에요. 노션에서 이미 해오던 일이잖아요. 페이지 만들고, 제목 정하고, 적당한 상위 페이지 밑에 넣고, 관련 페이지 링크 걸고. 문서를 정리하는 수고 자체는 어차피 계속 하고 있었습니다.</p>
<p>달라지는 건 그 결과물이 어디에 남느냐예요. 노션에 정리하면 노션 안에서만 쓸 수 있는 지식이 되고, 마크다운으로 정리하면 사람도 AI도 어떤 도구로든 읽을 수 있는 지식이 됩니다. 들이는 노력은 비슷한데 쓸 수 있는 범위가 달라요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="링크가 없으면 지식 그래프가 아니라 MD 파일 더미가 된다" src="https://nxtgen.co.kr/assets/images/09-995489819096cd1dc1d02238e1d4f59b.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">링크가 없으면 지식 그래프가 아니라 MD 파일 더미가 된다</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="굳이-로컬-파일을-고집한-이유">굳이 로컬 파일을 고집한 이유<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EA%B5%B3%EC%9D%B4-%EB%A1%9C%EC%BB%AC-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EA%B3%A0%EC%A7%91%ED%95%9C-%EC%9D%B4%EC%9C%A0" class="hash-link" aria-label="굳이 로컬 파일을 고집한 이유에 대한 직접 링크" title="굳이 로컬 파일을 고집한 이유에 대한 직접 링크" translate="no">​</a></h2>
<p>AI 에이전트가 지식을 활용하려면 결국 읽을 수 있어야 하는데, 파일만큼 보편적인 인터페이스가 없잖아요.</p>
<p>어떤 모델을 쓰든 어떤 에이전트를 쓰든 파일은 읽습니다. 오늘 쓰던 모델을 내일 다른 걸로 갈아타도 지식은 그대로 남아요. 벤더가 바뀌어도 제 지식은 제 디스크에 있습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="실제로-일하는-방식이-바뀌었어요">실제로 일하는 방식이 바뀌었어요<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%9D%BC%ED%95%98%EB%8A%94-%EB%B0%A9%EC%8B%9D%EC%9D%B4-%EB%B0%94%EB%80%8C%EC%97%88%EC%96%B4%EC%9A%94" class="hash-link" aria-label="실제로 일하는 방식이 바뀌었어요에 대한 직접 링크" title="실제로 일하는 방식이 바뀌었어요에 대한 직접 링크" translate="no">​</a></h2>
<p>"작년에 이 고객사 마이그레이션 어떻게 했었지"를 이제 사람한테 묻지 않습니다. AI한테 물어봐요. AI는 관련 문서를 링크 따라가면서 찾아서 답합니다. 답이 이상하면 원본 문서를 열어서 고쳐요. 고친 문서는 다시 동기화돼서 팀 전체의 지식이 됩니다.</p>
<p>지식을 쌓는 행위랑 AI가 그걸 꺼내 쓰는 행위가 같은 파일 위에서 일어나는 거예요. 따로 관리하는 지식 시스템이 아니라, 일하면서 남긴 기록이 그대로 AI의 컨텍스트가 되는 구조입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="궁금한 게 있는 사람이 AI에게 묻고, AI가 링크를 따라 관련 문서 묶음을 찾아온다" src="https://nxtgen.co.kr/assets/images/10-a7008b844ae911e6874008dde943cee2.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">궁금한 게 있는 사람이 AI에게 묻고, AI가 링크를 따라 관련 문서 묶음을 찾아온다</span></span></p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="nxtgen-ax 안에서 Claude Code에게 &amp;quot;우리 기술 블로그 어디다 정리했지?&amp;quot;라고 묻자 볼트를 검색해 두 위치를 찾아 답한 화면" src="https://nxtgen.co.kr/assets/images/11-ece1359620c5b6c609efd3846492ea39.jpg" width="1765" height="1090" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">nxtgen-ax 안에서 Claude Code에게 &amp;quot;우리 기술 블로그 어디다 정리했지?&amp;quot;라고 묻자 볼트를 검색해 두 위치를 찾아 답한 화면</span></span>
<em>실제로 Claude를 통해서 볼트 안의 지식을 검색한 화면</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="마치며">마치며<a href="https://nxtgen.co.kr/blog/2026/09/16/org-knowledge-graph#%EB%A7%88%EC%B9%98%EB%A9%B0" class="hash-link" aria-label="마치며에 대한 직접 링크" title="마치며에 대한 직접 링크" translate="no">​</a></h2>
<p>앞에서 인간이 종이에 기록을 남겨서 다음 세대가 그 위에서 시작할 수 있었다고 썼어요.</p>
<p>조직도 똑같다고 생각을 합니다. 다만 조직의 종이는 지금까지 너무 흩어져 있었고, 그걸 읽는 주체가 사람뿐이었어요.</p>
<p>이제 읽는 독자가 하나 더 늘었습니다. 그 독자를 위해서 기록을 어떤 모양으로 남길 것인가. 저는 그게 OKF 같은 형식이고 nxtgen-ax 같은 도구라고 생각을 합니다.</p>
<p>구성원은 떠나도, 지식은 남아야 하니까요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="구성원은 떠나도 마크다운 문서는 남아 지식이 이어진다. &amp;quot;구성원은 떠나도, 지식은 남아야 하니까요.&amp;quot;" src="https://nxtgen.co.kr/assets/images/12-46bf5dd016866d455ca569d58c1c65ce.jpg" width="1536" height="1024" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">구성원은 떠나도 마크다운 문서는 남아 지식이 이어진다. &amp;quot;구성원은 떠나도, 지식은 남아야 하니까요.&amp;quot;</span></span></p>]]></content:encoded>
            <category>okf</category>
            <category>nxtgen-ax</category>
            <category>knowledge-graph</category>
            <category>ai-agent</category>
            <category>rag</category>
            <category>markdown</category>
            <category>obsidian</category>
        </item>
        <item>
            <title><![CDATA[Tailscale에서 Cloudflare ZTNA로 갈아탄 PoC 기록]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster</guid>
            <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[온프렘 방화벽에 등록할 IP는 하나, 온프렘 서버에는 아무것도 설치 불가. 다섯 줄 요구사항 앞에서 WireGuard를 뜯어보고 Tailscale Subnet Router와 Cloudflare ZTNA를 각각 실증한 뒤 도구를 갈아탄 과정과, P2P 저지연이라는 전제가 NAT 뒤에서 무너진 지점을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>온프렘 방화벽에 등록할 저희 측 IP는 하나여야 하고, 온프렘 서버에는 아무것도 설치할 수 없었습니다.
요구사항은 다섯 줄이었는데 읽고 나니까 선택지가 거의 없었습니다.
WireGuard를 뜯어보고 Tailscale하고 Cloudflare ZTNA를 각각 실증한 뒤에 도구를 갈아탄 과정을 정리했습니다.</p>
</blockquote>
<p>안녕하세요. NxtCloud FDE/SA 류명재(Jay)입니다. 이번에는 제가 고객사 망에 접근하는 원격접속 구성에 대해서 좀 설명을 해보려고 합니다. 저희가 어떤 방식으로 목표를 달성했는지, 그리고 그 과정에서 좀 알게 된 부분들을 정리해봤습니다.</p>
<p>시작하기 전에 하나만 좀 짚어두겠습니다. 이 글은 흔히 말하는 사내 VPN, 그러니까 직원이 회사 망으로 들어오는 그런 구성을 다루지는 않습니다. 저희 문제는 방향이 반대였거든요. 저희가 고객사 망으로 들어가야 했고, 고객사는 저희 쪽 IP를 하나만 열어줄 수 있었고, 고객사 서버에는 아무것도 설치를 할 수가 없었습니다. 이 세 가지가 겹치다 보니까 사내 VPN의 정석이 그대로 적용이 안 됩니다. 그 차이에서 이제 이 글의 모든 선택이 출발을 합니다. 다만 골격이 겹치긴 합니다. 개인 PC에서 중간 박스까지는 어차피 VPN 터널이고, 그 박스 뒤를 저희 사내망으로 바꾸면 같은 구성이 사내 VPN이 되는 형태입니다. 방향이라든지 제약이 다를 뿐입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="보편적인 사내 원격접속 VPN 아키텍처. VPN 게이트웨이가 회사 경계 DMZ에 있고, 인증은 AD/RADIUS/MFA에, 인가는 내부 방화벽 존 정책에 맡긴다. 아래에 이 글의 케이스가 다른 점 네 가지를 함께 적었다" src="https://nxtgen.co.kr/assets/images/01-acadd8122dfbbff2e54bdfb9b303c051.png" width="2480" height="1280" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">보편적인 사내 원격접속 VPN 아키텍처. VPN 게이트웨이가 회사 경계 DMZ에 있고, 인증은 AD/RADIUS/MFA에, 인가는 내부 방화벽 존 정책에 맡긴다. 아래에 이 글의 케이스가 다른 점 네 가지를 함께 적었다</span></span>
<em>보편적인 사내 VPN 구성과, 이 글의 케이스가 그것과 다른 네 가지 지점</em></p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="개인 PC 여럿이 우리 측 박스 한 대를 거쳐 온프레미스의 허용된 서버까지 도달하는 목표 형태. 방화벽은 박스 IP 하나만 허용하고 서버에는 아무것도 설치하지 않는다" src="https://nxtgen.co.kr/assets/images/02-f7ac4d19f6159e73082e432753d47ebe.png" width="2480" height="1120" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">개인 PC 여럿이 우리 측 박스 한 대를 거쳐 온프레미스의 허용된 서버까지 도달하는 목표 형태. 방화벽은 박스 IP 하나만 허용하고 서버에는 아무것도 설치하지 않는다</span></span>
<em>우리가 만들어야 했던 형태. 박스 안에 무엇을 올릴지는 아직 정하지 않았다</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="요구사항은-다섯-줄이었습니다">요구사항은 다섯 줄이었습니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD%EC%9D%80-%EB%8B%A4%EC%84%AF-%EC%A4%84%EC%9D%B4%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="요구사항은 다섯 줄이었습니다에 대한 직접 링크" title="요구사항은 다섯 줄이었습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>한 대학 고객사의 온프레미스 서버에 접근을 해야 했습니다.</p>
<table><thead><tr><th>#</th><th>조건</th><th>의미</th></tr></thead><tbody><tr><td>1</td><td>온프렘 방화벽에 등록할 우리 측 IP는 하나</td><td>접속자가 몇 명으로 늘어도 IP는 1개 유지</td></tr><tr><td>2</td><td>온프렘 서버에 아무것도 설치하지 않음</td><td>VPN 에이전트 설치 불가. 순수 서버 유지</td></tr><tr><td>3</td><td>서버가 추가돼도 재설정 없음</td><td>클라이언트도 방화벽도 손대지 않고 접근</td></tr><tr><td>4</td><td>인터넷 구간 암호화, 내부망 직접 노출 없음</td><td></td></tr><tr><td>5</td><td>온프렘에 기존 VPN이 이미 있을 수 있음</td><td>공존 또는 접속 필요</td></tr></tbody></table>
<p>1번이 전부를 결정하게 됐습니다. 접속자가 다섯 명이면 소스 IP도 다섯 개가 됩니다. 그걸 하나로 만들라는 요구는 터널을 뚫어라가 아니라, N개의 출발지를 1개로 압축하는 지점을 어딘가에 만들어라는 뜻이었습니다.</p>
<p>그 지점의 이름이 이제 SNAT이라는 건데요. 이 프로젝트에서 검토한 모든 방식, 자체 WireGuard 허브라든지 Tailscale Subnet Router, Cloudflare의 cloudflared 커넥터, IPsec site-to-site까지 다 같은 그림이었습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="모든 후보의 공통 골격. 클라이언트 여럿이 암호화 터널로 박스 한 대에 모이고, 박스가 복호화와 SNAT을 거쳐 온프레미스 서버로 전달한다. 박스에 올리는 것만 WireGuard 허브, Tailscale Subnet Router, cloudflared 커넥터, IPsec으로 바뀐다" src="https://nxtgen.co.kr/assets/images/03-01379599e05a2a3eb2a43d01fb54195c.png" width="2480" height="1040" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">모든 후보의 공통 골격. 클라이언트 여럿이 암호화 터널로 박스 한 대에 모이고, 박스가 복호화와 SNAT을 거쳐 온프레미스 서버로 전달한다. 박스에 올리는 것만 WireGuard 허브, Tailscale Subnet Router, cloudflared 커넥터, IPsec으로 바뀐다</span></span>
<em>모든 후보의 공통 골격. 박스에 무엇을 올리느냐만 다르고 온프레미스가 보는 모습은 같다</em></p>
<p>박스에 무엇을 올리느냐만 다르고 골격은 같은 형태입니다. 이 부분을 초반에 잡아둔 덕분에, 나중에 도구를 바꿀 때 온프렘 쪽 협의를 다시 하지 않아도 됐습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="후보가-떨어져-나간-순서">후보가 떨어져 나간 순서<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%ED%9B%84%EB%B3%B4%EA%B0%80-%EB%96%A8%EC%96%B4%EC%A0%B8-%EB%82%98%EA%B0%84-%EC%88%9C%EC%84%9C" class="hash-link" aria-label="후보가 떨어져 나간 순서에 대한 직접 링크" title="후보가 떨어져 나간 순서에 대한 직접 링크" translate="no">​</a></h2>
<p>조건에 대입을 해보니까 후보가 빠르게 정리가 됐습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="후보가 걸러진 순서. 조건 2(온프렘 무설치), 조건 1(단일 IP), 운영 부담의 세 관문을 차례로 통과시켜 Tailscale Subnet Router와 Cloudflare ZTNA 두 개가 실증 대상으로 남는다" src="https://nxtgen.co.kr/assets/images/04-db3f0f373a668921efada44d666ecc49.png" width="2480" height="1240" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">후보가 걸러진 순서. 조건 2(온프렘 무설치), 조건 1(단일 IP), 운영 부담의 세 관문을 차례로 통과시켜 Tailscale Subnet Router와 Cloudflare ZTNA 두 개가 실증 대상으로 남는다</span></span>
<em>세 관문을 순서대로 통과시킨 결과. 자체 WireGuard 허브는 통과하지만 운영 부담으로 대안에 머문다</em></p>
<p>AWS 관리형 Site-to-Site VPN이 조건 1에 걸린 게 좀 의외였습니다. 관리형 VPN은 가용성 때문에 터널을 두 개 만들고 엔드포인트도 두 개가 되거든요. IP 딱 하나라는 문구 앞에서는 그 이중화가 오히려 탈락 사유가 되어버립니다.</p>
<p>Tailscale을 온프렘 서버에 직접 까는 mesh 형태도 초기에 검토를 했다가 접게 되었습니다. 노드마다 tailnet IP를 받다 보니까, 접속자가 늘면 온프렘이 관측하는 IP도 같이 늘어납니다. 조건 1하고 정면으로 부딪히는 부분입니다.</p>
<p>남은 셋 중에 자체 WireGuard 허브는 키 배포라든지 노드 관리를 전부 손으로 해야 되다 보니까 대안으로 좀 내려두고, Tailscale Subnet Router하고 Cloudflare ZTNA[1] 두 개를 실증 대상으로 잡게 되었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="vpn이라는-말에는-두-계보가-섞여-있습니다">VPN이라는 말에는 두 계보가 섞여 있습니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#vpn%EC%9D%B4%EB%9D%BC%EB%8A%94-%EB%A7%90%EC%97%90%EB%8A%94-%EB%91%90-%EA%B3%84%EB%B3%B4%EA%B0%80-%EC%84%9E%EC%97%AC-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="VPN이라는 말에는 두 계보가 섞여 있습니다에 대한 직접 링크" title="VPN이라는 말에는 두 계보가 섞여 있습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>본격적으로 들어가기 전에 이제 용어를 좀 정리를 해두는 게 좋을 것 같습니다. VPN을 한 덩어리로 부르는데, 실제로는 동작 계층이 다른 두 계보가 섞여 있습니다.</p>
<p>IPsec 계열은 L3에서 IP 패킷 자체를 암호화합니다. OS 네트워크 스택 안쪽에서 처리가 되다 보니까 애플리케이션은 VPN의 존재를 알 필요조차 없습니다. IKE로 키를 교환하고 ESP로 패킷을 감쌉니다. 라우터나 이런 방화벽 장비에서 오래 표준으로 쓰였고, AWS Site-to-Site VPN하고 GCP Cloud VPN이 이 계열입니다. 대신 협상을 할 파라미터가 많다 보니까 설정이 좀 까다롭고 NAT 통과도 번거롭긴 합니다.</p>
<p>SSL/TLS 계열은 L4에서 L7 사이에서 동작을 합니다. 브라우저 HTTPS하고 같은 자리입니다. 443 포트를 그대로 쓰니까 방화벽 정책을 우회하기가 쉽고, 클라이언트 배포라든지 사용자 인증이 유연합니다. OpenVPN이나 이런 Cisco AnyConnect가 여기 속합니다. 흔히 SSL VPN이라고 부르는 게 이 계열이고, 용도는 대체로 사람이 노트북으로 접속하는 원격 접속입니다.</p>
<p>이 구분이 실무에서 왜 중요하냐면, 저희가 필요했던 게 사람의 원격 접속이 아니라 머신 대 머신 상시 연결이었거든요. 서버가 서버에 붙는 L3 site-to-site라는 형태입니다. 이 성격이 정해지는 순간 SSL VPN 계열은 자연스럽게 후보에서 빠지게 됩니다. 방화벽 우회 능력이라든지 브라우저 접근성은 이 문제에서 값을 못 합니다.</p>
<p>그리고 세 번째 구현체가 있습니다.</p>
<table><thead><tr><th>항목</th><th>IPsec</th><th>OpenVPN</th><th>WireGuard</th></tr></thead><tbody><tr><td>계층</td><td>L3</td><td>L4/L7</td><td>L3</td></tr><tr><td>코드 규모</td><td>수십만 줄</td><td>큼</td><td>약 4천 줄</td></tr><tr><td>실행 위치</td><td>커널</td><td>사용자 공간</td><td>커널</td></tr><tr><td>설정 난이도</td><td>어려움</td><td>보통</td><td>쉬움</td></tr><tr><td>사용자 인증</td><td>유연</td><td>유연</td><td>없음. 외부에 위임</td></tr><tr><td>대표 용도</td><td>Site-to-Site</td><td>Remote Access</td><td>둘 다</td></tr></tbody></table>
<p>WireGuard의 약 4천 줄이라는 건 마케팅 문구가 아니라 설계 철학인데요. 이게 왜 그런 결과를 낳는지는 직접 뜯어보는 편이 빠릅니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wireguard를-뜯어봤습니다">WireGuard를 뜯어봤습니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#wireguard%EB%A5%BC-%EB%9C%AF%EC%96%B4%EB%B4%A4%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="WireGuard를 뜯어봤습니다에 대한 직접 링크" title="WireGuard를 뜯어봤습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>Docker로 허브 앤 스포크[2] 랩을 만들어가지고 계측을 했습니다. 클라이언트 두 대, 허브 한 대, 그리고 WireGuard가 설치되지 않은 순수 온프렘 서버 한 대인 구성입니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="신원은-공개키-하나입니다">신원은 공개키 하나입니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%8B%A0%EC%9B%90%EC%9D%80-%EA%B3%B5%EA%B0%9C%ED%82%A4-%ED%95%98%EB%82%98%EC%9E%85%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="신원은 공개키 하나입니다에 대한 직접 링크" title="신원은 공개키 하나입니다에 대한 직접 링크" translate="no">​</a></h3>
<p><code>wg show</code>가 인증 원장 그 자체입니다. X.509라든지 CA, CRL 이런 게 하나도 없습니다.[3]</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">peer: OBVIYQ8ZzxfFcJGSp3IcsW1RbGi2Fp34/kSY55XEU0s=</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  endpoint: 10.100.0.20:59032</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  allowed ips: 10.13.13.2/32</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  latest handshake: 2 minutes, 2 seconds ago</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  transfer: 25.92 KiB received, 11.61 KiB sent</span><br></div></code></pre></div></div>
<p>허브가 아는 공개키하고 클라이언트가 실제로 가진 키가 일치하면 그게 신원이 됩니다. 인증 스택은 Noise IK라는 핸드셰이크 하나로 고정이 되어 있습니다.[4] Curve25519로 키를 교환하고 BLAKE2s로 키를 파생하며 ChaCha20-Poly1305로 암호화를 합니다. 협상이라는 게 아예 없습니다. IPsec에서 IKE 버전, DH group, 암호 스위트, PFS, SA lifetime을 상대와 맞추느라 쓰는 시간이 여기서는 통째로 사라집니다.[5] 대신 알고리즘을 고를 자유도 없긴 합니다. 이 교환이 이제 4천 줄의 정체입니다.</p>
<p>좀 주목할 부분은 <code>endpoint</code>가 인증에 관여를 하지 않는다는 점이었습니다. 클라이언트가 카페 와이파이로 옮겨서 소스 IP가 바뀌어도, 공개키만 맞으면 세션이 그대로 이어집니다. 로밍이 공짜로 딸려오는 형태입니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="allowedips는-라우팅-테이블이면서-방화벽입니다">AllowedIPs는 라우팅 테이블이면서 방화벽입니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#allowedips%EB%8A%94-%EB%9D%BC%EC%9A%B0%ED%8C%85-%ED%85%8C%EC%9D%B4%EB%B8%94%EC%9D%B4%EB%A9%B4%EC%84%9C-%EB%B0%A9%ED%99%94%EB%B2%BD%EC%9E%85%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="AllowedIPs는 라우팅 테이블이면서 방화벽입니다에 대한 직접 링크" title="AllowedIPs는 라우팅 테이블이면서 방화벽입니다에 대한 직접 링크" translate="no">​</a></h3>
<p>WireGuard 설정에서 제일 오해하기 쉬운 부분입니다. <code>AllowedIPs</code>는 두 가지 일을 동시에 하게 됩니다.</p>
<p>나갈 때는 라우팅 테이블입니다. 목적지가 어느 피어의 AllowedIPs에 속하는지 보고 그 피어로 보냅니다. 들어올 때는 암호학적인 필터가 됩니다. 피어 A의 키로 복호화된 패킷의 출발지가 A의 AllowedIPs 밖이면 조용히 버립니다.</p>
<p>그래서 허브 설정에서 클라이언트별 AllowedIPs를 넓게 잡아버리면, 한 클라이언트가 다른 클라이언트의 IP를 사칭한 패킷을 보낼 수가 있게 됩니다. 반드시 <code>/32</code>로 좁혀야 되는 부분입니다. 이 한 줄이 이제 스푸핑 방어선이 됩니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="암호화-경계를-눈으로-봤습니다">암호화 경계를 눈으로 봤습니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%95%94%ED%98%B8%ED%99%94-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EB%88%88%EC%9C%BC%EB%A1%9C-%EB%B4%A4%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="암호화 경계를 눈으로 봤습니다에 대한 직접 링크" title="암호화 경계를 눈으로 봤습니다에 대한 직접 링크" translate="no">​</a></h3>
<p>제일 확실했던 건 이제 한 번의 curl이 허브의 세 인터페이스에서 각각 어떻게 보이는지를 동시에 캡처를 해보는 형태였습니다.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">docker compose exec -d wg-hub bash -c '</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  (timeout 5 tcpdump -i eth0 -nn -l udp port 51820 &gt; /tmp/cap-eth0.txt) &amp;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  (timeout 5 tcpdump -i wg0  -nn -l                &gt; /tmp/cap-wg0.txt)  &amp;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  (timeout 5 tcpdump -i eth2 -nn -l                &gt; /tmp/cap-eth2.txt) &amp;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  wait'</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">docker compose exec client-a curl -s http://10.60.0.100/</span><br></div></code></pre></div></div>
<p>결과가 이렇게 나왔는데요.</p>
<table><thead><tr><th>인터페이스</th><th>보이는 것</th><th>src → dst</th></tr></thead><tbody><tr><td>eth0 (외부)</td><td>UDP 51820 암호문만. HTTP 흔적 없음</td><td>10.100.0.20:59032 → 10.100.0.10:51820</td></tr><tr><td>wg0 (터널 내부)</td><td><code>GET / HTTP/1.1</code> 평문 육안 확인</td><td>10.13.13.2:42798 → 10.60.0.100:80</td></tr><tr><td>eth2 (SNAT 후)</td><td>같은 평문, src만 바뀜</td><td>10.60.0.10:42798 → 10.60.0.100:80</td></tr></tbody></table>
<p>표 한 장에 두 개념이 다 들어가 있습니다. eth0하고 wg0 사이가 암호화 경계고, wg0하고 eth2 사이가 SNAT이 일어나는 부분입니다. 커널 파이프라인으로 써보면 이렇게 흐르게 됩니다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">eth0(UDP 캡슐화) → WG 커널 모듈 복호화 → wg0(평문) → iptables SNAT → eth2(평문, src 재작성)</span><br></div></code></pre></div></div>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="한 번의 curl이 허브의 세 인터페이스에서 보이는 모습. eth0에서는 암호문만, WG 커널 모듈을 지나 wg0에서는 평문이, iptables SNAT을 지나 eth2에서는 출발지가 허브 IP로 바뀐 평문이 보인다" src="https://nxtgen.co.kr/assets/images/05-f502845daf8f648a75a45cdc6a9804e2.png" width="2480" height="940" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">한 번의 curl이 허브의 세 인터페이스에서 보이는 모습. eth0에서는 암호문만, WG 커널 모듈을 지나 wg0에서는 평문이, iptables SNAT을 지나 eth2에서는 출발지가 허브 IP로 바뀐 평문이 보인다</span></span>
<em>eth0과 wg0 사이가 암호화 경계, wg0과 eth2 사이가 SNAT 지점. 허브는 모든 세션의 평문을 보는 유일한 자리다</em></p>
<p>여기서 보안적으로 좀 중요한 부분이 하나 따라 나오는데요. 허브가 모든 세션의 평문을 보는 유일한 지점이 되는 형태거든요. 이 박스가 뚫리면 전체 트래픽이 노출이 되고, 이 박스가 죽으면 전원이 다 끊기게 됩니다. 뒤에 나올 Tailscale의 subnet router라든지 Cloudflare의 커넥터도 정확하게 같은 성질을 물려받습니다. 그러다 보니까 어떤 도구를 쓰든 이 박스를 강화하고 이중화하는 게 최우선이 됩니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="클라이언트를-늘려도-ip는-하나입니다">클라이언트를 늘려도 IP는 하나입니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EB%A5%BC-%EB%8A%98%EB%A0%A4%EB%8F%84-ip%EB%8A%94-%ED%95%98%EB%82%98%EC%9E%85%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="클라이언트를 늘려도 IP는 하나입니다에 대한 직접 링크" title="클라이언트를 늘려도 IP는 하나입니다에 대한 직접 링크" translate="no">​</a></h3>
<p>조건 1의 검증은 간단했습니다. 서로 다른 클라이언트 두 대에서 온프렘 서버로 요청을 보내고, 서버가 관측한 소스 IP를 확인을 해봤습니다.</p>
<table><thead><tr><th>요청</th><th>온프렘이 본 소스</th></tr></thead><tbody><tr><td>client-a</td><td>10.60.0.10</td></tr><tr><td>client-b</td><td>10.60.0.10</td></tr></tbody></table>
<p>다른 피어인데 같은 IP가 찍힙니다. 허브의 <code>iptables -t nat -L POSTROUTING -n -v</code> 카운터하고 온프렘의 <code>INPUT</code> 카운터가 정확하게 맞아떨어지는 것까지 확인을 했습니다. 온프렘 화이트리스트에 넣을 IP는 허브 하나면 되는 형태입니다.</p>
<p>참고로 커널 WireGuard의 처리량은 랩 환경에서 iperf3 기준 1324 Mbps가 나왔습니다. 사용자 공간에서 도는 구현체하고 비교를 하면 커널 모듈의 이점이 여기서 드러납니다. 다만 뒤에 나오는 Tailscale은 사용자 공간 구현인 wireguard-go를 쓰다 보니까, 이 숫자를 Tailscale의 성능으로 읽으시면 안 됩니다.[6]</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="경로가-성능을-정합니다">경로가 성능을 정합니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EA%B2%BD%EB%A1%9C%EA%B0%80-%EC%84%B1%EB%8A%A5%EC%9D%84-%EC%A0%95%ED%95%A9%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="경로가 성능을 정합니다에 대한 직접 링크" title="경로가 성능을 정합니다에 대한 직접 링크" translate="no">​</a></h2>
<p>랩에서는 모든 경로가 도커 브리지 위다 보니까 성능 차이가 안 났습니다. 실제로는 공용 인터넷을 타는 경로하고 전용선을 타는 경로가 완전히 다른 물건인데, 그게 재현이 안 되다 보니 <code>tc netem</code>으로 인터넷 조건을 인위적으로 주입을 했습니다.[7]</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain"># A 경로: 공용 인터넷 시뮬 - 지연 80ms 편차 20ms, 손실 0.5%, 대역 100Mbps</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">tc qdisc add dev eth1 root handle 1: netem delay 80ms 20ms distribution normal loss 0.5%</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">tc qdisc add dev eth1 parent 1:1 handle 10: tbf rate 100mbit burst 32kbit latency 400ms</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"># B 경로: 전용선 시뮬 - 백본 지연만</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">tc qdisc add dev eth2 root netem delay 5ms</span><br></div></code></pre></div></div>
<p>네 가지 지표를 재봤는데요.</p>
<table><thead><tr><th>지표</th><th>A (인터넷)</th><th>B (전용선)</th><th>배수</th></tr></thead><tbody><tr><td>RTT 평균</td><td>91.2 ms</td><td>6.9 ms</td><td>13배</td></tr><tr><td>지터 (mdev)[8]</td><td>14.6 ms</td><td>0.5 ms</td><td>29배</td></tr><tr><td>TCP 처리량</td><td>5.24 Mbps (재전송 207회)</td><td>1286 Mbps (재전송 0)</td><td>245배</td></tr><tr><td>UDP 손실률 (500Mbps 목표)</td><td>84%</td><td>0.47%</td><td></td></tr><tr><td>curl 총 소요</td><td>161-200 ms</td><td>12.7-14.2 ms</td><td>13배</td></tr></tbody></table>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="두 경로의 실측 비교 차트. RTT, 지터, TCP 처리량, UDP 손실률, curl 소요 시간 다섯 지표에서 인터넷 시뮬 경로 A와 전용선 시뮬 경로 B를 막대로 나란히 놓았다" src="https://nxtgen.co.kr/assets/images/06-532c28579f8e90f0a0de2834e3799c81.png" width="2480" height="1120" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">두 경로의 실측 비교 차트. RTT, 지터, TCP 처리량, UDP 손실률, curl 소요 시간 다섯 지표에서 인터넷 시뮬 경로 A와 전용선 시뮬 경로 B를 막대로 나란히 놓았다</span></span>
<em>막대 길이는 행마다 큰 값을 100%로 둔 상대 척도. 행 사이의 길이는 비교 대상이 아니다</em></p>
<p>TCP 항목이 좀 눈에 띕니다. A 경로는 100Mbps 대역을 목표로 걸었는데 실제로는 5Mbps밖에 못 냈습니다. 지연이랑 손실이 겹치다 보니까 TCP 혼잡 제어가 계속 윈도를 줄이는, long fat network라는 문제입니다.[9] 회선 계약서에 적힌 대역폭하고 실제 처리량은 별개라는 걸 숫자로 확인을 한 셈입니다.</p>
<p>UDP는 좀 더 극단적입니다. 100Mbps 상한에 500Mbps를 밀어넣었으니 8할은 정의상 버려지는 게 맞는데요. 중요한 건 TCP처럼 알아서 속도를 줄이는 장치가 없다 보니 그 손실이 그대로 애플리케이션에 노출이 된다는 부분입니다. 실시간 프로토콜을 이런 경로에 올리면 안 된다는 결론이 바로 나오게 됩니다.</p>
<p>여기서 배우게 된 건 검증 방법론 쪽이 더 큽니다. ping만 보고 연결됐다고 판정을 하면 절반만 본 셈입니다. 지연이라든지 지터, 처리량, 손실은 각각 다른 이야기를 하고, 넷을 다 봐야 경로의 성격이 완성이 됩니다. 그리고 RTT가 높다고 해서 통신 실패는 아닙니다. 판정 기준은 손실률입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1차-답---tailscale-subnet-router">1차 답 - Tailscale Subnet Router<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#1%EC%B0%A8-%EB%8B%B5---tailscale-subnet-router" class="hash-link" aria-label="1차 답 - Tailscale Subnet Router에 대한 직접 링크" title="1차 답 - Tailscale Subnet Router에 대한 직접 링크" translate="no">​</a></h2>
<p>WireGuard의 원리는 확인을 했는데, 그대로 쓰기에는 좀 걸리는 부분이 있었습니다. 키 배포라든지 피어 관리가 전부 수동이고, NAT 뒤에 있는 노드들을 직접 연결하기가 어렵습니다. Tailscale은 이제 이 둘을 SaaS로 푼 물건입니다.</p>
<p>구조는 명확한 편입니다. 컨트롤 플레인은 Tailscale이 SaaS로 돌리면서 노드 목록, 공개키 교환, ACL 배포, NAT 관통 중개만 합니다. 데이터 플레인은 노드끼리 직접 WireGuard로 붙는 형태입니다. 사설키가 기기 밖으로 나가지 않다 보니까 Tailscale조차 내용을 못 봅니다. 직접 연결에 실패하면 DERP 릴레이가 암호화된 덩어리만 중계를 하게 됩니다.[10]</p>
<p>저희한테 중요했던 기능은 subnet router라는 기능이었습니다. 라우터 한 대만 tailnet에 넣고 온프렘 대역을 광고하면, 그 대역 안의 서버들은 아무것도 설치하지 않은 채로 접근 대상이 됩니다.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">tailscale up --advertise-routes=10.99.0.0/24</span><br></div></code></pre></div></div>
<p>여기서 처음에 좀 헷갈렸던 부분이 있습니다. 두 개의 IP 대역이 등장하는데 이걸 섞으면 안 됩니다. tailnet 노드가 받는 IP는 <code>100.64.0.0/10</code> CGNAT 대역으로 고정이고 바꿀 수가 없습니다.[11] 광고하는 온프렘 대역은 제가 고르는 값입니다. Tailscale이 관리하는 대역 안에서 특정 IP만 뚫는다는 식으로 이해를 했다가 바로잡게 되었습니다.[12]</p>
<p><code>--snat-subnet-routes</code>가 기본으로 켜져 있다 보니까, 온프렘 서버가 보는 소스는 항상 라우터의 온프렘 쪽 IP 하나가 됩니다. 조건 1이 여기서 성립을 합니다. 그리고 클라이언트는 개별 서버 IP가 아니라 대역 통째를 라우팅 테이블에 받게 되어서, 온프렘에 서버가 100대로 늘어도 클라이언트는 영원히 설정을 안 바꿔도 됩니다. 조건 3이 여기서 성립을 하게 됩니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="Tailscale Subnet Router를 AWS에 올린 구성. 컨트롤 플레인은 Tailscale SaaS, 데이터 플레인은 WireGuard 직접 연결이며 라우터가 온프레미스 대역 10.1.0.0/16을 광고하고 VPC Peering으로 전달한다" src="https://nxtgen.co.kr/assets/images/07-351a4d7c8283d6d258af05e183025e7f.png" width="2880" height="1600" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">Tailscale Subnet Router를 AWS에 올린 구성. 컨트롤 플레인은 Tailscale SaaS, 데이터 플레인은 WireGuard 직접 연결이며 라우터가 온프레미스 대역 10.1.0.0/16을 광고하고 VPC Peering으로 전달한다</span></span></p>
<p>AWS로 올릴 때는 VPC 두 개를 Peering으로 붙여가지고 라우터를 private subnet에 뒀습니다. 좀 인상적이었던 건 라우터의 인바운드 보안그룹 규칙이 0개인 채로 전부 동작을 하더라는 점이었습니다. Tailscale은 아웃바운드로만 연결을 맺고, 관리용 SSH조차 22번 포트 대신 <code>tailscale ssh</code>로 처리가 됩니다. 열어둔 포트가 없다 보니까 스캔 대상도 아니게 됩니다.</p>
<p>한 가지 더 알게 된 건 격리가 인증 키로 되는 게 아니라는 부분입니다. 고객사별로 나누려고 실험을 하다가 알았는데요. auth key가 같다는 건 같은 tailnet에 들어간다는 뜻일 뿐입니다. 기본 ACL이 전원 허용이다 보니까 클라이언트 하나가 모든 고객사 대역에 다 닿았습니다. 격리는 태그 기반 ACL로 해야 되고, 그 정책 파일은 git으로 관리를 하는 게 맞습니다.</p>
<p>이 시점에 저희 프로덕션 EKS에는 이미 Tailscale이 파드 형태로 올라가 있었습니다. 파드가 egress 게이트웨이를 거쳐서 단일 IP로 나가는 구조입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2차---같은-요구를-cloudflare-ztna로-재현했습니다">2차 - 같은 요구를 Cloudflare ZTNA로 재현했습니다<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#2%EC%B0%A8---%EA%B0%99%EC%9D%80-%EC%9A%94%EA%B5%AC%EB%A5%BC-cloudflare-ztna%EB%A1%9C-%EC%9E%AC%ED%98%84%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="2차 - 같은 요구를 Cloudflare ZTNA로 재현했습니다에 대한 직접 링크" title="2차 - 같은 요구를 Cloudflare ZTNA로 재현했습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>Tailscale로 요구조건을 다 충족했는데도 Cloudflare Zero Trust를 따로 검증을 한 이유는, 접근 제어라든지 감사 요구가 계약 단계에서 올라올 조짐이 보였거든요. 그래서 같은 요구조건을 ZTNA로도 만족할 수 있는지 AWS에서 직접 재현을 해봤습니다.</p>
<p>대응 관계가 거의 일대일이었습니다.</p>
<table><thead><tr><th>Tailscale</th><th>Cloudflare</th><th>역할</th></tr></thead><tbody><tr><td>Subnet Router</td><td>cloudflared 커넥터</td><td>온프렘망 게이트웨이, SNAT 지점</td></tr><tr><td><code>--advertise-routes</code></td><td>Tunnel Private Network Route</td><td>광고할 대역 등록</td></tr><tr><td>Tailscale 클라이언트</td><td>WARP + Split Tunnel Include[13]</td><td>단말에서 대상 대역만 터널로</td></tr><tr><td>Tailscale ACL</td><td>Gateway Network Policy</td><td>사용자와 리소스 단위 인가</td></tr><tr><td><code>tailscale ssh</code></td><td>Access for Infrastructure</td><td>SSH 세션 감사</td></tr></tbody></table>
<p>핵심 대응은 subnet router가 곧 cloudflared 커넥터라는 부분입니다. 둘 다 온프렘 서버 무설치이고, 온프렘망에 박스 한 대를 두고, SNAT로 IP를 하나로 압축하는 형태입니다.</p>
<p>검증도 같은 항목으로 돌렸습니다. 온프렘 서버에서 <code>tcpdump -i any icmp -n</code>을 걸고 WARP를 켠 PC에서 접근을 해봤더니, 소스가 커넥터의 사설 IP 하나로만 찍혔습니다. WARP 클라이언트 IP가 아닙니다. 조건 1 성립입니다.</p>
<p>커넥터도 아웃바운드로만 터널을 엽니다. compose 파일에 published 포트가 아예 없습니다. 온프렘 방화벽 인바운드 규칙이 0개인 채로 접근이 성립하는 건 Tailscale하고 같은 부분입니다.</p>
<p>다른 건 인가 쪽이었습니다. Gateway Network Policy로 목적지 IP라든지 포트, 사용자 신원을 조합해가지고 정책을 쓸 수가 있습니다. 실제로 <code>Destination IP is 10.1.1.9 → Block</code> 정책을 걸어서 특정 서버만 차단이 되는 걸 확인했습니다. Tailscale ACL이 대역하고 포트 단위인 데 비해서 한 단계 더 내려갑니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="왜-갈아탔나">왜 갈아탔나<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%99%9C-%EA%B0%88%EC%95%84%ED%83%94%EB%82%98" class="hash-link" aria-label="왜 갈아탔나에 대한 직접 링크" title="왜 갈아탔나에 대한 직접 링크" translate="no">​</a></h2>
<p>두 방식 모두 요구조건 1번부터 4번까지를 실증으로 충족을 했습니다. 그래서 선택은 무엇이 부족한가가 아니라 무엇을 우선하는가의 문제였는데요. 최종적으로는 Cloudflare ZTNA를 고르게 되었습니다.</p>
<p>우선은 인원 한도 부분입니다. Cloudflare Zero Trust는 50유저까지 무료거든요. Tailscale의 무료 티어는 한 자릿수 인원이다 보니까, PoC를 넘어서 실사용 인원을 받으면 바로 유료 구간으로 들어가게 됩니다.[14] 초기 도입 비용이 0이라는 게 생각보다 좀 크게 작용을 했습니다.</p>
<p>그 다음에 운영이라든지 콘솔 일원화 부분인데요. 터널, 정책, 로그 이런 것들이 한 대시보드에 모이는 형태입니다. 커넥터를 replica로 늘려가지고 이중화를 하는 것도, 라우터를 두 대 세우고 온프렘 화이트리스트에 IP를 둘 다 등록하는 것보다 손이 좀 덜 갑니다. 고객사가 여러 곳으로 늘어나는 상황을 가정하면 이 차이가 누적이 됩니다.</p>
<p>그리고 사용자별 인가나 이런 감사 부분입니다. 앞에서 본 Gateway Network Policy에 더해서 Gateway Logs라든지 Access for Infrastructure의 세션 기록이 있습니다. 누가 언제 어디에 접근을 했는가를 남기라는 요구가 들어왔을 때 바로 답이 되는 그런 기능입니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="그리고-예상이-뒤집힌-지점">그리고 예상이 뒤집힌 지점<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%98%88%EC%83%81%EC%9D%B4-%EB%92%A4%EC%A7%91%ED%9E%8C-%EC%A7%80%EC%A0%90" class="hash-link" aria-label="그리고 예상이 뒤집힌 지점에 대한 직접 링크" title="그리고 예상이 뒤집힌 지점에 대한 직접 링크" translate="no">​</a></h3>
<p>비교표를 처음 그렸을 때 저희는 Tailscale 쪽에 저지연이라고 적어뒀거든요. P2P 직결이다 보니까 엣지를 한 번 거치는 Cloudflare보다 당연히 빠를 거라고 봤습니다.</p>
<p>근데 실제로는 아니었습니다. NAT 환경에서 P2P 관통이 실패해서 DERP 릴레이로 폴백이 되는 상황을 겪었는데요. 그렇게 되면 트래픽은 결국 Tailscale의 릴레이 서버를 한 번 거치게 됩니다. 원인은 라우터가 AWS NAT Gateway 뒤에 있었다는 부분이었습니다. 홀펀칭은 STUN으로 알아낸 공인 IP하고 포트를 서로에게 알려주고, 양쪽이 동시에 그 주소로 두드려서 구멍을 내는 방식인데요. NAT Gateway는 목적지가 달라지면 공인 포트를 새로 배정을 합니다. 대칭형 NAT하고 사실상 같은 동작입니다. STUN 서버와 얘기할 때 40001을 썼어도 피어로 나갈 때는 40002가 배정이 되니까, 상대가 40001로 두드려봐야 거긴 열린 구멍이 없습니다. <code>tailscale status</code>에 <code>relay "tok"</code>으로 찍혀서 도쿄 DERP를 타고 있다는 걸 확인하게 되었습니다. 즉 P2P라서 빠르다는 전제 자체가 성립을 안 하는 환경이 있었습니다. 반면에 Cloudflare는 엣지 경유가 기본 설계다 보니까 경로가 처음부터 예측이 가능하고, 실측 체감으로는 응답이 오히려 더 안정적이긴 했습니다. 아쉽게도 이 구간의 수치를 따로 기록해두지 않아서 여기서는 정성적인 서술에 그치게 됐습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="NAT 홀펀칭이 성공하는 정상 케이스와, 라우터가 AWS NAT Gateway 뒤에 있어 실패하고 도쿄 DERP 릴레이로 폴백한 우리 케이스를 위아래로 비교한 도식. STUN에 알려준 포트와 피어로 나갈 때 새로 배정되는 포트가 달라 구멍이 열리지 않는 지점을 표시했다" src="https://nxtgen.co.kr/assets/images/08-050063eae3a94e65bbff5eb19a5207c2.svg" width="1240" height="840" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">NAT 홀펀칭이 성공하는 정상 케이스와, 라우터가 AWS NAT Gateway 뒤에 있어 실패하고 도쿄 DERP 릴레이로 폴백한 우리 케이스를 위아래로 비교한 도식. STUN에 알려준 포트와 피어로 나갈 때 새로 배정되는 포트가 달라 구멍이 열리지 않는 지점을 표시했다</span></span></p>
<p>스펙표의 화살표하고 실측 결과가 반대로 나온 경험이었습니다. 아키텍처 문서에 P2P 저지연이라고 적을 때, 그 앞에 NAT 관통에 성공하면이라는 조건이 생략이 되어 있다는 걸 그때 알게 되었습니다.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="대신-내준-것">대신 내준 것<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EB%8C%80%EC%8B%A0-%EB%82%B4%EC%A4%80-%EA%B2%83" class="hash-link" aria-label="대신 내준 것에 대한 직접 링크" title="대신 내준 것에 대한 직접 링크" translate="no">​</a></h3>
<p>정직하게 적자면 Cloudflare를 고르면서 포기하게 된 부분도 분명히 있습니다.</p>
<p>Tailscale은 종단간 암호화입니다. 사설키가 기기 밖으로 안 나가다 보니까 Tailscale도 내용을 못 봅니다. Cloudflare는 엣지에서 TLS를 종료하고 다시 암호화를 합니다. 즉 제 트래픽을 볼 수 있는 위치에 있는 형태입니다.</p>
<p>네트워크 안에 들어왔다고 해서 아무도 믿지 않는다는 게 Zero Trust인데요. 그 구현을 위해서 특정 벤더는 전적으로 믿어야 하는 구조가 되어버립니다. 데이터 플레인까지 SaaS에 의존을 하게 되다 보니까, SaaS 사용이 금지되는 컴플라이언스 환경이라면 이 방식은 애초에 후보가 아닙니다. 그런 경우에는 자체 WireGuard 허브나 이런 IPsec site-to-site로 돌아가야 됩니다.</p>
<p>덧붙이면 Zero Trust라는 건 제품 이름이 아니라 보안 모델입니다. Tailscale도 기기별 키라든지 SSO 신원, ACL을 갖춘 엄연한 ZTNA입니다. 둘의 차이는 Zero Trust냐 아니냐가 아니라 mesh냐 proxy냐, 그리고 벤더를 어디까지 믿느냐인 부분입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="Tailscale 의 mesh 구성과 Cloudflare ZTNA 의 proxy 구성에서 평문을 볼 수 있는 위치를 나란히 표시한 도식. Tailscale 은 우리 박스 한 곳, Cloudflare 는 벤더 엣지가 하나 더 생긴다" src="https://nxtgen.co.kr/assets/images/09-28168f83d46aef41f61ec3e2bf6cf39d.svg" width="1240" height="690" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">Tailscale 의 mesh 구성과 Cloudflare ZTNA 의 proxy 구성에서 평문을 볼 수 있는 위치를 나란히 표시한 도식. Tailscale 은 우리 박스 한 곳, Cloudflare 는 벤더 엣지가 하나 더 생긴다</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="박스-뒤는-평문인데-괜찮은가">박스 뒤는 평문인데 괜찮은가<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EB%B0%95%EC%8A%A4-%EB%92%A4%EB%8A%94-%ED%8F%89%EB%AC%B8%EC%9D%B8%EB%8D%B0-%EA%B4%9C%EC%B0%AE%EC%9D%80%EA%B0%80" class="hash-link" aria-label="박스 뒤는 평문인데 괜찮은가에 대한 직접 링크" title="박스 뒤는 평문인데 괜찮은가에 대한 직접 링크" translate="no">​</a></h2>
<p>여기까지 읽으시면 두 가지가 좀 걸리실 겁니다. 하나는 박스가 복호화한 뒤부터 온프렘 서버까지는 암호화가 없다는 부분이고, 다른 하나는 방화벽이 IP 하나만 보고 통과를 시키는데 그 IP를 위조하면 어떻게 되느냐는 부분입니다. PoC 중에 저도 같은 질문을 받았거든요.</p>
<p>첫 번째는 사실입니다. 앞의 tcpdump 표에서 wg0하고 eth2 구간이 그 자리고, Tailscale의 subnet router도 Cloudflare의 커넥터도 똑같습니다. 서버에 아무것도 설치하지 않기로 한 순간, 서버가 직접 복호화하는 종단이 될 수가 없다 보니까 구조적으로 따라오는 비용입니다. 그래서 이게 취약점인지는 그 평문 구간이 어디를 지나가느냐로 갈리게 됩니다. 박스가 온프렘 DMZ 안에 있으면 평문은 고객사 내부망 안에서만 흐르고, 사내 서버끼리 오가는 트래픽하고 같은 신뢰 수준이라 대부분 수용이 됩니다. 반대로 박스가 AWS에 있고 온프렘이 인터넷 건너편이면, NAT 게이트웨이하고 온프렘 게이트웨이 사이에 IPsec이든 WireGuard든 site-to-site 터널이 반드시 있어야 됩니다. 방화벽의 IP 허용은 접근 제어일 뿐이지 기밀성을 주지는 않거든요. 그리고 터널하고 별개로, 서비스 자체의 암호화를 켜는 방법이 있습니다. SSH는 원래 암호화가 되어 있고, 웹은 HTTPS, DB는 PostgreSQL의 sslmode=verify-full이나 MySQL의 require_secure_transport, RDP는 TLS/NLA처럼 서버가 이미 갖고 있는 기능이다 보니까 설치가 아닙니다. 이렇게 하면 박스는 암호문을 암호문으로 넘기는 라우터가 되고, 박스가 뚫리면 전체 평문이 노출된다는 위험까지 같이 줄어들게 됩니다.</p>
<p>두 번째로, IP 위조는 인터넷 건너편에서는 현실적으로 안 됩니다. 출발지를 위조한 SYN을 보내면 SYN+ACK는 진짜 주인에게 날아가다 보니까 공격자는 받지를 못합니다. 핸드셰이크를 끝내려면 서버가 고른 초기 시퀀스 번호를 알아야 되는데요. 지금은 모든 OS가 이 값을 무작위화해서[15] 32비트를 찍어 맞춰야 됩니다. 1990년대에는 이 번호가 예측이 가능해서 실제로 성공한 사례가 있었지만[16] 지금은 아닙니다. 다만 이 논리는 두 전제 위에 서 있습니다. 응답이 공격자를 거치지 않는다는 것, 그리고 TCP처럼 왕복이 필요하다는 것. 같은 L2에서 ARP를 속인다든지 BGP 하이재킹으로 경로를 끌어오는 경로상 공격자는 첫 전제를 깨고,[17] syslog나 이런 SNMP처럼 UDP 패킷 한 방으로 동작하는 서비스는 두 번째 전제를 깹니다. 그리고 위조와 무관하게 더 자주 터지는 문제가 있는데요. 방화벽은 "그 IP에서 왔다"만 볼 수 있고 "그 IP 뒤의 누가 보냈다"는 못 봅니다. NAT IP 하나를 허용하면 그 뒤의 EKS 파드 전부가 정당한 허용 대상이 되어버리는 형태입니다. 앞에서 격리는 인증 키가 아니라 ACL로 해야 된다고 쓴 교훈이 정확하게 이 지점하고 맞닿아 있습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="방화벽 IP 허용(접근 제어), 터널의 키(인증), 서비스 자체의 TLS(데이터 보호) 세 겹이 각각 어느 구간을 덮는지 막대로 나란히 보여준다. 아래에 출발지 IP를 위조한 공격자가 겹 1은 지나도 겹 2의 키에서 막혀 터널이 열리지 않는 흐름을 함께 적었다" src="https://nxtgen.co.kr/assets/images/10-000ffe344525f885829972439d4c7786.png" width="2480" height="1360" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">방화벽 IP 허용(접근 제어), 터널의 키(인증), 서비스 자체의 TLS(데이터 보호) 세 겹이 각각 어느 구간을 덮는지 막대로 나란히 보여준다. 아래에 출발지 IP를 위조한 공격자가 겹 1은 지나도 겹 2의 키에서 막혀 터널이 열리지 않는 흐름을 함께 적었다</span></span></p>
<p>정리를 하면 세 겹입니다. 방화벽의 IP 허용은 접근 제어, 터널의 키는 인증, 서비스의 TLS는 데이터 보호가 됩니다. IP 허용은 인터넷의 소음을 걸러내는 1차 필터로는 훌륭한데 인증은 아닙니다. 정말 저희가 보낸 것임을 증명하는 건 WireGuard의 공개키라든지 IPsec의 IKE 인증, Cloudflare의 사용자·기기 신원입니다. 경로상 공격자가 IP를 흉내내는 데 성공을 해도 키가 없으면 핸드셰이크가 안 열립니다. WireGuard는 첫 패킷부터 키로 인증된 핸드셰이크다 보니까, 알 수 없는 상대에게는 응답조차 하지 않도록 설계가 되어 있습니다.[18]</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="막혔던-지점들">막혔던 지점들<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EB%A7%89%ED%98%94%EB%8D%98-%EC%A7%80%EC%A0%90%EB%93%A4" class="hash-link" aria-label="막혔던 지점들에 대한 직접 링크" title="막혔던 지점들에 대한 직접 링크" translate="no">​</a></h2>
<p>구축을 하면서 실제로 시간을 좀 뺏긴 부분들입니다. 다음에 같은 걸 하실 분한테는 이 목록이 본문보다 쓸모가 있을 수도 있겠네요.</p>
<p><strong>Split Tunnel의 서브넷 마스크.</strong> 대상이 <code>10.10.25.11</code>인데 Include에 <code>10.10.0.0/24</code>를 넣고 왜 안 되는지 한참 봤습니다. <code>/24</code>는 <code>10.10.0.x</code>만 덮습니다. VPC 전체를 쓰려면 <code>/16</code>이어야 되고, Include하고 Tunnel Route 양쪽 다 그렇습니다.</p>
<p><strong>디바이스 프로필 중복.</strong> 같은 이름의 Onboarding 프로필이 네 개 있었고, 제가 고친 프로필하고 기기에 실제로 매칭된 프로필이 달랐습니다. 설정을 바꿨는데 안 바뀐다의 정체가 이거였습니다. 프로필은 위에서 아래로 우선순위가 매겨지고 Default는 fallback입니다. 단일 사용자라면 Default 하나로 통일을 하는 게 낫습니다.</p>
<p><strong>ICMP는 Gateway Network Policy의 대상이 아닙니다.</strong> 이게 제일 위험했습니다. 정책으로 차단을 해놓고 ping이 통하길래 정책이 안 먹는 줄 알았는데, 애초에 ICMP는 정책 평가 대상이 아닙니다.[19] 차단 테스트도 도달 테스트도 <code>nc -vz &lt;ip&gt; &lt;port&gt;</code>처럼 TCP로 해야 됩니다.</p>
<p><strong>VPC Peering 라우트는 양방향.</strong> 한쪽 라우팅 테이블에만 상대 대역을 넣으면 요청은 가는데 응답이 못 돌아옵니다.</p>
<p><strong>보안그룹은 프로토콜별로 따로.</strong> ICMP 허용하고 TCP 22 허용은 별개 규칙입니다. ping은 되는데 SSH가 안 되면 대개 이겁니다. Peering 구간에서는 소스에 IP 대신 상대 보안그룹을 참조하는 방식이 확장에 강합니다.</p>
<p><strong>에러 메시지를 구분해서 읽기.</strong> <code>Network is unreachable</code>은 클라이언트에 라우트가 없다는 뜻으로 Split Tunnel 문제입니다. <code>Connection timed out</code>은 보안그룹에서 드롭이 된 것입니다. <code>Permission denied (publickey)</code>는 연결 자체는 성공을 했고 키 문제입니다. 이 셋을 구분하면 디버깅 범위가 즉시 좁혀집니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="지금-어디까지-왔나">지금 어디까지 왔나<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%A7%80%EA%B8%88-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%99%94%EB%82%98" class="hash-link" aria-label="지금 어디까지 왔나에 대한 직접 링크" title="지금 어디까지 왔나에 대한 직접 링크" translate="no">​</a></h2>
<p>솔직하게 적으면 이 대학 고객사 건은 PoC 단계에서 멈춰 있습니다. 요구조건 다섯 개를 두 가지 방식으로 각각 실증을 했고, Docker 랩에서 AWS로 승격해가지고 단일 IP 압축, 온프렘 무설치, 서버 추가 시 무설정, 인바운드 0을 전부 확인했습니다. 남은 건 고객사 측 게이트웨이 정보라든지 화이트리스트 등록인데, 그건 저희가 결정할 수 있는 영역이 아닙니다.</p>
<p>다만 같은 스택이 사내 다른 프로젝트에서는 실제로 돌아가고 있습니다. 내부 리소스 접근을 Cloudflare Zero Trust로 통제하는 형태입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="NxtCloud Prod EKS와 온프레미스 연결 구성. Subnet Router와 Cloudflare Tunnel 파드가 온프레미스 CIDR을 광고하고, 온프레미스 방화벽은 EKS의 고정 NAT IP 하나만 허용한다" src="https://nxtgen.co.kr/assets/images/11-0de53ca63c37dacd25efec42e4e76295.png" width="3320" height="1500" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">NxtCloud Prod EKS와 온프레미스 연결 구성. Subnet Router와 Cloudflare Tunnel 파드가 온프레미스 CIDR을 광고하고, 온프레미스 방화벽은 EKS의 고정 NAT IP 하나만 허용한다</span></span></p>
<p>이 구성에서 온프렘 방화벽은 EKS의 고정 NAT IP 하나만 허용을 합니다. 그 위의 인증은 Cloudflare의 사용자, 기기 신원이 맡고, 데이터 보호는 서비스 자체의 TLS라든지 SSH가 맡는 형태입니다. 앞에서 정리한 세 겹이 그대로 들어가 있는 셈입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="정리하면">정리하면<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%A0%95%EB%A6%AC%ED%95%98%EB%A9%B4" class="hash-link" aria-label="정리하면에 대한 직접 링크" title="정리하면에 대한 직접 링크" translate="no">​</a></h2>
<p>서버까지 닿는 터널은 처음부터 만들 수가 없었습니다. 서버에 아무것도 설치하지 않기로 한 순간 정해진 부분이었거든요. 대신 터널은 저희 쪽 종단인 박스까지로 끊고, 그 뒤는 출발지를 하나로 압축하는 것이라든지 서비스가 이미 갖고 있는 암호화로 처리를 하게 되었습니다. 누가 언제 어디에 접근했는가는 네트워크가 아니라 Cloudflare의 신원 정책이나 로그 이런 쪽으로 넘기게 되었습니다. 온프렘이 보는 건 IP 하나, 저희가 보는 건 사용자 단위의 기록입니다.</p>
<p>온프렘 쪽이 확정이 되면 남은 갈래는 둘입니다. 고객사에 이미 VPN 게이트웨이가 있으면 저희 egress 게이트웨이가 그 터널에 붙게 됩니다. 고객사가 새로 설치할 건 없고, IPsec이면 IKE 버전, DH group, 암호 스위트, PFS, SA lifetime, traffic selector를, WireGuard면 공개키하고 엔드포인트, AllowedIPs 세 가지를 맞추는 협의만 남습니다. 이 협의 비용 차이가 두 프로토콜의 성격을 가장 잘 보여주는 지점이기도 합니다. 게이트웨이가 없으면 커넥터 박스 한 대를 고객사 DMZ에 두는 것으로 끝납니다. 커넥터는 아웃바운드로만 연결을 맺다 보니까, 이때는 방화벽에 IP를 등록할 필요조차 없어지게 됩니다.</p>
<p>어느 쪽이든 저희 설계는 같습니다. EKS 파드에서 egress 게이트웨이로, 거기서 SNAT를 거쳐서 단일 IP로 나가는 골격은 변하지 않고, 게이트웨이에서 돌리는 것만 <code>strongSwan</code>이나 <code>wg</code>나 <code>cloudflared</code>로 바뀌게 됩니다. 앞의 두 경우는 Docker 랩에서 통과를 시켜 뒀습니다.</p>
<p>여기까지가 저희가 단일 IP라는 제약 하나에서 시작해서 도구를 두 번 갈아탔던 과정이었습니다.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] ZTNA(Zero Trust Network Access)는 "사내망 안에 있으면 신뢰한다"는 전제를 버리고, 접속마다 사용자와 기기의 신원을 확인한 뒤 허용된 리소스에만 연결해 주는 접근 방식입니다. 구현 형태로는 노드끼리 직접 붙는 mesh형(Tailscale)과 벤더의 엣지를 거치는 proxy형(Cloudflare)이 있고, 본문 뒷부분의 "mesh냐 proxy냐"가 이 구분입니다.</p>
<p>[2] 허브 앤 스포크(hub-and-spoke)는 모든 노드가 중앙의 허브 한 대에만 연결되고 노드 간 통신은 허브를 경유하는 구조입니다. 노드끼리 직접 연결하는 mesh와 대비됩니다. 이 글의 SNAT 박스가 바로 허브 자리입니다.</p>
<p>[3] X.509는 TLS 인증서의 표준 형식이고, CA(Certificate Authority)는 그 인증서에 서명해 신뢰를 보증하는 기관, CRL(Certificate Revocation List)은 폐기된 인증서 목록입니다. 일반적인 SSL VPN은 이 세 가지로 신원을 관리하는데, WireGuard는 공개키 한 줄로 대신합니다. 그래서 인증서 만료나 폐기 같은 운영 절차가 없지만, 반대로 키를 회수하려면 설정에서 직접 지워야 합니다.</p>
<p>[4] Noise IK는 Noise 프로토콜 프레임워크의 핸드셰이크 패턴 중 하나로, 개시자가 응답자의 정적 공개키를 미리 알고 있는 상황을 전제합니다. WireGuard는 이 패턴 하나만 지원하고 암호 스위트도 Curve25519, BLAKE2s, ChaCha20-Poly1305로 고정해 협상 단계를 아예 없앴습니다. 알고리즘을 바꿔야 하면 프로토콜 버전을 올리는 방식으로 대응한다는 설계입니다.</p>
<p>[5] IPsec 터널을 맺을 때 양쪽이 맞춰야 하는 값들입니다. IKE 버전(키 교환 프로토콜의 세대, v1/v2), DH group(키 교환에 쓰는 수학적 그룹의 크기), 암호 스위트(암호화·무결성 알고리즘 조합), PFS(세션 키가 유출돼도 과거 트래픽은 안전하게 하는 옵션), SA lifetime(키를 갱신하는 주기), traffic selector(어느 대역의 트래픽을 터널에 태울지). 하나라도 어긋나면 터널이 올라오지 않아서, 상대 조직과 표를 주고받으며 맞추는 작업이 IPsec 협의 비용의 대부분입니다.</p>
<p>[6] WireGuard는 리눅스 커널 모듈 구현과, Go로 작성된 사용자 공간 구현(wireguard-go)이 있습니다. 커널 구현은 패킷이 커널 안에서 처리되어 빠르고, 사용자 공간 구현은 이식성이 좋지만 컨텍스트 전환 비용이 있습니다. Tailscale은 모든 플랫폼에서 wireguard-go 기반으로 동작하므로, 본문 랩의 커널 WireGuard 처리량을 Tailscale 성능으로 바로 대입할 수는 없습니다.</p>
<p>[7] <code>tc</code>는 리눅스 커널의 트래픽 제어 도구이고, <code>netem</code>은 지연·지터·손실을 인위적으로 주입하는 모듈, <code>tbf</code>(token bucket filter)는 대역폭을 제한하는 모듈입니다. 본문 A 경로에서 <code>tbf</code>의 <code>burst 32kbit</code>는 100Mbps 기준 권장 하한(대략 rate/HZ, HZ=1000이면 약 12.5KB)보다 작아서 tbf 자체가 대역을 100Mbps 아래로 깎고 드롭을 더했을 가능성이 있습니다. 다만 RTT 91ms·손실 0.5% 조건에서 TCP 처리량 상한을 계산하면 수 Mbps 수준이 나오므로, 실측 5.24Mbps는 tbf와 무관하게 long fat network 효과만으로도 설명됩니다. 재현하실 때는 <code>burst 1600kbit</code> 이상으로 두시길 권합니다.</p>
<p>[8] <code>ping</code> 출력의 mdev는 RTT 표본의 표준편차입니다. 엄밀한 의미의 지터(연속 패킷 간 지연 차이)와는 정의가 다르지만, 경로의 지연 흔들림을 보는 간편한 지표로 흔히 대용합니다. 본문에서는 이 뜻으로 썼습니다.</p>
<p>[9] long fat network는 대역폭과 지연의 곱(BDP, bandwidth-delay product)이 큰 경로를 말합니다. TCP 처리량의 상한은 대략 윈도 크기를 RTT로 나눈 값이라, 지연이 크면 윈도가 충분히 커져야 대역을 다 씁니다. 여기에 패킷 손실이 섞이면 혼잡 제어가 윈도를 반복적으로 줄이면서 계약 대역과 무관하게 처리량이 주저앉습니다.</p>
<p>[10] DERP(Designated Encrypted Relay for Packets)는 Tailscale이 운영하는 릴레이입니다. 양쪽 모두 대칭 NAT 뒤에 있는 등의 이유로 직접 연결이 안 될 때 폴백 경로로 쓰이며, WireGuard로 암호화된 페이로드만 중계하므로 릴레이가 내용을 볼 수는 없습니다. 다만 경로에 홉이 하나 더 붙으므로 지연은 늘어납니다.</p>
<p>[11] <code>100.64.0.0/10</code>은 RFC 6598이 통신사 등급 NAT(CGNAT)용으로 예약한 공유 주소 공간입니다. 사설 대역이나 공인 대역과 충돌할 가능성이 낮아 Tailscale이 tailnet 노드 주소로 씁니다. 이 대역은 변경할 수 없으므로, 광고하는 온프렘 대역과는 별개의 층으로 이해해야 합니다.</p>
<p>[12] <code>--advertise-routes</code>만으로는 끝나지 않습니다. 라우터 호스트에서 IP 포워딩(<code>net.ipv4.ip_forward=1</code>)을 켜야 하고, 광고된 대역은 Tailscale 관리 콘솔에서 승인(approve)해야 다른 노드에 배포됩니다. 승인을 자동화하려면 ACL의 <code>autoApprovers</code>를 쓰면 됩니다.</p>
<p>[13] WARP의 Split Tunnel은 단말에서 어떤 트래픽을 Cloudflare로 보낼지 정하는 설정입니다. Exclude 모드는 "이 대역만 빼고 전부 터널로", Include 모드는 "이 대역만 터널로"입니다. 본문처럼 온프렘 대역만 Cloudflare를 거치게 하려면 Include 모드에 그 대역을 넣습니다.</p>
<p>[14] 두 서비스의 무료 티어 인원은 정책이 바뀔 수 있는 값입니다. 도입 시점에 각 벤더의 요금제 페이지에서 다시 확인하시는 게 좋습니다.</p>
<p>[15] RFC 6528(Defending against Sequence Number Attacks)은 TCP 초기 시퀀스 번호를 비밀 키와 해시로 생성하도록 권고하며, 현대 리눅스·BSD·Windows가 모두 이를 따릅니다. 인터넷 사업자들이 위조 출발지 패킷을 걸러내는 BCP38 이그레스 필터링도 블라인드 스푸핑을 어렵게 만드는 또 한 겹입니다.</p>
<p>[16] 1994년 케빈 미트닉이 츠토무 시모무라의 시스템에 침입할 때 쓴 방식이 TCP 시퀀스 번호 예측을 이용한 블라인드 스푸핑이었습니다. 당시 구현은 시퀀스 번호가 일정 규칙으로 증가해 추측이 가능했습니다.</p>
<p>[17] 2018년 4월 Amazon Route 53의 주소 대역이 BGP 하이재킹으로 약 두 시간 납치되어 MyEtherWallet 사용자 트래픽이 공격자 서버로 유도된 사건이 대표 사례입니다. 경로를 통제하는 공격자에게는 출발지 IP 검사가 아무 보장을 하지 못합니다.</p>
<p>[18] WireGuard 백서의 "stealth" 설계 원칙입니다. 핸드셰이크 첫 메시지부터 상대 공개키로 인증되며, 인증되지 않은 패킷에는 어떤 응답도 보내지 않아 포트 스캔으로 존재를 확인할 수 없습니다.</p>
<p>[19] 이 글을 작성한 시점의 Cloudflare 문서 기준입니다. Gateway 정책의 프로토콜 지원 범위는 바뀔 수 있으니, 같은 증상을 만나면 현재 문서를 먼저 확인하시길 권합니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="참고">참고<a href="https://nxtgen.co.kr/blog/2026/09/14/p2p-is-not-always-faster#%EC%B0%B8%EA%B3%A0" class="hash-link" aria-label="참고에 대한 직접 링크" title="참고에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://www.wireguard.com/papers/wireguard.pdf" target="_blank" rel="noopener noreferrer" class="">WireGuard: Next Generation Kernel Network Tunnel (백서)</a></li>
<li class=""><a href="https://tailscale.com/kb/1019/subnets" target="_blank" rel="noopener noreferrer" class="">Subnet routers, Tailscale</a></li>
<li class=""><a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/cloudflared/" target="_blank" rel="noopener noreferrer" class="">Connect private networks with cloudflared, Cloudflare</a></li>
<li class=""><a href="https://docs.aws.amazon.com/vpn/latest/s2svpn/VPC_VPN.html" target="_blank" rel="noopener noreferrer" class="">AWS Site-to-Site VPN 사용 설명서</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc6598" target="_blank" rel="noopener noreferrer" class="">RFC 6598, IANA-Reserved IPv4 Prefix for Shared Address Space</a></li>
<li class=""><a href="https://man7.org/linux/man-pages/man8/tc-netem.8.html" target="_blank" rel="noopener noreferrer" class="">tc-netem(8), tc-tbf(8) man pages</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc6528" target="_blank" rel="noopener noreferrer" class="">RFC 6528, Defending against Sequence Number Attacks</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc2827" target="_blank" rel="noopener noreferrer" class="">BCP 38 / RFC 2827, Network Ingress Filtering</a></li>
<li class=""><a href="https://tailscale.com/kb/1214/site-to-site" target="_blank" rel="noopener noreferrer" class="">Site-to-site networking, Tailscale</a></li>
<li class=""><a href="https://developers.cloudflare.com/cloudflare-one/policies/gateway/egress-policies/dedicated-egress-ips/" target="_blank" rel="noopener noreferrer" class="">Cloudflare Gateway dedicated egress IPs</a></li>
</ul>
<p>이 글의 수치와 구성은 실제 PoC 과정에서 확인한 것입니다. 고객사 식별 정보와 실제 공인 IP는 익명화했고, 본문에 등장하는 내부 대역은 사설 주소입니다.</p>]]></content:encoded>
            <category>vpn</category>
            <category>wireguard</category>
            <category>tailscale</category>
            <category>cloudflare</category>
            <category>ztna</category>
            <category>network</category>
        </item>
        <item>
            <title><![CDATA[조인 성능은 엔진이 아니라 플래너가 정한다 (Clickhouse 26.4+)]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner</guid>
            <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[ClickHouse는 조인에 약하다는 통념을 DuckDB와 같은 조건에서 TPC-H SF100까지 직접 재봤습니다. 조인 성능을 가르는 건 엔진이 아니라 플래너였고, 그 통념은 25.8까지의 얘기였습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>ClickHouse는 조인에 약하다는 통념이 있습니다. 정말 그런지, 어떤 조건에서 그런지를 같은 조건에서 직접 재보고 싶어서 DuckDB와 ClickHouse를 컨테이너에 나란히 세워 TPC-H를 SF100까지 돌렸습니다. 결론은 제목 그대로 조인 성능을 가르는 건 엔진이 아니라 플래너였고, 그 통념은 특정 버전까지의 얘기였습니다. 거기까지 가는 길에 예상 못 한 것들이 몇 개 있었습니다.</p>
</blockquote>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-7730d21351bbc57b63cba5853a2e9e29.png" width="2100" height="900" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="왜-다시-재봤나">왜 다시 재봤나<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%99%9C-%EB%8B%A4%EC%8B%9C-%EC%9E%AC%EB%B4%A4%EB%82%98" class="hash-link" aria-label="왜 다시 재봤나에 대한 직접 링크" title="왜 다시 재봤나에 대한 직접 링크" translate="no">​</a></h2>
<p>고객 데이터 파이프라인을 설계하다 보면 분석용 엔진을 고르는 순간이 꼭 옵니다. 로그와 이벤트를 쌓고 대시보드를 돌리는 쪽은 ClickHouse가 익숙하고, 정규화된 업무 테이블을 이리저리 붙여 보는 애드혹 분석은 DuckDB가 편하죠. 그런데 이 둘을 가르는 기준으로 늘 나오는 말이 하나 있습니다. ClickHouse는 조인에 약하다. 이 말을 저도 몇 번 들었는데, 정작 제 손으로 같은 조건에서 재본 적은 없더라고요. 벤더 블로그 수치는 각자 유리한 환경에서 나온 것들이라 그대로 믿기가 좀 어렵습니다.</p>
<p>그래서 조건을 하나로 맞췄습니다. 엔진마다 Docker 컨테이너를 따로 띄우고 cgroup으로 8 vCPU와 16 GiB를 똑같이 고정했습니다. cgroup은 리눅스가 프로세스 묶음에 CPU와 메모리 상한을 거는 기능이라, 세 엔진이 정확히 같은 크기의 상자 안에서 도는 셈입니다. 쿼리 하나가 쓸 수 있는 메모리 상한은 양쪽 다 12 GiB로 맞췄고, 한 시점에 한 엔진만 돌렸습니다. 데이터는 TPC-H를 DuckDB의 dbgen으로 만들어 Parquet로 뽑은 다음 두 엔진이 같은 파일을 읽어 각자 네이티브 스토리지에 적재했습니다[4]. 스케일 팩터는 1, 10, 30, 100까지 갔는데 SF100이면 lineitem이 6억 행입니다. 22개 쿼리의 결과 집합은 행 단위로 대조해서 세 엔진이 같은 답을 내는지도 확인했습니다. 결국에는 누가 빠른가보다 왜 다른가를 보려고 이렇게까지 맞춘 겁니다.</p>
<p>TPC-H를 모르는 분을 위해 한 줄만 적으면, 주문 데이터를 흉내 낸 표준 벤치마크입니다. 고객이 주문을 넣고 주문 안에 품목이 여러 줄 있고 품목마다 부품과 공급자가 붙는 구조라, 테이블 여덟 개를 이리저리 붙여야 답이 나오는 22개 질의로 돼 있어요. 제일 큰 lineitem이 주문 상세이고, 스케일 팩터(SF)는 데이터 크기 배율입니다. SF10이면 lineitem이 6천만 행, SF100이면 6억 행입니다. 정규화된 업무 데이터를 조인하는 워크로드를 흉내 내기에 이만한 게 없더라고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="TPC-H 스키마. 주문 상세 lineitem이 orders, part, supplier, partsupp에 키로 연결되고 orders는 customer, nation, region으로 이어진다. SF10 기준 행 수 표시" src="https://nxtgen.co.kr/assets/images/02-29fd2846d1b919deeac535ce9c07a6f4.png" width="1400" height="563" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">TPC-H 스키마. 주문 상세 lineitem이 orders, part, supplier, partsupp에 키로 연결되고 orders는 customer, nation, region으로 이어진다. SF10 기준 행 수 표시</span></span></p>
<p>처음엔 ClickHouse 25.8 LTS 하나면 되겠지 했어요. 아니죠, 최신 26.8의 설정 목록을 열어보니 25.8에는 없는 설정 같은 것들이 줄줄이 나오더라고요. 통계 기반 조인 리오더링, 런타임 조인 필터, hash 조인 스필. 조인에 관해서는 같은 버전대로 묶을 수 없었습니다. 그래서 DuckDB 1.5.5, ClickHouse 25.8, ClickHouse 26.8 세 개를 놓고 비교했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="용어-몇-개만-먼저">용어 몇 개만 먼저<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%9A%A9%EC%96%B4-%EB%AA%87-%EA%B0%9C%EB%A7%8C-%EB%A8%BC%EC%A0%80" class="hash-link" aria-label="용어 몇 개만 먼저에 대한 직접 링크" title="용어 몇 개만 먼저에 대한 직접 링크" translate="no">​</a></h2>
<p>뒤에서 계속 나올 말이라 여기서 풀고 갑니다.</p>
<p>조인은 두 테이블을 키로 맞춰 붙이는 일이고, 분석 엔진은 거의 다 해시 조인으로 합니다. 한쪽 테이블을 전부 읽어 키를 색인으로 하는 해시 테이블을 메모리에 만들고, 다른 쪽 테이블을 한 행씩 흘리면서 그 해시 테이블에서 짝을 찾는 방식이에요. 해시 테이블을 만드는 쪽을 빌드 사이드, 흘리면서 찾는 쪽을 프로브 사이드라고 부릅니다. 빌드 사이드는 통째로 메모리에 올라가니까 작아야 하고, 프로브 사이드는 흘러가기만 하니까 아무리 커도 됩니다. 이 글의 절반은 결국 누가 빌드 사이드가 되느냐 얘기입니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="해시 조인의 두 단계. 오른쪽 테이블을 전부 읽어 키로 색인한 해시 테이블을 만드는 빌드, 왼쪽 테이블을 한 행씩 흘리며 키로 조회해 짝을 찾는 프로브" src="https://nxtgen.co.kr/assets/images/03-c71e0a22ec1a6f110e13af08feb2ce90.png" width="1400" height="174" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">해시 조인의 두 단계. 오른쪽 테이블을 전부 읽어 키로 색인한 해시 테이블을 만드는 빌드, 왼쪽 테이블을 한 행씩 흘리며 키로 조회해 짝을 찾는 프로브</span></span></p>
<p>플래너는 SQL을 실행 계획으로 바꾸는 부품입니다. SQL에는 무엇을 원하는지만 적고 어떻게 구할지는 안 적잖아요. 테이블 여섯 개를 어떤 순서로 붙일지, 조인마다 어느 쪽을 빌드로 삼을지, 어떤 알고리즘을 쓸지를 플래너가 정합니다. 옵티마이저라고도 부르는데 이 글에서는 같은 뜻으로 씁니다. 좋은 플래너는 테이블 행 수와 컬럼 값 분포 같은 통계로 중간 결과가 몇 행이 될지 어림잡아서 제일 싼 순서를 고르는데, 그 어림값을 카디널리티 추정이라고 하고 그런 방식을 비용 기반이라고 합니다. 조인 순서를 적힌 대로 하나씩 붙이면 왼쪽 깊은 트리가 되고, 작은 것들끼리 먼저 묶어 두 덩어리를 붙이면 bushy 트리가 됩니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="플래너가 하는 일. SQL과 통계를 받아 조인 순서, 빌드 사이드, 알고리즘을 정하고 왼쪽 깊은 트리 또는 bushy 트리 형태의 계획을 실행 엔진에 넘긴다" src="https://nxtgen.co.kr/assets/images/04-254f993e748fb5d4afd7f0ff981608d0.png" width="1400" height="344" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">플래너가 하는 일. SQL과 통계를 받아 조인 순서, 빌드 사이드, 알고리즘을 정하고 왼쪽 깊은 트리 또는 bushy 트리 형태의 계획을 실행 엔진에 넘긴다</span></span></p>
<p>실행 엔진은 그 계획을 그대로 수행하는 쪽입니다. 벡터화, 병렬 스캔, 압축 같은 것들이 여기 속해요. 결국에는 같은 SQL이라도 플래너가 어떤 계획을 내느냐에 따라 실행 엔진이 할 일의 양이 100배씩 달라질 수 있다는 게 이 글의 뼈대입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="숫자부터">숫자부터<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%88%AB%EC%9E%90%EB%B6%80%ED%84%B0" class="hash-link" aria-label="숫자부터에 대한 직접 링크" title="숫자부터에 대한 직접 링크" translate="no">​</a></h2>
<p>SF10에서 세 엔진이 모두 완주한 21개 쿼리의 기하평균은 DuckDB 65ms, 26.8 129ms, 25.8 226ms였습니다. 기하평균은 쿼리마다의 배율을 곱해서 평균 낸 값이라 긴 쿼리 한두 개가 전체를 끌고 가지 않습니다. DuckDB가 25.8보다 3.5배, 26.8보다 2배 빠릅니다. 25.8은 Q21 하나를 12 GiB 안에서 끝내지 못했고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="TPC-H SF10과 SF100에서 세 엔진의 기하평균 실행시간. SF10은 DuckDB 65ms, ClickHouse 26.8 129ms, 25.8 226ms이고 SF100은 1.12초, 1.50초, 2.28초" src="https://nxtgen.co.kr/assets/images/05-39ece03fa2fd55798268539bc64f77e0.png" width="1400" height="529" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">TPC-H SF10과 SF100에서 세 엔진의 기하평균 실행시간. SF10은 DuckDB 65ms, ClickHouse 26.8 129ms, 25.8 226ms이고 SF100은 1.12초, 1.50초, 2.28초</span></span></p>
<p>SF100으로 올리면 그림이 되게 달라집니다. 25.8은 22개 중 9개를 완주하지 못했습니다. Q3, Q4, Q5, Q7, Q8, Q9, Q10, Q13, Q21. 전부 다중 조인이거나 상관 서브쿼리가 있는 것들입니다. 상관 서브쿼리는 EXISTS처럼 바깥 행마다 안쪽 질의를 다시 보는 구문인데, 엔진은 이걸 짝이 있는지만 확인하는 semi 조인이나 짝이 없는 것만 남기는 anti 조인으로 바꿔서 실행합니다. 26.8은 22개를 다 끝냈고 합계 67.8초, DuckDB는 56.2초였습니다. 그런데 쿼리별로 보면 26.8이 이기는 자리가 꽤 있어요. Q13은 26.8이 2.6초, DuckDB가 7.7초였고 Q21도 26.8이 빨랐습니다. 반대로 Q2, Q6, Q10 같은 것들은 DuckDB가 3-4배 빠릅니다. 결국에는 SF100에서 26.8과 DuckDB는 같은 급이고, 25.8은 따로 놓고 봐야 한다는 게 첫 번째 관찰입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="순서만-바꿨는데-110배">순서만 바꿨는데 110배<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%88%9C%EC%84%9C%EB%A7%8C-%EB%B0%94%EA%BF%A8%EB%8A%94%EB%8D%B0-110%EB%B0%B0" class="hash-link" aria-label="순서만 바꿨는데 110배에 대한 직접 링크" title="순서만 바꿨는데 110배에 대한 직접 링크" translate="no">​</a></h2>
<p>TPC-H 공식 쿼리는 ClickHouse 저장소에 이미 유리한 순서로 적혀 있습니다. 그래서 22개 표만 보면 플래너의 차이가 잘 안 보여요. 진짜 차이는 같은 쿼리를 다르게 적어봤을 때 나왔습니다.</p>
<p>Q5는 여섯 테이블 조인입니다. 이걸 세 가지 순서로 적었습니다. 공식 순서, lineitem을 맨 앞에 둔 순서, 그리고 region과 nation 같은 차원 테이블을 맨 앞에 둔 순서. 의미는 완전히 같은 SQL입니다. 25.8은 0.56초, 0.26초, 30.8초가 나왔습니다. 110배입니다. 26.8은 세 순서 모두 0.15초, DuckDB는 0.06초였고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="같은 Q5를 세 가지 FROM 순서로 적었을 때 세 엔진의 실행시간. 25.8만 순서에 따라 0.26초에서 30.8초로 갈리고 DuckDB에서 조인 순서 옵티마이저를 끄면 67.2초가 된다" src="https://nxtgen.co.kr/assets/images/06-b34cebe1214582cb0eb28405e934c70c.png" width="1400" height="653" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">같은 Q5를 세 가지 FROM 순서로 적었을 때 세 엔진의 실행시간. 25.8만 순서에 따라 0.26초에서 30.8초로 갈리고 DuckDB에서 조인 순서 옵티마이저를 끄면 67.2초가 된다</span></span></p>
<p>실행 계획을 열어보니 이유가 그대로 보였습니다. 25.8은 FROM 절에 적힌 순서 그대로 왼쪽 깊은 트리를 만듭니다. region, nation, supplier까지 붙인 다음 customer를 붙이는데, supplier와 customer는 nationkey로만 묶이니까 다대다 조인이 됩니다. 아시아 다섯 나라마다 supplier 4천 개에 customer 6만 명이 곱해져서 중간 결과가 수억 행이 되고, 그 다음에야 orders와 lineitem을 만나는 겁니다.</p>
<p>26.8은 통계를 보고 lineitem을 프로브 쪽에 두고 나머지를 작게 묶어 빌드 사이드로 넘겼고, DuckDB는 세 순서 모두 같은 물리 계획을 냈습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="25.8은 적힌 순서대로 왼쪽 깊은 트리를 만들어 supplier와 customer의 다대다 조인이 먼저 실행되고, 26.8과 DuckDB는 lineitem을 프로브로 두고 나머지를 작은 빌드 사이드로 묶는다" src="https://nxtgen.co.kr/assets/images/07-0149701c46d3638bb60e827a43d73650.png" width="1400" height="809" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">25.8은 적힌 순서대로 왼쪽 깊은 트리를 만들어 supplier와 customer의 다대다 조인이 먼저 실행되고, 26.8과 DuckDB는 lineitem을 프로브로 두고 나머지를 작은 빌드 사이드로 묶는다</span></span></p>
<p>25.8에도 조인 순서를 손보는 기능이 하나 있긴 합니다. 24.12에 들어온 2-테이블 스왑인데, 조인 하나에서 두 입력 중 행 수가 적어 보이는 쪽을 빌드로 삼도록 좌우만 바꿉니다. 실제로 lineitem과 orders 둘만 조인할 때 순서를 뒤집어 적어도 25.8은 같은 시간을 냈어요. 스왑을 끄면 lineitem 6천만 행이 해시 테이블에 올라가서 0.18초가 0.69초로, 메모리는 1 GiB에서 4.2 GiB로 뜁니다. 그런데 이 스왑은 조인 하나의 좌우만 볼 뿐 여섯 테이블의 순서는 건드리지 않습니다. 3개 이상 테이블의 리오더링은 25.9에서야 들어왔고[1] 25.8에는 그 설정 자체가 없습니다. 결국에는 25.8 사용자는 FROM 절 순서를 성능 튜닝 항목으로 관리해야 한다는 뜻입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="duckdb의-옵티마이저를-꺼봤다">DuckDB의 옵티마이저를 꺼봤다<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#duckdb%EC%9D%98-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EB%A5%BC-%EA%BA%BC%EB%B4%A4%EB%8B%A4" class="hash-link" aria-label="DuckDB의 옵티마이저를 꺼봤다에 대한 직접 링크" title="DuckDB의 옵티마이저를 꺼봤다에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 하나 더 해보고 싶은 게 생겼습니다. DuckDB가 빠른 게 실행 엔진이 좋아서인지, 플랜을 잘 짜서인지. DuckDB에는 조인 순서 옵티마이저와 빌드 사이드 선택을 끄는 설정이 있습니다[2]. 끄면 적힌 순서대로 왼쪽 깊은 트리를 만드니까 25.8과 같은 조건이 됩니다.</p>
<p>같은 j07c를 돌렸더니 67.2초가 나왔습니다. 25.8의 30.8초보다 두 배 느립니다. 두 테이블 조인의 순서를 뒤집은 것도 0.22초에서 0.36초로, 메모리는 0.2 GiB에서 2.4 GiB로 갔고요. 25.8이 겪는 일을 DuckDB도 똑같이 겪고, 심지어 더 심하게 겪는 겁니다.</p>
<p>이게 이번 벤치에서 되게 중요한 결과였습니다. 조인 성능을 정하는 건 실행 엔진이 아니라 플래너다. 나쁜 플랜을 실행하는 능력은 오히려 ClickHouse가 낫습니다. DuckDB가 빠른 이유는 좋은 플랜을 스스로 찾기 때문이고, 25.8에서는 그 플랜을 사용자가 SQL 작성 순서로 정해 줘야 했던 겁니다. 그리고 그 몫이 26.x에서 플래너로 넘어갔습니다. 분석 엔진에서 플래너가 곧 제품인 거죠.</p>
<p>26.8에서도 같은 실험을 했습니다. 리오더링만 끄면 j07c가 0.15초에서 14.3초로 돌아갑니다. 런타임 필터까지 끄고 스왑까지 끄면 Q9가 1.30초에 4.9 GiB로, 25.8 기본값의 1.28초에 6.5 GiB와 거의 같아집니다. 26.8이 빨라진 만큼이 정확히 플래너 기능 세 개의 합이라는 얘기입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="해시-테이블과-메모리-상한">해시 테이블과 메모리 상한<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%ED%95%B4%EC%8B%9C-%ED%85%8C%EC%9D%B4%EB%B8%94%EA%B3%BC-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%83%81%ED%95%9C" class="hash-link" aria-label="해시 테이블과 메모리 상한에 대한 직접 링크" title="해시 테이블과 메모리 상한에 대한 직접 링크" translate="no">​</a></h2>
<p>플래너 다음은 메모리입니다. ClickHouse의 hash 조인은 오른쪽 테이블을 전부 읽어 RAM에 해시 테이블을 만들고 왼쪽을 흘려보내며 찾습니다. 오른쪽이 메모리에 안 들어가면 25.8은 메모리 초과 오류로 멈춥니다. SF100에서 lineitem과 orders를 한 번 조인하는 가장 단순한 쿼리도 25.8은 12 GiB 안에서 못 끝냈습니다. orders 1억5천만 행의 해시 테이블이 안 들어가는 겁니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/08-c2842134e3bb8184ed150ec5bb6e7b33.png" width="2100" height="900" class="zoomable_B8z7 img_ev3q"></p>
<p>26.8은 끝내긴 했는데 20.4초가 걸렸습니다. 스필은 메모리에 다 못 올리는 데이터를 디스크에 임시로 내려놓고 나눠서 처리하는 걸 말합니다. grace hash 조인은 그걸 조인에 적용한 방식이에요. 키 해시로 양쪽 테이블을 버킷 몇 개로 나눠서 메모리에 들어가는 버킷은 바로 조인하고 나머지는 디스크에 썼다가 버킷 하나씩 다시 읽어 처리합니다. 디스크를 오가는 만큼 느려지지만 오류 대신 답이 나옵니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="스필의 원리. 해시 테이블이 상한을 넘으면 키 해시로 버킷을 나눠 일부는 메모리에서 바로 조인하고 나머지는 디스크에 썼다가 버킷별로 처리한다" src="https://nxtgen.co.kr/assets/images/09-fd8584576c4efab70d123e021ef1c62e.png" width="1400" height="257" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">스필의 원리. 해시 테이블이 상한을 넘으면 키 해시로 버킷을 나눠 일부는 메모리에서 바로 조인하고 나머지는 디스크에 썼다가 버킷별로 처리한다</span></span></p>
<p>26.5부터 오른쪽 데이터가 가용 메모리의 절반을 넘으면 grace hash로 바꿔 디스크로 내리는 경로가 생겼고[1], 이 쿼리가 그 경로를 탄 겁니다. 같은 조인을 DuckDB는 4.4초에 인메모리로 끝냈습니다. 해시 테이블 피크가 1.83 GiB였어요. 키 하나만 필요한 1억5천만 행에 ClickHouse는 6 GiB 넘게 쓰니까 행당 40바이트 이상이고, DuckDB는 행당 13바이트쯤입니다. 행당 오버헤드가 세 배 차이 나면 스필 임계에 닿는 시점도 세 배 앞당겨집니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="조인 하나를 SF1에서 SF100까지 키웠을 때 세 엔진의 실행시간. SF30까지는 26.8이 가장 빠르지만 SF100에서 20초로 꺾이고 25.8은 12 GiB 상한을 넘는다" src="https://nxtgen.co.kr/assets/images/10-52d715a45a431506b0db9f8b3b93fbea.png" width="1400" height="735" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">조인 하나를 SF1에서 SF100까지 키웠을 때 세 엔진의 실행시간. SF30까지는 26.8이 가장 빠르지만 SF100에서 20초로 꺾이고 25.8은 12 GiB 상한을 넘는다</span></span></p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="오른쪽 테이블이 RAM 해시 테이블에 다 안 들어갈 때 세 엔진의 갈림길. 25.8은 메모리 초과 오류, 26.8은 grace hash 스필, DuckDB는 파티션 단위 스필" src="https://nxtgen.co.kr/assets/images/11-488beabeb508173e17a09578f064cd02.png" width="1400" height="523" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">오른쪽 테이블이 RAM 해시 테이블에 다 안 들어갈 때 세 엔진의 갈림길. 25.8은 메모리 초과 오류, 26.8은 grace hash 스필, DuckDB는 파티션 단위 스필</span></span></p>
<p>상한을 4 GiB로 더 조이면 차이가 완주 여부로 나옵니다. SF100에서 조인 쿼리 넷을 돌렸는데 ClickHouse는 두 버전 모두 넷 다 상한 안에서 끝내지 못했고, DuckDB는 넷 다 끝냈습니다. j01이 15.8초로 12 GiB 때의 3.6배가 걸리면서 4.4 GiB를 디스크로 내렸고, 나머지 셋은 1.2-1.5배 안에서 끝났어요. 결국에는 메모리가 조인 입력보다 작아지면 ClickHouse는 설정을 다시 잡으라는 신호를 주고 DuckDB는 느려진 답을 줍니다. 배치 리포트라면 후자가 편하고, 지연 시간이 중요한 서빙이라면 어느 쪽이든 메모리를 맞추거나 미리 집계하는 게 답입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="grace_hash를-쓸-때-알아야-할-것">grace_hash를 쓸 때 알아야 할 것<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#grace_hash%EB%A5%BC-%EC%93%B8-%EB%95%8C-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EA%B2%83" class="hash-link" aria-label="grace_hash를 쓸 때 알아야 할 것에 대한 직접 링크" title="grace_hash를 쓸 때 알아야 할 것에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 좀 헤맸습니다. 문서에는 grace_hash가 메모리를 묶는 알고리즘이라고 돼 있는데, 25.8에서 알고리즘만 바꾸니까 기본값과 정확히 같은 지점에서 멈추더라고요. 내가 뭘 잘못 걸었나 싶었죠. 알고 보니 grace_hash는 max_bytes_in_join을 넘을 때만 버킷을 늘려 디스크로 내리는데 그 기본값이 0, 그러니까 무제한이었습니다. 무제한이니 스필할 일이 없고, 그 사이에 max_memory_usage에 먼저 걸리는 겁니다.</p>
<p>max_bytes_in_join을 1 GiB로 주니까 j01은 2 GiB 상한에서도 완주했습니다. 대신 1.8초로, parallel_hash가 여유 있을 때 낸 0.26초의 7배입니다. Q9는 4 GiB에서 11초가 걸렸는데 26.8이 같은 상한에서 0.29초니까 38배입니다. Q21은 어떤 설정으로도 못 끝냈습니다. grace_hash가 INNER와 OUTER 조인만 지원하고, EXISTS나 NOT EXISTS가 풀려서 나오는 semi, anti 조인은 hash로 되돌아가기 때문입니다.</p>
<p>말이 좀 돌아갔는데, 제일 효과가 좋았던 건 알고리즘이 아니라 SQL이었습니다. SF30에서 25.8이 상한 안에서 끝내지 못하던 Q5를 grace_hash로 살리면 25초에서 32초가 걸리는데, FROM 절을 lineitem부터 시작하게 고쳐 적으니 기본 알고리즘으로 1.1초에서 2.2초에 끝났습니다. 메모리도 1.3 GiB였고요. 결국에는 25.8에서는 사용자가 플래너 역할의 일부를 맡아야 하고, 그 몫이 26.x에서 엔진 안으로 들어갔습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="268에서-무엇이-달라졌나">26.8에서 무엇이 달라졌나<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#268%EC%97%90%EC%84%9C-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%EB%9D%BC%EC%A1%8C%EB%82%98" class="hash-link" aria-label="26.8에서 무엇이 달라졌나에 대한 직접 링크" title="26.8에서 무엇이 달라졌나에 대한 직접 링크" translate="no">​</a></h2>
<p>ClickHouse 팀은 정확히 이 지점을 2년에 걸쳐 바꿔 왔습니다. 바이너리 안의 설정 변경 이력을 뽑아보면 순서가 그대로 보여요. 24.12에 parallel_hash가 기본이 되고 2-테이블 스왑이 들어왔고, 25.9에 다중 테이블 리오더링, 26.2에 런타임 조인 필터, 26.3과 26.4에 컬럼 통계와 자동 수집, 26.5에 hash 조인 스필, 26.8에 INSERT 시점 통계 materialize가 붙었습니다. 공식 블로그는 이 과정을 조인에 빠른 ClickHouse가 되기까지의 2년으로 정리하고 있고[1], TPC-H SF100에서 25.4 대비 26.4가 6배 빨라졌다고 씁니다. 제 측정과 방향이 같습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="24.12부터 26.8까지 ClickHouse 조인 관련 기본값이 바뀐 순서. 25.8까지가 옛 조인 경로이고 25.9부터 리오더링, 런타임 필터, 통계, 스필이 차례로 기본이 됐다" src="https://nxtgen.co.kr/assets/images/12-897738ebaaf0be40b907e8428df3698f.png" width="1400" height="504" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">24.12부터 26.8까지 ClickHouse 조인 관련 기본값이 바뀐 순서. 25.8까지가 옛 조인 경로이고 25.9부터 리오더링, 런타임 필터, 통계, 스필이 차례로 기본이 됐다</span></span></p>
<p>몇 가지는 직접 보고 나서야 이해가 됐습니다. 통계가 하는 일은 lineitem에만 필터가 있고 orders에는 없는 조인에서 보였어요. 통계가 없으면 플래너가 아는 건 테이블 행 수뿐이라, 6천만 행짜리 lineitem보다 1천5백만 행짜리 orders가 작다고 보고 orders를 빌드로 잡습니다. 26.8은 l_shipdate 값 분포로 필터 후 lineitem이 71만 행이라고 추정해 그쪽을 빌드로 삼았습니다. 25.8은 같은 쿼리에서 orders를 올려 1.2 GiB를 썼고 26.8은 0.04 GiB였습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="통계 유무에 따른 빌드 사이드 선택. 통계가 없으면 테이블 행 수만 보고 orders를 해시 테이블로 올리고, 통계가 있으면 필터 후 lineitem이 71만 행임을 추정해 그쪽을 올린다" src="https://nxtgen.co.kr/assets/images/13-55016e669c777ad78919dedb87d6a72e.png" width="1400" height="631" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">통계 유무에 따른 빌드 사이드 선택. 통계가 없으면 테이블 행 수만 보고 orders를 해시 테이블로 올리고, 통계가 있으면 필터 후 lineitem이 71만 행임을 추정해 그쪽을 올린다</span></span></p>
<p>런타임 필터는 빌드가 끝난 뒤에 생기는 정보를 프로브 쪽에 돌려주는 장치입니다. 해시 테이블을 다 만들면 오른쪽에 어떤 키가 있는지 알게 되니까, 그 키 집합을 블룸 필터로 압축해서 왼쪽 테이블을 읽을 때 집합에 없는 행은 조인까지 가기 전에 버립니다. 차원 테이블을 필터해서 2천 개만 남았는데 팩트 테이블 6천만 행을 전부 조인에 밀어 넣을 이유가 없거든요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="런타임 필터의 원리. 빌드가 끝난 해시 테이블의 키 집합으로 블룸 필터를 만들고, 프로브 테이블을 스캔하면서 집합에 없는 행은 조인 전에 버린다" src="https://nxtgen.co.kr/assets/images/14-4b79dffea2016157486c851c1419e35e.png" width="1400" height="252" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">런타임 필터의 원리. 빌드가 끝난 해시 테이블의 키 집합으로 블룸 필터를 만들고, 프로브 테이블을 스캔하면서 집합에 없는 행은 조인 전에 버린다</span></span></p>
<p>예상과 좀 달랐던 건 읽는 행 수 자체는 두 버전이 같았다는 점입니다. 26.8의 런타임 필터는 기본 설정에서 읽은 뒤 파이프라인 안에서 거르고 스토리지 단계의 프루닝은 꺼져 있거든요. 대신 조인 연산자에 닿는 행이 줄어서 Q5의 메모리가 1.4 GiB에서 0.06 GiB로 내려갔습니다. DuckDB는 같은 정보를 zonemap 프루닝까지 연결해서 읽는 양을 줄입니다. zonemap은 저장 블록마다 컬럼의 최소값과 최대값을 적어 둔 것들이라, 찾는 키가 그 범위 밖이면 블록을 읽지도 않습니다[3].</p>
<p>아직 손볼 자리도 있었습니다. NOT EXISTS anti 조인은 SF100에서 25.8이 2.9초, DuckDB가 5.1초인데 26.8은 17초가 걸렸습니다. 1억5천만 그룹을 만드는 GROUP BY도 26.8이 8.5초로 25.8의 4.7초보다 느렸고요. 새 플래너가 모든 자리에서 이기는 건 아니라는 것들을 이런 데서 봅니다. 그래도 큰 그림은 분명합니다. 조인에 관한 한 26.8은 25.8과 다른 엔진이죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="집계에서는-clickhouse의-강점이-그대로다">집계에서는 ClickHouse의 강점이 그대로다<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%A7%91%EA%B3%84%EC%97%90%EC%84%9C%EB%8A%94-clickhouse%EC%9D%98-%EA%B0%95%EC%A0%90%EC%9D%B4-%EA%B7%B8%EB%8C%80%EB%A1%9C%EB%8B%A4" class="hash-link" aria-label="집계에서는 ClickHouse의 강점이 그대로다에 대한 직접 링크" title="집계에서는 ClickHouse의 강점이 그대로다에 대한 직접 링크" translate="no">​</a></h2>
<p>조인이 없는 쿼리에서는 ClickHouse가 원래 잘하던 것들이 그대로 나옵니다. SF100에서 count(DISTINCT) 두 개는 ClickHouse가 2.4초, DuckDB가 10.1초였고, GROUP BY l_suppkey 상위 10개는 0.9초 대 6.8초였습니다. DuckDB는 집계 해시 테이블이 12 GiB 상한까지 차올라 스필했더라고요. LIKE로 comment 컬럼을 훑는 것도 ClickHouse가 두 배 빠릅니다. 반대로 행마다 같은 그룹 안의 순번을 매기는 윈도우 함수와 1억5천만 그룹짜리 GROUP BY 같은 것들은 DuckDB가 앞섰고요.</p>
<p>집계 워크로드에서는 어느 한쪽이 일방적으로 우세하지 않고, 조인처럼 한쪽이 완주조차 못 하는 일도 없었습니다. 결국에는 두 엔진이 같은 데이터를 비슷한 속도로 읽고, 조인에서만 플래너와 메모리 구조 때문에 갈리는 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결국에는-어떻게-고를-것인가">결국에는 어떻게 고를 것인가<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EA%B2%B0%EA%B5%AD%EC%97%90%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B3%A0%EB%A5%BC-%EA%B2%83%EC%9D%B8%EA%B0%80" class="hash-link" aria-label="결국에는 어떻게 고를 것인가에 대한 직접 링크" title="결국에는 어떻게 고를 것인가에 대한 직접 링크" translate="no">​</a></h2>
<p>제가 정리한 기준은 이렇습니다.</p>
<p>고객이 ClickHouse 25.8 이하를 쓰고 있고 조인이 느리다고 하면, 쿼리 튜닝보다 26.4 이상으로 올리는 게 먼저입니다.</p>
<p>통계, 리오더링, 런타임 필터, 스필이 전부 기본값으로 따라옵니다. 올릴 수 없는 환경이면 작은 테이블을 오른쪽에 두고, 필터는 서브쿼리로 미리 걸고, EXPLAIN PIPELINE에서 FillingRightJoinSide가 어느 테이블에 붙는지 확인하는 것들을 손으로 해야 합니다. grace_hash를 쓸 거면 max_bytes_in_join을 같이 잡고, semi나 anti 조인에는 안 먹힌다는 걸 알고 써야 하고요.</p>
<p>정규화 스키마 위에서 애드혹으로 여러 테이블을 붙이는 노트북 작업이라면 DuckDB가 손이 덜 갑니다. 순서를 어떻게 적어도 같은 시간이 나오고, 메모리를 넘어도 느려질지언정 답은 나옵니다. 서버로 적재하고 서빙하는 워크로드는 ClickHouse 26.x가 조인까지 같이 감당하고, 비정규화한 wide 테이블과 딕셔너리, 머티리얼라이즈드 뷰로 조인을 줄이는 설계와 같이 쓰면 원래의 강점이 더 살아납니다.</p>
<p>가장 크게 남은 건 이겁니다. 엔진을 고를 때 조인이 빠르냐를 물을 게 아니라, 플래너가 내 대신 순서를 정해주느냐를 물어야 합니다. 그 답이 버전 하나 차이로 갈리는 걸 이번에 봤고, 그래서 고객 환경에 들어갈 때 제일 먼저 확인하는 게 버전이 됐죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/09/join-performance-is-the-planner#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] ClickHouse, How ClickHouse became fast at joins (2026-06-03). 24.12의 parallel hash 기본화와 로컬 리오더링, 26.4의 자동 통계와 통계 기반 리오더링, TPC-H SF100 배율이 정리돼 있습니다. <a href="https://clickhouse.com/blog/clickhouse-fast-joins" target="_blank" rel="noopener noreferrer" class="">https://clickhouse.com/blog/clickhouse-fast-joins</a>. 25.9의 다중 테이블 리오더링 도입은 공식 가이드 Joining Tables에 적혀 있습니다. <a href="https://clickhouse.com/docs/guides/joining-tables" target="_blank" rel="noopener noreferrer" class="">https://clickhouse.com/docs/guides/joining-tables</a>. 버전별 설정 변경 이력은 각 버전 바이너리의 system.settings_changes 테이블에서 직접 뽑았습니다.</p>
<p>[2] DuckDB 성능 가이드의 Join Operations. disabled_optimizers에 join_order와 build_side_probe_side를 주면 적힌 순서대로 왼쪽 깊은 트리를 만듭니다. <a href="https://duckdb.org/docs/current/guides/performance/join_operations.html" target="_blank" rel="noopener noreferrer" class="">https://duckdb.org/docs/current/guides/performance/join_operations.html</a>. 조인 순서 최적화 알고리즘은 DPhyp이며 DuckDB Internals 문서에 있습니다. <a href="https://duckdb.org/docs/current/internals/overview.html" target="_blank" rel="noopener noreferrer" class="">https://duckdb.org/docs/current/internals/overview.html</a></p>
<p>[3] DuckDB 블로그, Optimizers: The Low-Key MVP (2024-11-14). 빌드 사이드 키의 min/max를 프로브 스캔에 밀어 넣는 조인 필터 푸시다운을 설명합니다. <a href="https://duckdb.org/2024/11/14/optimizers.html" target="_blank" rel="noopener noreferrer" class="">https://duckdb.org/2024/11/14/optimizers.html</a></p>
<p>[4] 벤치의 스키마와 22개 쿼리는 ClickHouse 저장소의 tests/benchmarks/tpc-h(ClickHouse용)와 DuckDB tpch 확장(DuckDB용)을 그대로 썼습니다. 환경은 Apple M4 Pro, Docker Desktop 4.61, 컨테이너당 --cpus=8 --cpuset-cpus=0-7 --memory=16g, 쿼리 메모리 상한 12 GiB, 워밍업 1회 후 3회 측정 중앙값(SF100은 2회)입니다.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[FDE에게 필요한 건 프롬프트가 아니라 해체하는 능력이다]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts</guid>
            <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[에이전트를 만들 때 프롬프트보다 오래 걸리는 일은 고객이 덩어리로 준 것을 어디까지 쪼갤지 정하는 일입니다. 데리다의 해체를 빌려 그 과정을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-a1554ef7900f4efa413928ca87a89cb6.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<blockquote>
<p>에이전트를 만들다 보면, 프롬프트를 쓰는 시간보다 고객이 덩어리로 준 것들을 어디까지 쪼갤지 정하는 시간이 훨씬 깁니다. 그 쪼개기가 데리다가 말한 해체와 되게 닮아 있어서, deconstruction이라는 말을 빌려 정리해 봤습니다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="해체라는-말을-빌린-이유">해체라는 말을 빌린 이유<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%ED%95%B4%EC%B2%B4%EB%9D%BC%EB%8A%94-%EB%A7%90%EC%9D%84-%EB%B9%8C%EB%A6%B0-%EC%9D%B4%EC%9C%A0" class="hash-link" aria-label="해체라는 말을 빌린 이유에 대한 직접 링크" title="해체라는 말을 빌린 이유에 대한 직접 링크" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/02-ca6c7432884f384418da9adc7f6b2f09.jpg" width="600" height="426" class="zoomable_B8z7 img_ev3q"></p>
<p>데리다의 해체주의 얘기로 시작하겠습니다. 어떤 글이나 이론을 아주 꼼꼼히 읽어서, 그 글이 스스로 세운 규칙을 자기도 모르게 어기는 지점을 찾아내는 겁니다. 부수는 게 아니라, 이미 안에 있던 균열을 드러내는 거죠. 그리고 순서를 뒤집는 데서 멈추지 않습니다. 말이 글보다 앞선다는 위계를 뒤집어서 "글이 말보다 낫다"로 끝내면 그것도 또 다른 위계가 되니까, 둘로 나누는 틀 자체를 흔듭니다.[1]</p>
<p>판정 에이전트를 몇 번 만들고 나니 이게 남의 얘기가 아니더라고요. 고객이 준 규정을 꼼꼼히 읽어서 규정이 스스로 어기는 지점을 찾아내고, 새로 설계하는 게 아니라 이미 안에 있던 경계를 따라 뜯어내고, 모델에서 코드로 옮기는 데서 멈추지 않고 코드와 모델을 둘로 나누는 틀까지 흔드는 것. 판정 에이전트를 만들 때 하는 일이 정확히 이 세 동작이었습니다.</p>
<p><a class="" href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role">지난 글</a>에서 FDE는 혼자 다 하는 사람이 아니라고 썼는데요, 이번엔 FDE 한 사람한테 무슨 능력이 필요한가 얘기입니다. 핵심 주장을 먼저 던지면 이겁니다. FDE에게 필요한 건 프롬프트를 잘 쓰는 능력이 아니라 고객이 덩어리로 준 것을 판정 가능한 단위까지 해체하는 능력이다. 공학에서는 decomposition이라고 부르지만 굳이 deconstruction이라고 쓰는 이유가 있어요. 처음부터 설계해서 나누는 게 아니라 이미 돌아가고 있던 덩어리를 뜯어서 다시 나누는 일이고, 뜯는 자리를 우리가 정하는 게 아니라 규정 안에 있던 균열이 정하거든요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="고객은-덩어리로-준다">고객은 덩어리로 준다<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EA%B3%A0%EA%B0%9D%EC%9D%80-%EB%8D%A9%EC%96%B4%EB%A6%AC%EB%A1%9C-%EC%A4%80%EB%8B%A4" class="hash-link" aria-label="고객은 덩어리로 준다에 대한 직접 링크" title="고객은 덩어리로 준다에 대한 직접 링크" translate="no">​</a></h2>
<p>고객은 항상 덩어리로 줍니다. 규정집 몇 권, 양식 몇십 장, 그리고 규정대로 검토해 주는 AI라는 한 문장. 담당자 입장에서는 그게 전부예요. 그분들 머릿속에서 규정은 나눠져 있지 않거든요. 몇 년 동안 손으로 검토하면서 몸에 밴 하나의 감각이죠. 그런데 에이전트는 그 감각을 그대로 못 받습니다. 규정을 항목으로, 항목을 판정 주체로, 입력을 건 단위로 쪼개서 넘겨줘야 돌아가요.</p>
<p>이 쪼개기를 누가 하느냐가 문제입니다. 도메인 담당자는 코드와 모델의 경계를 모르고, 개발팀은 규정을 모르죠. 둘 사이에 들어가 있는 사람이 FDE고, 그래서 이 일이 FDE한테 떨어집니다. 결국에는 무엇을 쪼갤지, 어디까지 쪼갤지, 어디서 멈출지를 정하는 게 프로젝트의 거의 전부더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="프롬프트-하나로-시작하면-왜-안-되는가">프롬프트 하나로 시작하면 왜 안 되는가<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%ED%95%98%EB%82%98%EB%A1%9C-%EC%8B%9C%EC%9E%91%ED%95%98%EB%A9%B4-%EC%99%9C-%EC%95%88-%EB%90%98%EB%8A%94%EA%B0%80" class="hash-link" aria-label="프롬프트 하나로 시작하면 왜 안 되는가에 대한 직접 링크" title="프롬프트 하나로 시작하면 왜 안 되는가에 대한 직접 링크" translate="no">​</a></h2>
<p>첫 버전은 대개 되게 단순합니다. 기준 항목을 몇 개씩 묶어서 모델한테 던지고 자연어로 판정을 받는 구조죠. 결과가 그럴듯하게 나와요. 그런데 문제가 바로 그 그럴듯함입니다.</p>
<p>어떤 판정 작업이든 뜯어보면 상당 부분은 산술이고 룩업이고 패턴입니다. 비율이 한도 안인지는 숫자 두 개를 나누면 끝나죠. 코드나 번호가 맞는지는 표 하나 뒤지면 되고, 형식은 정규식 한 줄입니다. 규정 검증 같은 도메인에서는 이런 항목이 절반 가까이 되는데, 첫 버전에서는 이걸 전부 모델이 자연어로 판정하고 있어요. 같은 입력을 두 번 돌리면 문구가 달라지고, 가끔은 판정도 달라집니다. 확신에 찬 오답이죠.</p>
<p>한 덩어리라서 생기는 문제가 하나 더 있어요. 틀렸을 때 어디가 틀렸는지 모릅니다. 규정을 잘못 읽은 건지, 문서에서 값을 잘못 뽑은 건지, 비교를 잘못한 건지가 한 번의 호출 안에 섞여 있으니까요.</p>
<p>그런데도 처음에 아무도 안 쪼개는 이유가 있어요. 프롬프트 하나가 제일 빠르고, 데모가 제일 잘 나오거든요. 쪼개기는 항상 데모 다음에, 실제 문서 수백 건이 들어오고 나서 시작됩니다. 결국에는 여기서부터 쪼개기가 시작됩니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="기준을-원자로">기준을 원자로<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EA%B8%B0%EC%A4%80%EC%9D%84-%EC%9B%90%EC%9E%90%EB%A1%9C" class="hash-link" aria-label="기준을 원자로에 대한 직접 링크" title="기준을 원자로에 대한 직접 링크" translate="no">​</a></h2>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="실제 기준을 통합하고 아이템화 하는 화면" src="https://nxtgen.co.kr/assets/images/03-aabb1e2802200b2525585d9c1a98891f.png" width="2963" height="1123" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">실제 기준을 통합하고 아이템화 하는 화면</span></span></p>
<p><em>실제 기준을 통합하고 아이템화 하는 화면</em></p>
<p>가장 먼저 쪼갤 건 기준입니다. 규정이든 정책이든 체크리스트든, 원본 문서를 항목 하나하나로 나누고 번호를 붙여요. 번호가 곧 주소입니다. 이후 모든 룰, 모든 결과 카드, 모든 고객 피드백이 이 번호로 대화하게 되죠. 데리다식으로 말하면 꼼꼼히 읽기가 여기서 시작됩니다. 통째로 읽을 때는 안 보이던 것들이 항목 단위로 읽으면 보이거든요. 두 항목이 같은 말을 다르게 정의하고 있다든지, 한 항목이 다른 항목의 전제를 어기고 있다든지.</p>
<p>사소해 보이는데 되게 중요합니다. 주소가 없으면 고객이 준 의견을 코드의 어느 룰에 반영해야 하는지 매번 다시 찾아야 하거든요. 검수 의견이 항목 번호로 돌아오면 수백 건이라도 룰과 자동으로 대조할 수 있어요. 기준 문서가 둘 이상이라 충돌하는 항목이 있으면 더 엄격한 쪽을 택한다는 식의 규칙 하나를 고객과 먼저 정해 두는 게 좋아요. 결국에는 기준이 원자가 돼야 그 위의 모든 것들이 원자 단위로 움직이고, 고객과도 같은 번호로 말할 수 있습니다.</p>
<p>FDE 입장에서 이 단계는 표 하나를 만드는 일이에요. 항목 번호, 원문, 출처 페이지. 이 표가 이후 모든 작업의 원장이 됩니다. 뒤의 컬럼들은 다음 단계에서 하나씩 채워져요.</p>
<table><thead><tr><th>번호</th><th>항목 원문 (요약)</th><th>출처</th><th>레인</th><th>시점</th><th>판정 주체</th></tr></thead><tbody><tr><td>R-12</td><td>인건비는 총사업비의 25% 이내</td><td>지침 p.14</td><td>추출 종속</td><td>사전, 사후</td><td>코드</td></tr><tr><td>R-37</td><td>사업번호는 연도-과제-항목 형식</td><td>지침 p.3</td><td>결정론</td><td>사전</td><td>코드</td></tr><tr><td>R-58</td><td>참여 대상은 XXX에 한함</td><td>지침 p.21</td><td>자격</td><td>사전</td><td>모델, 게이트 선행</td></tr><tr><td>R-104</td><td>용역 계약에는 견적서 2부 이상</td><td>매뉴얼 p.40</td><td>절차</td><td>사후</td><td>모델</td></tr><tr><td>R-131</td><td>지출 목적이 사업 취지에 부합</td><td>매뉴얼 p.9</td><td>의미</td><td>사후</td><td>모델</td></tr><tr><td>R-140</td><td>승인 경로는 예산 규모에 따름</td><td>지침 p.30</td><td>안내</td><td>사전</td><td>사람</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="누가-판정하는가를-가르기">누가 판정하는가를 가르기<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EB%88%84%EA%B0%80-%ED%8C%90%EC%A0%95%ED%95%98%EB%8A%94%EA%B0%80%EB%A5%BC-%EA%B0%80%EB%A5%B4%EA%B8%B0" class="hash-link" aria-label="누가 판정하는가를 가르기에 대한 직접 링크" title="누가 판정하는가를 가르기에 대한 직접 링크" translate="no">​</a></h2>
<p>다음엔 항목 하나하나에 누가 판정하는가를 붙입니다. 저희는 이걸 레인이라고 불러요. 항목의 성격을 뜯어보면 대개 다섯 갈래가 나옵니다.</p>
<p>결정론 레인은 산술, 룩업, 정규식으로 끝나는 것들입니다. 비율 한도, 코드 일치, 형식 패턴이 여기 들어가죠. 추출 종속 레인은 비교 자체는 코드지만 비교할 값을 문서에서 모델이 뽑아야 하는 것들이에요. 자격 레인은 사람이나 대상이 조건에 맞는지 보는 항목, 절차 레인은 필수 서류가 갖춰졌는지 보는 항목, 의미 레인은 목적에 맞는 지출인지 같은 판단이 필요한 항목입니다. 앞의 둘은 코드가 판정하고 뒤의 셋은 모델이 판정합니다.</p>
<p>이렇게 나누면 코드가 판정하는 항목이 절반 가까이 나오고 모델 호출은 수십 분의 일로 줄어요. 그런데 호출 수보다 중요한 건 재현성입니다. 코드 레인은 같은 입력에 항상 같은 답을 내니까, 판정이 흔들리면 그건 모델 레인이거나 추출 쪽 문제라는 게 바로 좁혀지죠.</p>
<p>레인 앞에는 게이트가 둘 있습니다. 이 문서 종류에 이 항목이 해당하는가, 그리고 이 문서의 조건에서 이 항목이 적용되는가. 해당없음이라는 판정을 처음부터 넣어 두지 않으면, 모델이 항목을 건너뛸 때마다 조건부나 미비로 떨어져서 오탐이 쌓입니다.</p>
<p>레인은 고정이 아니에요. 검수를 거치면서 옮겨 다닙니다. 절차 레인은 사전 단계에서 비고 사후로 몰리고, 판정하지 않고 사람한테 문구만 띄우는 안내 레인이 새로 생기기도 하죠. 결국에는 레인이라는 게 항목마다 코드, 모델, 사람 중 누구한테 보낼지 적어 둔 라우팅 표고, 그 표를 고객과 같이 고쳐 나가는 게 FDE의 일이더라고요. 앞에서 만든 원장에 레인 컬럼 하나를 더하는 거예요.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/04-c73be4738ec270195a3fde1f43402579.png" width="2352" height="2082" class="zoomable_B8z7 img_ev3q"></p>
<p>표로 놓으면 이렇습니다. 실무에서 제일 자주 보는 컬럼은 오른쪽 둘이에요.</p>
<table><thead><tr><th>레인</th><th>무엇을 보는가</th><th>판정 주체</th><th>같은 입력에 같은 답이 나오나</th><th>값을 못 얻으면</th></tr></thead><tbody><tr><td>결정론</td><td>산술, 룩업, 형식</td><td>코드</td><td>항상</td><td>미비</td></tr><tr><td>추출 종속</td><td>비교는 코드, 비교할 값은 문서에서 추출</td><td>코드, 값은 모델</td><td>추출이 같으면</td><td>미비</td></tr><tr><td>자격</td><td>대상이 조건에 맞는가</td><td>모델, 게이트 선행</td><td>대체로</td><td>미비</td></tr><tr><td>절차</td><td>필수 서류가 있는가</td><td>모델, 파일명은 코드</td><td>대체로</td><td>미비</td></tr><tr><td>의미</td><td>목적과 취지에 맞는가</td><td>모델</td><td>흔들림</td><td>미비</td></tr><tr><td>안내</td><td>판정하지 않음, 조건 발생 시 문구만</td><td>사람</td><td>해당 없음</td><td>표시 안 함</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="추출과-판정을-가르기">추출과 판정을 가르기<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EC%B6%94%EC%B6%9C%EA%B3%BC-%ED%8C%90%EC%A0%95%EC%9D%84-%EA%B0%80%EB%A5%B4%EA%B8%B0" class="hash-link" aria-label="추출과 판정을 가르기에 대한 직접 링크" title="추출과 판정을 가르기에 대한 직접 링크" translate="no">​</a></h2>
<p>그 다음이 추출과 판정의 분리입니다. 판정에 앞서 입력에서 값을 뽑아 구조화된 팩트시트로[2] 모아 두고, 룰은 그 팩트시트만 읽게 하는 거죠. 추출은 입력당 한 번, 판정은 룰마다 한 번. 이렇게 하면 모델은 값을 읽는 데만 쓰이고 비교는 코드가 합니다.</p>
<p>여기서 한 번 더 쪼개야 하는 게 결정론이라는 말 자체예요. 비율 한도는 비교만 결정론입니다. 분자와 분모를 문서에서 뽑아내는 건 100% 모델이죠. 추출 종속 레인이 따로 있는 이유가 이겁니다. 그래서 결정론을 판정 결정론과 추출 종속으로 나누고, 코드가 판정했다고 해서 컨피던스 1.0을 주지 않게 막아야 해요.</p>
<p>컨피던스는 판정의 것이 아니라 추출의 것이다. 결정론이라는 말 안에 이미 있던 균열이에요. 추출이 안 된 항목은 통과가 아니라 미비로 떨어지게 하고요.[3] 결국에는 추출을 따로 떼어 놓아야 어디서 틀렸는지가 팩트시트 한 장에서 보입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="시점으로-가르기">시점으로 가르기<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EC%8B%9C%EC%A0%90%EC%9C%BC%EB%A1%9C-%EA%B0%80%EB%A5%B4%EA%B8%B0" class="hash-link" aria-label="시점으로 가르기에 대한 직접 링크" title="시점으로 가르기에 대한 직접 링크" translate="no">​</a></h2>
<p>같은 항목이라도 사전 문서만으로 볼 수 있는 것들이 있고, 사후 문서와 첨부가 와야 볼 수 있는 것들이 있거든요. 항목 전체를 놓고 한 번은 전수로 물어봐야 합니다. 리트머스는 하나예요. 지금 손에 있는 입력만으로 판정할 데이터가 존재하는가. 해보면 사전에 남는 것, 사전과 사후로 갈라지는 것, 사후로 넘어가는 것 세 무더기로 나뉩니다.</p>
<table><thead><tr><th>무더기</th><th>예</th><th>처리</th></tr></thead><tbody><tr><td>사전 문서만으로 판정 가능</td><td>번호 형식, 계획 단가 상한, 대상 자격</td><td>사전에 남긴다</td></tr><tr><td>계획값과 실제값을 둘 다 봐야 함</td><td>한도 비율, 인원당 단가, 총액</td><td>둘로 쪼갠다. 사전에서 계획값, 사후에서 실제값</td></tr><tr><td>사후 문서가 와야 판정 가능</td><td>필수 서류 구비, 실제 지급액, 참석 인원</td><td>사후로 옮긴다</td></tr></tbody></table>
<p>그런데 여기서 과잉 이동을 막는 게 이동시키는 것만큼 중요하더라고요. 실제 값은 사후에 나오니까 한도 항목을 전부 사후로 옮기자는 논리가 그럴듯한데, 그러면 사전 단계에서 계획 값이 상한을 넘는 걸 아무도 안 봅니다. 결국에는 쪼갠다는 건 옮긴다는 뜻이 아니라 각 시점에 맞는 질문을 남긴다는 뜻입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="공통을-의심하기">공통을 의심하기<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EA%B3%B5%ED%86%B5%EC%9D%84-%EC%9D%98%EC%8B%AC%ED%95%98%EA%B8%B0" class="hash-link" aria-label="공통을 의심하기에 대한 직접 링크" title="공통을 의심하기에 대한 직접 링크" translate="no">​</a></h2>
<p>쪼개다 보면 공통이라고 믿었던 것들이 제일 늦게 뜯깁니다. 사후 검증에는 첨부 서류가 갖춰졌는지 보는 공통 담당이 하나 있게 마련인데, 영수증이나 계산서 확인을 모든 항목에 똑같이 돌리기 쉬워요. 그런데 개인한테 계좌로 바로 지급하는 항목은 영수증이 애초에 없거든요. 이런 룰은 실패를 잔뜩 내는데 전부 오탐입니다. 규정이 한 페이지에서는 모든 지출에 서류를 갖추라고 써 놓고, 다른 페이지에서는 개인 지급에 영수증을 요구하지 않는 거죠. 규정이 스스로 세운 규칙을 자기도 모르게 어기는 지점이고, 해체가 드러내야 하는 균열이 바로 이런 것들입니다.</p>
<p>그래서 에이전트를 항목 종류별로 다시 쪼개게 돼요. 처음엔 몇 개였던 담당이 기준 문서의 세부 번호와 1:1로 맞춰지면서 십수 개가 됩니다. 공통 담당에는 정말로 모든 항목에 공통인 서류만 남기고, 영수증은 그걸 요구하는 항목의 룰로 옮기고요. 공통이라고 믿었던 규칙은 대개 어떤 부분집합에서만 참이다. 결국에는 공통 룰 하나가 오탐을 만들면 그건 룰이 틀린 게 아니라 아직 덜 쪼개진 거더라고요.</p>
<p>여기까지를 그림으로 놓으면 위아래 차이가 분명하죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/05-0ce23aa58679554d3aafb0d87ad4e801.png" width="2352" height="1434" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="쪼개면-안-되는-곳">쪼개면 안 되는 곳<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EC%AA%BC%EA%B0%9C%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EA%B3%B3" class="hash-link" aria-label="쪼개면 안 되는 곳에 대한 직접 링크" title="쪼개면 안 되는 곳에 대한 직접 링크" translate="no">​</a></h2>
<p>쪼개기가 늘 답은 아닙니다. 경계가 없는 곳을 쪼개는 경우가 있어요. 병렬 처리를 하려고 워커 프로세스를 여러 개 복제해서 돌리는 식인데, 병목이 CPU가 아니라 모델 대기라면 복제할 이유가 없죠. 워커 한 기가 여러 건을 동시에 처리하면 됩니다. 반면 코드는 코어와 AI 패키지로 갈라서 import 방향까지 테스트로 강제할 만해요. 거기엔 경계가 실제로 있으니까요. 해체는 부수는 게 아니라 안에 있던 균열을 드러내는 거라고 했잖아요. 균열이 없는 곳을 자르는 건 해체가 아니라 그냥 파괴입니다.</p>
<p>쪼개기에는 비용도 있습니다. 조각이 늘면 같이 고쳐야 하는 자리가 늘어요. 문서 종류 하나를 추가하면 분류기, 분할기, 이미지 판독기를 같이 고쳐야 하고, 한 곳을 빠뜨리면 새 종류가 어딘가에서 조용히 유실됩니다. 조각을 늘릴 때는 그 조각을 아는 곳이 몇 군데인지 세어 두는 게 좋아요.</p>
<p>말이 좀 돌아갔는데, 결국에는 쪼개는 기준이 하나입니다. 그 조각이 독립적으로 판정되거나 실행될 수 있는가. 독립적이지 않은 걸 쪼개면 조각 사이에 상태를 주고받는 코드만 늘어납니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="판정-하나도-쪼갠다">판정 하나도 쪼갠다<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%ED%8C%90%EC%A0%95-%ED%95%98%EB%82%98%EB%8F%84-%EC%AA%BC%EA%B0%A0%EB%8B%A4" class="hash-link" aria-label="판정 하나도 쪼갠다에 대한 직접 링크" title="판정 하나도 쪼갠다에 대한 직접 링크" translate="no">​</a></h2>
<p>마지막 해체는 판정 하나의 안쪽입니다. 대상 조건에 괄호로 제외 항목이 적혀 있는데 키워드 부분일치 룰이 그 제외 항목에 걸려서 정반대로 분류하는 일이 흔해요. 괄호 안이 제외 절이라는 걸 정규식은 모르죠. 글자 단위로 쪼개면 의미가 깨집니다. 이럴 땐 추출 직후에 모델이 그 표기를 포함과 제외로 한 번 분류해서 팩트시트에 남기고, 룰은 그 분류를 읽게 하면 됩니다.</p>
<p>여기가 데리다의 세 번째 동작이 필요한 자리예요. 앞에서 모델이 하던 걸 코드로 옮겼으니 코드가 모델보다 낫다로 끝내고 싶어지는데, 그러면 또 다른 위계가 됩니다. 정규식이 글자 하나에 걸려 넘어지는 게 그 위계의 균열이죠. 아니죠, 이건 모델로 되돌아가자는 얘기가 아닙니다. 코드냐 모델이냐로 나누는 틀 자체를 흔들자는 거예요. 판정 하나를 확실한 것, 애매한 것, 사람만 아는 것으로 쪼개고, 확실한 건 코드가, 애매한 건 모델이, 사람만 아는 건 미비로 사람한테 보내는 거예요. 이름 대조 같은 것도 같은 패턴이 됩니다. 확실한 일치와 확실한 불일치는 코드, 약식이나 변형 표기는 모델, 외국인 이름처럼 사람만 알 수 있는 건 미비로. 저희는 이걸 게이트라고 부릅니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/06-1678a55413dc18cfc0a6aed0a633d0da.png" width="2352" height="906" class="zoomable_B8z7 img_ev3q"></p>
<p>처음엔 사람한테 넘기는 게 좀 후퇴처럼 느껴집니다. 판정률이 곧 성능 같으니까요. 그런데 사람도 판정 주체 중 하나고, 어느 조각을 사람한테 보낼지 정하는 것까지가 해체예요. 결국에는 모른다고 말할 수 있는 시스템만이 안다고 말할 때 믿을 수 있습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="쪼개-둔-것만-다시-합칠-수-있다">쪼개 둔 것만 다시 합칠 수 있다<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EC%AA%BC%EA%B0%9C-%EB%91%94-%EA%B2%83%EB%A7%8C-%EB%8B%A4%EC%8B%9C-%ED%95%A9%EC%B9%A0-%EC%88%98-%EC%9E%88%EB%8B%A4" class="hash-link" aria-label="쪼개 둔 것만 다시 합칠 수 있다에 대한 직접 링크" title="쪼개 둔 것만 다시 합칠 수 있다에 대한 직접 링크" translate="no">​</a></h2>
<p>해체의 배당은 마지막에 옵니다. 같은 입력을 고쳐서 다시 돌리는 기능을 만들 때예요. 사용자가 서류를 고쳐서 다시 올렸을 때 화면의 주인공은 디프입니다. 무엇이 달라져서 결과가 바뀌었는가를 말하지 못하면 그냥 두 번째 실행이죠.</p>
<p>이게 되려면 안쪽이 쪼개져 있어야 합니다. 문서 하나하나가 해시로 구분돼 있어야 안 바뀐 문서는 재추출하지 않고, 항목이 번호로 구분돼 있어야 어느 항목이 해소됐는지 말할 수 있고, 추출과 판정이 갈라져 있어야 바뀐 문서만 모델에 보낼 수 있어요. 교체된 파일 뭉치도 옛 분할을 페이지 해시로 물려받으면 달라진 구간만 다시 자르면 되고요. 그러면 디프에 모델의 비결정성이 새어 들지 않습니다. 결국에는 처음부터 한 덩어리였다면 디프라는 개념 자체가 성립하지 않아요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결국에는-fde의-능력">결국에는 FDE의 능력<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EA%B2%B0%EA%B5%AD%EC%97%90%EB%8A%94-fde%EC%9D%98-%EB%8A%A5%EB%A0%A5" class="hash-link" aria-label="결국에는 FDE의 능력에 대한 직접 링크" title="결국에는 FDE의 능력에 대한 직접 링크" translate="no">​</a></h2>
<p>남는 생각은 이겁니다. 에이전트를 만드는 일의 대부분은 프롬프트를 쓰는 일이 아니에요. 고객이 덩어리로 준 걸 단위로 만드는 일입니다. 기준의 단위, 판정 주체의 단위, 추출의 단위, 시점의 단위, 입력의 단위. 그리고 어디서 쪼개기를 멈출지. 데리다의 세 동작으로 접으면 이렇습니다. 꼼꼼히 읽어서 규정이 스스로 어기는 지점을 찾고, 새로 설계하는 대신 이미 있던 균열을 따라 뜯고, 코드가 모델보다 낫다는 새 위계를 세우지 않는 것. 한 장으로 접으면 이렇게 됩니다.</p>
<table><thead><tr><th>무엇을 쪼개나</th><th>단위</th><th>잘 쪼개졌는지 묻는 질문</th><th>덜 쪼개졌을 때 증상</th></tr></thead><tbody><tr><td>기준</td><td>항목 번호</td><td>고객 피드백이 번호로 오는가</td><td>의견 하나 반영에 룰을 처음부터 뒤진다</td></tr><tr><td>판정 주체</td><td>레인</td><td>같은 입력에 같은 답이 나오는가</td><td>판정이 실행마다 흔들린다</td></tr><tr><td>추출과 판정</td><td>팩트시트</td><td>틀린 곳이 팩트시트 한 장에서 보이는가</td><td>추출 오류와 판정 오류를 못 가른다</td></tr><tr><td>시점</td><td>사전, 사후</td><td>지금 입력만으로 판정할 데이터가 있는가</td><td>없는 값을 판정하려다 미비만 쌓인다</td></tr><tr><td>입력</td><td>의미 있는 건</td><td>조각이 독립적으로 판정되는가</td><td>건수가 부풀고 첨부 귀속이 깨진다</td></tr><tr><td>공통 룰</td><td>항목별 요구</td><td>그 룰이 정말 모든 항목에 참인가</td><td>특정 항목에서만 오탐이 쏟아진다</td></tr><tr><td>판정 하나</td><td>확실, 애매, 사람</td><td>애매한 것만 모델로 가는가</td><td>정규식이 글자 하나에 걸려 넘어진다</td></tr></tbody></table>
<p>이건 개발팀 혼자서는 못 하는 일이에요. 규정의 어느 항목이 산술이고 어느 항목이 판단인지는 규정을 읽어 본 사람만 알거든요. 반대로 도메인 담당자 혼자서도 못 합니다. 어디까지가 코드로 되고 어디부터 모델이 필요한지는 만들어 본 사람만 알죠. 팔란티어가 온톨로지를 고객사에 내재화한다고 할 때 하는 일이 결국 이거라고 봅니다. 고객의 업무를 시스템이 다룰 수 있는 단위로 뜯어서 다시 세우는 것. 그래서 저는 FDE를 뽑을 때 프롬프트를 얼마나 잘 쓰는지보다, 규정 문서 한 권을 주면 그걸 레인이 붙은 표로 쪼갤 수 있는지를 보려고 합니다.</p>
<p>쪼개 놓은 걸 사용자한테 어떻게 내보낼지는 또 다른 얘기입니다. 안에서 잘게 쪼갠 걸 그대로 밖에 내보내면 아무도 못 읽더라고요. 그건 다음 글에서 쓰겠습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/07/fde-deconstruction-not-prompts#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] 자크 데리다가 그라마톨로지에 대하여(1967)에서 서양 철학이 말을 글보다 앞세워 온 위계를 읽어 낸 방식입니다. 본문의 요약은 철학적으로 엄밀한 정의가 아니라 에이전트 설계에 빌려 쓰기 위한 것이고, 공학 쪽 문헌에서는 같은 동작을 decomposition이라고 부릅니다.</p>
<p>[2] 팩트시트. 판정에 앞서 입력에서 추출한 값들을 모아 둔 구조화 데이터입니다. 추출과 판정을 분리하면 추출은 입력당 한 번만 하고, 판정은 룰마다 이 팩트시트를 읽습니다.</p>
<p>[3] fail-closed. 판단에 필요한 값을 얻지 못했을 때 통과가 아니라 차단 쪽으로 떨어지는 설계입니다. 보안 쪽 용어를 빌려 왔습니다.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[에이전트한테 규칙을 지키게 하는 데 6개월이 걸렸습니다]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules</guid>
            <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[에이전트가 짠 코드가 세션마다 다른 패턴을 고르는 문제를 컨벤션 저장소와 디자인 시스템으로 6개월간 잡아 온 과정과 배운 점을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-cb579a5ff302f8200a1ba709101e25f0.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<p>올해 3월에 저희가 갖고 있던 FastAPI 프로젝트 네 개를 에이전트한테 스캔시켜 봤습니다. 같은 일을 세 가지 방식으로 하고 있더라고요. API prefix가 /api/v1과 /v1과 /api로 갈리고, 서비스 레이어는 ServiceManager와 컨트롤러 클래스와 함수형이 공존하고, 의존성 주입도 세 가지, 인증도 세 가지였습니다. 네 명이 각자 짠 코드면 그럴 수 있죠. 근데 대부분 에이전트가 짠 코드입니다. 에이전트는 세션마다 처음 보는 사람처럼 들어오니까, 그때그때 가장 그럴듯한 패턴을 고르는 것들이 쌓여서 이렇게 된 겁니다.</p>
<p>그래서 컨벤션 저장소를 만들었습니다. 한 달 뒤에는 프론트 쪽에서 같은 문제가 보여서 디자인 시스템도 만들었어요. 6개월 동안 컨벤션 저장소에 76번 커밋했고 규칙 파일이 59개, 변경 이력은 시간 단위로 남겼습니다. 이 글은 그 이력을 처음부터 다시 읽으면서 건진 것들입니다. 대부분 제가 틀렸던 기록이라 좀 민망한데, 그래서 쓸 가치가 있는 것 같더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="문서는-처음부터-3순위였습니다">문서는 처음부터 3순위였습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EB%AC%B8%EC%84%9C%EB%8A%94-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-3%EC%88%9C%EC%9C%84%EC%98%80%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="문서는 처음부터 3순위였습니다에 대한 직접 링크" title="문서는 처음부터 3순위였습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>시작할 때 DDD 리팩토링 글[1]을 읽었습니다. CLAUDE.md나 AGENTS.md 같은 자연어 지침의 효과가 -0.5%에서 +4% 사이인데 비용은 20-23% 늘었다는 수치가 있었어요. 그 글의 한 문장이 저희 컨벤션의 뼈대가 됐습니다.</p>
<blockquote>
<p>AI가 규칙을 어긴다면, 프롬프트를 고치기 전에 구조와 시스템을 먼저 점검하라</p>
</blockquote>
<p>그래서 규칙 강제에 우선순위를 뒀습니다. 1순위는 코드로 강제하는 것들이에요. Ruff의 banned-api로 python-jose나 loguru 같은 것들을 import 자체가 안 되게 막고, AST를 파싱하는 아키텍처 테스트로 라우터가 SQLAlchemy를 직접 부르는지 잡습니다. 2순위는 구조로 유도하는 겁니다. 도메인 폴더 하나를 보면 나머지가 다 예측되는 대칭 구조를 만들어서 에이전트가 패턴을 따라가게 합니다. 문서는 3순위, 최후 수단이죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/02-1550b9bea7dfb956ff4fd4c5f8de355b.png" width="2400" height="1380" class="zoomable_B8z7 img_ev3q"></p>
<p>아키텍처 테스트를 쓸 때 원칙이 하나 있습니다. 실패 메시지에 고치는 방법을 넣는다.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">test_no_direct_db_access_in_routers</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    violations </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> find_imports_in</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"src/routers/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> pattern</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"sqlalchemy"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> violations</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string-interpolation string" style="color:#e3116c">f"Router files must not import sqlalchemy directly.\n"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string-interpolation string" style="color:#e3116c">f"Violations: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">violations</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">\n"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string-interpolation string" style="color:#e3116c">f"Fix: Move DB queries to repositories/ and inject via Depends()."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>위반 파일 목록 뒤에 Fix 한 줄이 붙어 있으면 에이전트는 문서를 찾아보지 않고 에러 메시지만 보고 고칩니다. 문서를 읽으라고 하는 것보다 에러를 잘 쓰는 게 되게 효과가 크더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="컨벤션은-구축하며-하루에-서른-번-바뀌었습니다">컨벤션은 구축하며 하루에 서른 번 바뀌었습니다.<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EC%BB%A8%EB%B2%A4%EC%85%98%EC%9D%80-%EA%B5%AC%EC%B6%95%ED%95%98%EB%A9%B0-%ED%95%98%EB%A3%A8%EC%97%90-%EC%84%9C%EB%A5%B8-%EB%B2%88-%EB%B0%94%EB%80%8C%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="컨벤션은 구축하며 하루에 서른 번 바뀌었습니다.에 대한 직접 링크" title="컨벤션은 구축하며 하루에 서른 번 바뀌었습니다.에 대한 직접 링크" translate="no">​</a></h2>
<p>CHANGELOG를 처음엔 날짜 단위로 적었습니다. 시작한 날 오후에 시간 단위로 바꿨어요. 3월 15일 하루에 결정이 열아홉 건, 다음 날은 서른다섯 건이 났거든요. 사람이 회의해서 컨벤션을 정하면 한 달에 몇 건인데, 에이전트랑 같이 결정하면 한 시간에 몇 건이 나옵니다. 순서를 추적하려면 HH<!-- -->:MM<!-- -->이 필요했습니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/03-f1de7d93d21853ae2fe8ad7bcdb85d5e.png" width="2400" height="1260" class="zoomable_B8z7 img_ev3q"></p>
<p>엔트리 형식은 여섯 필드로 고정했습니다.</p>
<div class="language-markdown codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-markdown codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token title important punctuation" style="color:#393A34">###</span><span class="token title important"> 2026-04-10 12:30 | [확정] TEST-002 Phase 2.5 Ratchet + TEST-007 속도 기준 원칙</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 대상: test/02-architecture-tests.md, test/07-tdd-process.md</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 유형: 규칙 추가</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 변경 전: Phase 1 WARNING과 Phase 3 ERROR 사이에 큰 공백</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 변경 후: 위반 개수를 baseline 파일에 고정, 증가 시 CI 실패</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 사유: 거짓 초록. 검사 범위를 tests로 좁혀 CI를 통과시킨 사건</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token list punctuation" style="color:#393A34">-</span><span class="token plain"> 결정자: 사용자 지적, 정정 승인</span><br></div></code></pre></div></div>
<p>여기서 하나 더 배운 게 있습니다. 에이전트는 확인하지 않으면 그럴듯한 시간을 적습니다. 그래서 기록 전에 date 명령으로 실제 시간을 확인하라는 규칙이 CHANGELOG 상단에 박혀 있어요. 결정자 필드도 따로 뒀습니다. 사용자가 결정했는지 에이전트가 제안했는지, 그리고 확정인지 제안인지 기각인지를 남깁니다.</p>
<p>이 형식이 나중에 예상 못 한 곳에서 값어치를 했습니다. 엔트리마다 변경 전과 변경 후와 사유 같은 것들이 있으니까, 신규 입사자 온보딩 교안을 만들 때 왜 이 규칙인가를 CHANGELOG에서 그대로 꺼내 쓸 수 있었어요. 결국에는 컨벤션의 Why가 어디 따로 있는 게 아니고 변경 이력 안에 있었던 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="리서치로-부풀린-규칙은-다-지워야-했습니다">리서치로 부풀린 규칙은 다 지워야 했습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EB%A6%AC%EC%84%9C%EC%B9%98%EB%A1%9C-%EB%B6%80%ED%92%80%EB%A6%B0-%EA%B7%9C%EC%B9%99%EC%9D%80-%EB%8B%A4-%EC%A7%80%EC%9B%8C%EC%95%BC-%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="리서치로 부풀린 규칙은 다 지워야 했습니다에 대한 직접 링크" title="리서치로 부풀린 규칙은 다 지워야 했습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>초안은 업계 표준 리서치로 보강했습니다. tiangolo 템플릿이나 Netflix Dispatch 같은 것들을 보면서 저희 코드에 없는 항목을 채웠어요. 캐싱 전략, 분산 락, Circuit Breaker, SAGA 패턴, Secrets Manager, K8s 배포, 벡터 DB 세 종 비교. 문서는 두꺼워졌고 그럴듯했습니다.</p>
<p>확정 단계에서 그중 절반을 지웠습니다. CHANGELOG의 사유 필드를 보면 같은 말이 반복돼요. 현재 규모에서 오버다. 실제로 사용한 적이 없다. 실제 경험과 맞는 컨벤션이어야 한다. SAGA는 단일 DB 모놀리식에서 범위 밖으로 뺐고, Secrets Manager는 .env로 되돌렸고, Qdrant는 써본 적이 없어서 뺐고, K8s와 Terraform은 별도 레포로 보냈습니다. 가드레일 문서는 PII 탐지와 ML 분류기와 다층 방어를 전부 확정 규칙으로 적어놨었는데, 실제로 쓰는 건 SLM 프롬프트 라우팅 하나였어요. 확정과 향후 방향을 갈라서 다시 썼습니다. RAG 파이프라인은 아예 WIP로 남겼습니다. 개발을 해보고 나서 쓰는 게 더 정확하다는 판단이었어요.</p>
<p>이걸 왜 굳이 지웠냐면, 에이전트는 문서에 있으면 씁니다. 사람은 우리 규모엔 오버라고 넘어가는데 에이전트는 SAGA 섹션이 있으면 SAGA를 구현해요. 안 쓰는 것을 컨벤션에 두는 건 중립이 아니라 오염이더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="규칙-파일만-고치면-에이전트는-옛-규칙을-읽습니다">규칙 파일만 고치면 에이전트는 옛 규칙을 읽습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EA%B7%9C%EC%B9%99-%ED%8C%8C%EC%9D%BC%EB%A7%8C-%EA%B3%A0%EC%B9%98%EB%A9%B4-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EB%8A%94-%EC%98%9B-%EA%B7%9C%EC%B9%99%EC%9D%84-%EC%9D%BD%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="규칙 파일만 고치면 에이전트는 옛 규칙을 읽습니다에 대한 직접 링크" title="규칙 파일만 고치면 에이전트는 옛 규칙을 읽습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>저장소는 루트 AGENTS.md, 팀별 AGENTS.md, 세부 규칙 파일의 3단계입니다. 3월 16일에 세부 파일 결정을 한참 바꾸고 나서 전체 일관성 검사를 돌렸는데 불일치가 10건 나왔습니다. 세부 파일은 ServiceManager를 폐기했는데 루트 문서는 여전히 ServiceManager를 권장하고 있는 식이었어요. 에이전트는 루트부터 읽으니까 옛 규칙을 먼저 만납니다.</p>
<p>그 뒤로 변경은 항상 3단계를 같이 검수합니다. 이 교훈은 6월에 한 번 더 돌아왔어요. 규칙 사이에 관계 그래프를 넣었는데, 규칙 하나를 고치면 그 규칙을 가리키는 다른 파일들의 엣지도 함께 고쳐야 했습니다. 엣지 한쪽만 고치는 건 그래프를 끊는 겁니다. 결국에는 문서가 여러 층이면 어느 층을 고치든 나머지 층에 전파하는 절차가 규칙 자체보다 중요하더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="토큰-아끼려다-하루-만에-되돌린-것">토큰 아끼려다 하루 만에 되돌린 것<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%ED%86%A0%ED%81%B0-%EC%95%84%EB%81%BC%EB%A0%A4%EB%8B%A4-%ED%95%98%EB%A3%A8-%EB%A7%8C%EC%97%90-%EB%90%98%EB%8F%8C%EB%A6%B0-%EA%B2%83" class="hash-link" aria-label="토큰 아끼려다 하루 만에 되돌린 것에 대한 직접 링크" title="토큰 아끼려다 하루 만에 되돌린 것에 대한 직접 링크" translate="no">​</a></h2>
<p>4월 초에 토큰 최적화를 했습니다. 캐싱 어떻게 하냐는 질문 하나에 루트에서 팀에서 세부 파일까지 세 번 읽고 710줄을 소비하더라고요. 실제로 필요한 건 캐싱 규칙 몇 줄인데요. 모든 세부 파일에 frontmatter를 달았습니다. Rule ID, 검색용 키워드, 120자 요약. BACK-010, DB-008 같은 ID 체계가 이때 생겼고, 루트에는 캐싱 레이어 추가면 BACK-010과 DB-008을 읽으라는 식으로 작업별 규칙을 묶은 번들 표를 넣었습니다. 같은 질문으로 전후를 재봤는데 710줄이 420줄로, 56개 파일 중 정확히 2개만 읽게 됐습니다. 이건 잘 됐어요.</p>
<p>그런데 거기서 한 걸음 더 갔습니다. 파일 안에서도 규칙 섹션만 offset과 limit으로 잘라 읽으라는 프로토콜을 CLAUDE.md에 넣었어요. 규칙과 코드 예시와 금지 사항을 섹션으로 나누고, 줄 번호 계산법까지 적었습니다. 그리고 같은 날 되돌렸습니다. 규칙만 읽은 에이전트가 금지 사항을 놓치고 구현하더라고요. 규칙과 코드 예시와 금지 사항은 하나의 맥락이었던 겁니다. 줄 번호를 미리 계산해 두는 방식도 파일을 고칠 때마다 전부 틀어져서 관리 비용이 이점을 넘었습니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/04-f2fafd61b4803ac2d6999ff8103f9cb9.png" width="2352" height="2013" class="zoomable_B8z7 img_ev3q"></p>
<p>지금 CLAUDE.md에는 이렇게 적혀 있습니다. 필요하다고 판단한 파일은 통째로 읽어라. 결국에는 절감은 어느 파일을 읽을지 정확히 고르는 데서 나오고, 고른 파일을 얼마나 읽을지에서 나오면 안 되는 거였습니다.</p>
<p>하나 더 있어요. AGENTS.md에 읽기 지침을 아무리 잘 써도 에이전트가 AGENTS.md를 먼저 읽는다는 보장이 없었습니다. 그래서 세션 시작 시 자동으로 로딩되는 CLAUDE.md로 프로토콜을 옮겼어요. 지침의 내용보다 지침이 로딩되는 경로가 먼저였습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="첫-실전-적용에서-컨벤션이-깨졌습니다">첫 실전 적용에서 컨벤션이 깨졌습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EC%B2%AB-%EC%8B%A4%EC%A0%84-%EC%A0%81%EC%9A%A9%EC%97%90%EC%84%9C-%EC%BB%A8%EB%B2%A4%EC%85%98%EC%9D%B4-%EA%B9%A8%EC%A1%8C%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="첫 실전 적용에서 컨벤션이 깨졌습니다에 대한 직접 링크" title="첫 실전 적용에서 컨벤션이 깨졌습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>첫 충돌은 3월 말 PDF 처리 파이프라인에서 왔습니다. 모노레포에서 서비스마다 패키지 이름을 src로 두니까 파이썬의 sys.modules 안에서 src가 충돌해서 여러 서비스 테스트를 한 번에 돌릴 수 없었어요. Temporal에서는 child workflow를 시작하고 await를 빼먹으니까 부모가 끝나는 순간 기본 정책이 child를 종료시켜서 임베딩 워크플로우가 조용히 사라졌습니다. 둘 다 그 자리에서 컨벤션이 됐어요.</p>
<p>4월에는 운영 중인 백엔드를 컨벤션대로 리팩토링했습니다. 문서로만 있을 때는 몰랐던 런타임 함정이 며칠 사이에 네 개 나왔어요.</p>
<p>FastAPI의 Depends 세션은 autobegin이라 컨벤션대로 session.begin()을 부르면 IllegalStateChangeError가 납니다.</p>
<p>라우터에 <code>@router.get("/")</code>을 쓰면 307 리다이렉트가 나면서 Authorization 헤더가 날아가 401이 됩니다.</p>
<p>PyJWT는 leeway 기본값이 0이라 Cognito 토큰의 iat가 서버 시계와 몇 초만 어긋나도 거부해요.</p>
<p>python-jose는 기본으로 leeway가 켜져 있어서 전환 전에는 안 보였던 문제입니다.</p>
<p>pydantic-settings는 .env의 CSV 문자열을 list[str]로 받으면 JSON 파싱을 시도하다 죽습니다.</p>
<p>테스트 쪽에서는 6건이 더 나왔습니다. LangGraph 노드를 테스트할 때 get_stream_writer를 전역으로 패치하면 효과가 없고 노드 모듈 경로로 패치해야 하는데, 이거 하나에 30분을 썼어요. 이런 것들이 전부 컨벤션에 추가됐습니다.</p>
<p>컨벤션은 문서로 쓸 때 완성되는 게 아니라 첫 프로젝트에 붙일 때 완성된다. CHANGELOG에서 제일 값진 엔트리들은 "실전 적용 후 사후 보고"라고 적힌 것들입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="거짓-초록">거짓 초록<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EA%B1%B0%EC%A7%93-%EC%B4%88%EB%A1%9D" class="hash-link" aria-label="거짓 초록에 대한 직접 링크" title="거짓 초록에 대한 직접 링크" translate="no">​</a></h2>
<p>가장 크게 배운 건 이겁니다. 리팩토링 중에 한 패키지에서 ruff 위반이 187건 나왔습니다. 에이전트의 첫 반응은 Makefile에 <code>ENFORCED_PATHS := tests</code>를 넣어서 tests 폴더만 검사하게 하는 거였어요. CI는 초록이 됐습니다. src는 그대로였죠.</p>
<p>제가 이걸 "테스트의 의미 전복"이라고 지적했고, 그날 컨벤션에 두 가지가 들어갔습니다. 검사 범위를 줄이는 유일한 기준은 속도다. 느려서 빼는 것만 허용하고, 내 작업 범위로 좁히거나 내가 만든 규칙만 검사하는 건 금지입니다.</p>
<p>그리고 Ratchet. 기존 위반이 수백 건인 규칙은 바로 에러로 못 올리는데, 경고로 두면 줄어들 압력이 0이에요. 그래서 위반 개수를 baseline 파일에 고정하고, 늘어나면 CI 실패, 줄어들면 갱신을 허용합니다. 숫자가 올라가지 않게만 하는 겁니다. 실제로 ruff는 187에서 15에서 0으로 갔고, mypy는 250건이 남아서 ratchet으로 넘겼습니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/05-9477983774506c315c944a0b434e3a0e.png" width="2400" height="1260" class="zoomable_B8z7 img_ev3q"></p>
<p>같은 날 check라는 단일 엔트리포인트도 규칙이 됐습니다. ruff, format, mypy, arch 테스트, unit 테스트를 순서대로 돌리는 명령인데, 이름을 check로 통일한 이유는 에이전트가 프로젝트마다 lint인지 validate인지 ci-check인지 외우게 만들지 않으려는 겁니다. 로컬과 CI가 같은 명령을 쓰니까 로컬은 되는데 CI는 실패하는 일도 없어졌어요.</p>
<p>에이전트는 초록불을 만드는 가장 짧은 길을 찾습니다. 그 길이 검사 범위를 줄이는 거라면 줄이죠. 결국에는 그 길을 막는 것도 문서가 아니라 코드여야 했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="검색을-붙였더니-측정이-결정을-대신-해줬습니다">검색을 붙였더니 측정이 결정을 대신 해줬습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EA%B2%80%EC%83%89%EC%9D%84-%EB%B6%99%EC%98%80%EB%8D%94%EB%8B%88-%EC%B8%A1%EC%A0%95%EC%9D%B4-%EA%B2%B0%EC%A0%95%EC%9D%84-%EB%8C%80%EC%8B%A0-%ED%95%B4%EC%A4%AC%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="검색을 붙였더니 측정이 결정을 대신 해줬습니다에 대한 직접 링크" title="검색을 붙였더니 측정이 결정을 대신 해줬습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>키워드 Grep은 정확하지만 의미 검색이 약합니다. 사용자가 "DB에서 시간 다루는 법"이라고 말하면 타임존 규칙으로 가야 하는데 Grep으로는 안 잡혀요. 4월 중순에 벡터 검색을 붙였습니다. 사람이 던질 법한 질문 171개에 정답 Rule ID를 붙인 골든셋을 먼저 만들었고, 그 뒤 모든 결정을 이 골든셋의 Recall로 했습니다.</p>
<table><thead><tr><th>실험</th><th>결과</th><th>결정</th></tr></thead><tbody><tr><td>청킹: 파일 단위 vs 섹션 단위</td><td>Recall@1 0.78 vs 0.87</td><td>섹션 단위 채택</td></tr><tr><td>색인 페이지(AGENTS.md)까지 인덱싱</td><td>rule_id 없는 페이지가 상위 노출</td><td>인덱스에서 제외</td></tr><tr><td>summary 청크 거리 부스트 0.9x</td><td>MRR 하락</td><td>롤백</td></tr><tr><td>로컬 임베딩 모델을 Gemini API로</td><td>워커 RAM 700MB-1.2GB 해소, 한글 품질 유지</td><td>전환</td></tr><tr><td>Context7[2]식 resolve/fetch 2단계</td><td>Recall@1 0.918에서 0.912, 토큰 절감</td><td>채택</td></tr><tr><td>LLM이 생성한 발동 상황 청크(enrich)</td><td>171 쿼리에서 top-1 변화 0건</td><td>미채택, 기본 off</td></tr></tbody></table>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/06-0687d14cb668a08c4eb765349c027f86.png" width="2400" height="1140" class="zoomable_B8z7 img_ev3q"></p>
<p>여섯 개 실험 중 셋을 기각했습니다. enrich는 특히 아까웠어요. Rule마다 LLM이 이 규칙이 발동하는 상황을 한 줄씩 써서 추가 임베딩하는 건데, 직관적으로는 될 것 같았습니다. 171개 쿼리에서 순위가 바뀐 게 한 건도 없었어요. 골든셋이 없었으면 채택했을 겁니다.</p>
<p>6월에는 문서 구조 자체를 두고 큰 실험을 했습니다. 규칙 파일을 개념 단위로 잘게 쪼개는 방향과, 파일은 응집된 채로 두고 파일 사이에 타입이 있는 관계 엣지를 두는 방향이었어요. 쪼개는 쪽은 Recall@5가 1.0에서 0.99로 회귀했고 frontmatter 오버헤드 때문에 토큰도 안 줄었습니다. 관계 그래프 쪽은 캐싱 규칙을 찾을 때 인증 캐시 누출 주의 규칙처럼 의미 검색만으로는 안 나오는 교차 의존을 1홉 전개로 잡아냈는데, 이 deep recall이 0.26에서 0.86으로 올랐어요. 그래서 응집 파일에 requires, related, caution 세 종류 엣지를 달았습니다. 규칙 59개에 엣지 621개, 카테고리 사이를 넘는 엣지가 절반 이상입니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/07-aeaf5e57e9089390364a6e3b9ea6d72d.png" width="2400" height="900" class="zoomable_B8z7 img_ev3q"></p>
<p>엣지는 각 규칙 파일의 frontmatter에 들어갑니다. reason 한 줄이 필수라서 에이전트가 따라갈지 말지를 읽고 판단할 수 있어요.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> BACK</span><span class="token punctuation" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">010</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">title</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"캐싱"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">relations</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">type</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> requires</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">target</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"DB-008"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">reason</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"캐시 backend는 Redis.from_url(settings.REDIS_URL)로 연결. Redis 연결 컨벤션이 선행 필수"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">type</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> caution</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">target</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"BACK-006"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">reason</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"인증된 사용자별 응답에 @cache를 적용하면 사용자 간 데이터 누출"</span><br></div></code></pre></div></div>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/08-58a5905392a6a40340ef03a0032148ec.png" width="1824" height="3444" class="zoomable_B8z7 img_ev3q"></p>
<p>엣지를 만들 때는 규칙 하나에 에이전트 하나를 붙여 후보를 뽑고, 다른 에이전트가 반대 입장에서 반증을 시도해서 약한 엣지를 강등했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="문서만-보고-쓴-규칙은-sdk에-없는-api를-부릅니다">문서만 보고 쓴 규칙은 SDK에 없는 API를 부릅니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EB%AC%B8%EC%84%9C%EB%A7%8C-%EB%B3%B4%EA%B3%A0-%EC%93%B4-%EA%B7%9C%EC%B9%99%EC%9D%80-sdk%EC%97%90-%EC%97%86%EB%8A%94-api%EB%A5%BC-%EB%B6%80%EB%A6%85%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="문서만 보고 쓴 규칙은 SDK에 없는 API를 부릅니다에 대한 직접 링크" title="문서만 보고 쓴 규칙은 SDK에 없는 API를 부릅니다에 대한 직접 링크" translate="no">​</a></h2>
<p>5월에 Langfuse 관찰성 규칙을 v2 API에서 v4와 OpenTelemetry 기준으로 전면 재작성했습니다. 코드베이스는 이미 넘어갔는데 컨벤션만 v2에 머물러 있었거든요. 그런데 재작성한 규칙에 update_current_trace라는 호출이 있었고, 이게 설치된 langfuse 4.6.1에는 없었습니다. AttributeError가 나요. 40분 뒤에 propagate_attributes 컨텍스트 매니저로 정정했고, 이번엔 실제 프로젝트에서 트레이스를 찍어 session_id와 user_id가 반영되는 걸 보고 확정했습니다.</p>
<p>같은 종류의 일이 컨벤션을 소비하는 프로젝트 쪽에서도 나왔습니다. 컨벤션은 Circuit Breaker에 pybreaker를 쓰라고 했는데, 한 프로젝트에서 적용해보니 pybreaker 1.4.1이 asyncio를 지원하지 않더라고요. Tornado 전용이었습니다. 그 프로젝트는 asyncio 네이티브로 직접 구현했고 그 사실이 커밋 메시지에 남았어요.</p>
<p>컨벤션의 코드 예시는 설치된 버전에서 한 번은 돌려봐야 합니다. 문서와 기억으로 쓴 규칙은 그럴듯한 API를 부르는데, 에이전트는 그걸 그대로 씁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="디자인-시스템도-같은-병을-앓았습니다">디자인 시스템도 같은 병을 앓았습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EB%8F%84-%EA%B0%99%EC%9D%80-%EB%B3%91%EC%9D%84-%EC%95%93%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="디자인 시스템도 같은 병을 앓았습니다에 대한 직접 링크" title="디자인 시스템도 같은 병을 앓았습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>4월 말에 디자인 시스템을 시작했습니다. 토스, 당근 SEED[3], 카카오스타일 ZDS, 원티드, shadcn, Geist, W3C DTCG를 리서치해서 플랜을 짰어요. 플랜과 다르게 간 것들도 있습니다. Storybook 대신 Next.js 문서 사이트[4]를 직접 만들었고, Figma 파이프라인 없이 코드 토큰부터 시작했고, 토큰의 단일 진실은 DESIGN.md라는 마크다운 문서인데 빌드가 이 파일을 읽지는 않아서 값을 바꾸면 양쪽을 손으로 맞춰야 합니다. 컴포넌트 티어는 안 만들었어요. 같은 패턴이 세 번 반복되면 그때 승격한다는 원칙만 뒀습니다.</p>
<p>npm에 공개 패키지로 올렸더니 대학 고객사 프로젝트 두 곳이 바로 가져다 썼습니다. 그리고 둘 다 첫 커밋에서 브랜드 색을 override했어요. 한 곳은 흰색과 빨강, 한 곳은 빨강과 파랑. 멀티 브랜드는 나중에 필요할 기능이라고 생각했는데 첫 소비자부터 필요했습니다.</p>
<p>7월에 큰 개편을 했습니다. 계기가 좀 재미있어요. 에이전트가 화면을 만들 때마다 같은 형태로 수렴하더라고요. 좌측 사이드바에 카드 그리드. 어떤 화면이든 그렇게 나옵니다. 처음엔 프롬프트 문제인가 싶었죠. 아니죠, 골격을 고르는 프리미티브가 없어서 매번 즉흥으로 짜다 보니 가장 익숙한 형태로 가는 거였습니다. 그래서 Box, Flex, HStack, VStack, Grid, AppShell 같은 레이아웃 프리미티브를 만들고, AppShell의 nav prop에 기본값을 두지 않았습니다. top인지 side인지 none인지 매번 명시해야 타입 체크가 통과합니다. 목적지가 5개 이하면 top, 8개 이상이면 side, 이동할 곳이 없으면 none이라는 기준표도 같이 넣었어요.</p>
<p>이게 컨벤션 저장소의 2순위, 구조로 유도한다는 원칙과 같은 해법이었습니다. 에이전트가 매번 같은 답을 내면 문서에 다르게 하라고 쓰는 게 아니라 결정을 강제하는 구조를 만든다. 간격 토큰도 같은 이유로 바꿨습니다. gap-x4는 16px이라는 크기만 말하는데 gap-component는 컴포넌트 사이라는 의도를 코드에 남기거든요. 얼마나가 아니라 어디에로 고르게 한 겁니다.</p>
<div class="language-tsx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-tsx codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag class-name" style="color:#00009f">AppShell</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">nav</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">side</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain-text">          </span><span class="token punctuation" style="color:#393A34">{</span><span class="token comment" style="color:#999988;font-style:italic">/* nav 는 필수 prop. top, side, none 중 하나 */</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain-text"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain-text">  </span><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag class-name" style="color:#00009f">VStack</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">gap</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">section</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain-text">       </span><span class="token punctuation" style="color:#393A34">{</span><span class="token comment" style="color:#999988;font-style:italic">/* 48px 이 아니라 섹션 사이 */</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain-text"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain-text">    </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">children</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain-text"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain-text">  </span><span class="token tag punctuation" style="color:#393A34">&lt;/</span><span class="token tag class-name" style="color:#00009f">VStack</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain-text"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain-text"></span><span class="token tag punctuation" style="color:#393A34">&lt;/</span><span class="token tag class-name" style="color:#00009f">AppShell</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/09-8b3db643d9bd7ef96265027ee5077a67.png" width="1983" height="3840" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="컨벤션이-저장소-밖으로-나갔습니다">컨벤션이 저장소 밖으로 나갔습니다<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EC%BB%A8%EB%B2%A4%EC%85%98%EC%9D%B4-%EC%A0%80%EC%9E%A5%EC%86%8C-%EB%B0%96%EC%9C%BC%EB%A1%9C-%EB%82%98%EA%B0%94%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="컨벤션이 저장소 밖으로 나갔습니다에 대한 직접 링크" title="컨벤션이 저장소 밖으로 나갔습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>Rule ID를 붙이고 벡터 인덱스를 커밋해 두니까 다른 시스템이 컨벤션을 인용할 수 있게 됐습니다. PR 리뷰 게이트를 하나 만들었는데, 에디터에서는 Claude가 코드를 쓰고 PR이 올라오면 다른 모델인 Codex가 diff를 컨벤션 벡터 검색 결과와 대조해서 approve나 block을 커밋 상태로 남깁니다. 모든 지적에 BACK-006 같은 Rule ID가 붙어요. 쓰는 모델과 검사하는 모델을 갈라놓은 겁니다.</p>
<p>신규 입사자 온보딩 커리큘럼도 컨벤션 위에 올렸습니다. 4주 교안 하나하나가 어떤 Rule ID를 다루는지 매핑표가 있고, 원칙은 컨벤션 원문이 언제나 정답이고 교안은 그것의 Why라는 겁니다. 차이가 나면 컨벤션을 정정하거나 교안을 고칩니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/10-b0ebf40435240bbf65da2f440fdb54b4.png" width="2352" height="954" class="zoomable_B8z7 img_ev3q"></p>
<p>문서에 주소가 생기면 다른 시스템이 그 주소를 부를 수 있다. Rule ID는 토큰 절감용으로 만들었는데 결국에는 컨벤션을 인프라로 만든 게 그 ID였습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그래서-6개월-뒤에-남은-것">그래서 6개월 뒤에 남은 것<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EA%B7%B8%EB%9E%98%EC%84%9C-6%EA%B0%9C%EC%9B%94-%EB%92%A4%EC%97%90-%EB%82%A8%EC%9D%80-%EA%B2%83" class="hash-link" aria-label="그래서 6개월 뒤에 남은 것에 대한 직접 링크" title="그래서 6개월 뒤에 남은 것에 대한 직접 링크" translate="no">​</a></h2>
<p>말이 좀 돌아갔는데, 정리하면 이렇습니다. 처음엔 컨벤션을 잘 쓰는 일이라고 생각했습니다. 규칙을 정확하게 적으면 에이전트가 따를 거라고요. 6개월 이력을 다시 읽어보니 잘 된 것들은 전부 문서가 아니었어요. banned-api, 아키텍처 테스트의 에러 메시지, ratchet baseline, check 엔트리포인트, 기본값 없는 nav prop, 골든셋 171개. 문서는 그 구조들이 왜 그렇게 생겼는지 설명하는 역할로 물러났습니다.</p>
<p>그리고 컨벤션은 첫 실전 적용에서 깨지고, 설치된 SDK에서 깨지고, 소비하는 프로젝트에서 깨집니다. 깨진 자리를 CHANGELOG에 사유와 함께 남기는 것까지가 컨벤션이었어요.</p>
<p>결국에는 규칙을 문서에 쓰는 일이 아니라, 에이전트가 규칙을 어길 수 없는 구조를 만드는 일이었던 거죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="삽질에서-건진-것">삽질에서 건진 것<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EC%82%BD%EC%A7%88%EC%97%90%EC%84%9C-%EA%B1%B4%EC%A7%84-%EA%B2%83" class="hash-link" aria-label="삽질에서 건진 것에 대한 직접 링크" title="삽질에서 건진 것에 대한 직접 링크" translate="no">​</a></h2>
<p>프로그램으로 frontmatter를 고칠 때 키 순서를 가정하지 마세요. 파일 네 개에서 키 순서가 달라 오펀 블록이 생겼고, 결국 손으로 수리했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/06/six-months-of-agent-rules#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] 채널코퍼레이션, AI Native DDD Refactoring. <a href="https://channel.io/ko/team/blog/articles/ai-native-ddd-refactoring-98c23cdb" target="_blank" rel="noopener noreferrer" class="">https://channel.io/ko/team/blog/articles/ai-native-ddd-refactoring-98c23cdb</a></p>
<p>[2] Context7. 라이브러리 문서를 resolve-library-id와 query-docs 두 단계로 찾는 MCP 서버. <a href="https://context7.com/" target="_blank" rel="noopener noreferrer" class="">https://context7.com</a></p>
<p>[3] 당근 SEED Design. <a href="https://seed-design.io/" target="_blank" rel="noopener noreferrer" class="">https://seed-design.io</a></p>
<p>[4] NxtGen Design System 문서 사이트. <a href="https://design.nxtgen.co.kr/" target="_blank" rel="noopener noreferrer" class="">https://design.nxtgen.co.kr</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Qwen3.8 27B 모델, 내 맥에서 진짜 쓸 수 있을까]]></title>
            <link>https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac</link>
            <guid>https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac</guid>
            <pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[dense 27B인 Qwen3.8을 M4 Pro 48GB 맥에서 4bit로 돌리면 실제로 얼마나 나오는지, 직접 벤치를 돌리며 배운 평가 프로토콜의 함정을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-2b14b49966855e484f84a923db88897a.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<p>Qwen3.8-27B가 dense인데도 MoE급 성능이 나온다는 이야기를 봤습니다. 리더보드를 열어보니 같은 27B급 모델들이 평균 0.51-0.53 사이에 몰려 있고, 이 모델도 그 안에 들어 있더라고요. 27B dense면 제가 쓰는 맥에서도 돌아가는 크기죠. 그래서 자연스럽게 이런 생각이 들었습니다. 이거 실제 업무에 붙일 수 있는 물건인가.</p>
<p>문제는 조건입니다. 제 맥은 M4 Pro 48GB인데, 27B를 BF16으로 올리면 54GB라 애초에 안 들어갑니다. 게다가 통합메모리인 부분을 고려하면 4bit로 양자화한 15GB짜리를 써야 합니다. 그러면 리더보드에 올라온 수치는 원본 정밀도 기준이니까, 내 환경에서 실제로 얼마나 나오는지는 따로 재봐야 하는 것들이 생깁니다. 결국에는 양자화 모델의 벤치마크를 어떻게 돌려야 하는가라는 질문으로 시작한 셈입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="lm-eval-harness로-시작했는데">lm-eval harness로 시작했는데<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#lm-eval-harness%EB%A1%9C-%EC%8B%9C%EC%9E%91%ED%96%88%EB%8A%94%EB%8D%B0" class="hash-link" aria-label="lm-eval harness로 시작했는데에 대한 직접 링크" title="lm-eval harness로 시작했는데에 대한 직접 링크" translate="no">​</a></h2>
<p>가장 익숙한 조합부터 갔습니다. lm-evaluation-harness[1]에 LM Studio를 붙이는 겁니다. LM Studio가 OpenAI 호환 API를 열어주니까 local-chat-completions 모델 타입으로 연결하면 될 거라고 생각했습니다.</p>
<p>gsm8k 세 문제는 잘 돌아가더라고요. 문제당 19초쯤 걸렸고 두 문제 맞혔습니다. 여기까지는 되게 순조로웠습니다.</p>
<p>그런데 MMLU를 돌리려니까 안 됩니다. 이유를 이해하려면 logprobs가 뭔지부터 짚어야 해요.</p>
<p>언어 모델은 다음 토큰을 고를 때 후보마다 확률을 매깁니다. logprob은 그 확률에 로그를 씌운 값이에요. 이 모델이 이 문장을 얼마나 자연스럽다고 보는가를 숫자로 꺼내온 것들이라고 보면 됩니다. 확률 0.67이면 logprob은 -0.4쯤 되고, 확률 0.0003이면 -8.1쯤 됩니다. 0에 가까울수록 모델이 그 문장을 그럴듯하게 본다는 뜻이에요.</p>
<p>객관식 벤치라고 하는 것들은 이 값을 씁니다. 프랑스의 수도를 묻는 문제가 있으면 모델한테 답을 쓰라고 하지 않아요. 프롬프트 뒤에 리옹, 파리, 마르세유, 니스를 각각 붙여서 문장 네 개를 만들고, 모델에 통과시켜서 붙인 부분의 logprob을 더합니다. 제일 높은 게 모델의 답입니다. 생성을 안 하니까 빠르고, 모델이 딴소리를 해도 채점이 안 흔들리고, 리더보드 수치 대부분이 이렇게 나온 값이라 비교도 됩니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="객관식 벤치의 loglikelihood 채점 방식. 프롬프트 뒤에 선택지를 붙여 각각의 logprob 합을 비교한다" src="https://nxtgen.co.kr/assets/images/02-cb49f8c0a0e2b1338ff510d9293e93cd.png" width="2352" height="1236" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">객관식 벤치의 loglikelihood 채점 방식. 프롬프트 뒤에 선택지를 붙여 각각의 logprob 합을 비교한다</span></span></p>
<p><em>객관식 벤치의 loglikelihood 채점 방식. 프롬프트 뒤에 선택지를 붙여 각각의 logprob 합을 비교한다</em></p>
<p>여기서 핵심은 붙인 부분의 logprob이라는 거예요. 모델이 스스로 고른 토큰이 아니라, 내가 써서 넘긴 토큰의 확률이 필요합니다. lm-eval 소스를 보면 실제로 그렇게 계산해요. 프롬프트 길이를 ctxlen이라고 두고, 응답에서 그 뒤쪽 토큰들의 logprob만 잘라서 더합니다.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">logprobs </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">sum</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">choice</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"logprobs"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"token_logprobs"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">ctxlen</span><span class="token punctuation" style="color:#393A34">:</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>그러려면 API가 내가 보낸 텍스트를 그대로 되돌려주면서 토큰마다 logprob을 붙여줘야 하는데, 이게 completions 엔드포인트의 echo 옵션입니다. 그리고 LM Studio는 이걸 안 줍니다.</p>
<p>처음엔 제가 잘못 보낸 줄 알았습니다. 아니죠, chat completions는 logprobs를 주긴 줍니다. 근데 그건 모델이 방금 생성한 토큰의 확률이에요. 내가 써서 넘긴 문자열의 확률이 아니에요. completions 엔드포인트에 echo를 켜서 요청해야 하는데 LM Studio는 echo도 logprobs도 구현이 안 돼 있더라고요. 마침 LM Studio가 Open Responses API[2]라는 걸 새로 내놨길래 그것도 확인했는데, 여기도 생성 토큰의 logprobs만 줍니다. 네이티브 API까지 네 군데를 다 찔러봤는데 전부 같았습니다.</p>
<p>lm-eval 소스를 열어보니 아예 명시가 돼 있었습니다.</p>
<blockquote>
<p>Loglikelihood (and therefore multiple_choice-type tasks) is not supported for chat completions as OpenAI does not provide prompt logprobs.</p>
</blockquote>
<p>결국에는 HTTP API를 통해서는 객관식 벤치를 돌릴 수 없다는 게 결론이었습니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="LM Studio HTTP API 세 엔드포인트는 프롬프트 logprobs를 주지 않고, mlx_lm evaluate는 모델을 직접 로드해 확보" src="https://nxtgen.co.kr/assets/images/03-c9b6c169e980cff650346c859b7d9208.png" width="2352" height="1464" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">LM Studio HTTP API 세 엔드포인트는 프롬프트 logprobs를 주지 않고, mlx_lm evaluate는 모델을 직접 로드해 확보</span></span></p>
<p><em>LM Studio HTTP API 세 엔드포인트는 프롬프트 logprobs를 주지 않고, mlx_lm evaluate는 모델을 직접 로드해 확보</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mlx_lm-evaluate로-넘어가기">mlx_lm evaluate로 넘어가기<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#mlx_lm-evaluate%EB%A1%9C-%EB%84%98%EC%96%B4%EA%B0%80%EA%B8%B0" class="hash-link" aria-label="mlx_lm evaluate로 넘어가기에 대한 직접 링크" title="mlx_lm evaluate로 넘어가기에 대한 직접 링크" translate="no">​</a></h2>
<p>모델을 직접 로드하면 되는 거였습니다. mlx_lm에 lm-eval이 통합돼 있어서[3] 명령 하나로 같은 태스크를 돌릴 수 있더라고요. 이 모델은 GGUF가 아니라 MLX 포맷이라 llama.cpp로 우회하는 것도 안 됐는데, 오히려 그래서 mlx_lm이 정답이었습니다.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python3 -m mlx_lm evaluate \</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --model /path/to/Qwen3.8-27B-MLX-4bit \</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --tasks kmmlu haerae \</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --no-apply-chat-template --batch-size 1</span><br></div></code></pre></div></div>
<p>여기서 함정 같은 것들을 두 개 밟았습니다.</p>
<p>chat template을 켜면 점수가 무너져요. MMLU abstract_algebra 20문제로 대조해봤는데 끄면 0.70, 켜면 0.20이 나왔어요. 4지선다에서 무작위가 0.25니까 켜면 찍는 것보다 못한 겁니다. 객관식은 선택지의 확률을 비교하는 건데 chat template이 프롬프트 구조를 바꿔놓으니까 그 비교가 어긋나는 겁니다.</p>
<p>batch-size는 1이 제일 빠릅니다. 8이나 32로 올리면 오히려 느려져요. MLX 패딩 오버헤드 때문인데, 50문제 기준으로 1일 때 55초, 32일 때 66초였습니다. 정확도는 셋 다 같았고요.</p>
<p>한국어 벤치 중에 lm-eval에 없는 것들이 있었습니다. KMMLU-Pro[4], Ko-MuSR[5] 같은 거요. 이건 YAML 하나 써서 lm-eval의 tasks 디렉토리에 넣으면 됩니다. mlx_lm evaluate에 include_path 옵션은 없는데 내부에서 simple_evaluate를 호출하니까 그냥 인식이 되더라고요. 데이터셋 스키마를 보면서 잔손이 좀 갔습니다. KMMLU-Pro는 5지선다 2,702문항에 4지선다 120문항이 섞여 있어서 선택지 레이블을 문항마다 잘라줘야 했고, 정답 필드가 숫자가 아니라 문자열이라 형변환을 안 하면 TypeError가 납니다. 결국에는 도구를 바꾸는 게 아니라 모델을 로드하는 경로를 바꾸는 문제였습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결과">결과<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EA%B2%B0%EA%B3%BC" class="hash-link" aria-label="결과에 대한 직접 링크" title="결과에 대한 직접 링크" translate="no">​</a></h2>
<p>한국어 벤치 다섯 개를 돌렸습니다. 서브태스크당 50문제로 잘랐으니 정식 수치는 아니고 경향을 보는 용도입니다.</p>
<table><thead><tr><th>벤치</th><th>값</th><th>무작위 기준</th></tr></thead><tbody><tr><td>HAE-RAE</td><td>0.756</td><td>0.25</td></tr><tr><td>CLIcK</td><td>0.717</td><td>0.25</td></tr><tr><td>KMMLU</td><td>0.575</td><td>0.25</td></tr><tr><td>Ko-MuSR</td><td>0.540</td><td>0.25-0.50</td></tr><tr><td>KMMLU-Pro</td><td>0.420</td><td>0.20</td></tr><tr><td>ko_commongen_v2</td><td>0.300</td><td>0.25</td></tr></tbody></table>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/04-6fe089e97ae020ff9f4dc7cda504cbed.png" width="2400" height="1260" class="zoomable_B8z7 img_ev3q"></p>
<p>업무 적용 관점에서 보면 이렇습니다. 한국 문화나 상식을 묻는 것들은 되게 잘해요. CLIcK 문화 파트가 0.77이고 전통 항목은 0.92까지 나와요. KMMLU에서도 computer_science가 0.92, economics가 0.86입니다. 그런데 한국어 문법 판단은 0.34로 좀 약합니다. 문화는 아는데 언어 자체는 흔들리는 겁니다.</p>
<table><thead><tr><th>영역</th><th>항목</th><th>값</th></tr></thead><tbody><tr><td>강함</td><td>CLIcK 전통</td><td>0.92</td></tr><tr><td>강함</td><td>KMMLU computer_science</td><td>0.92</td></tr><tr><td>강함</td><td>KMMLU economics</td><td>0.86</td></tr><tr><td>강함</td><td>CLIcK 문화 파트 전체</td><td>0.77</td></tr><tr><td>약함</td><td>CLIcK 문법</td><td>0.34</td></tr><tr><td>약함</td><td>KMMLU 전문 법률</td><td>0.18</td></tr></tbody></table>
<p>같은 법률인데 정반대인 게 재미있었습니다. CLIcK의 생활 법률 상식은 0.88인데 KMMLU의 전문 법률은 0.18이에요. 4지선다에서 0.18이면 찍는 것보다 낮습니다. 단순히 모르는 게 아니라 체계적으로 틀린 답을 고르고 있다는 뜻입니다. 한국 법체계가 국가별 특수성이 강한 영역이라 다른 나라 법 지식을 끌어다 쓰는 것 같더라고요. 역사도 비슷한데, HAE-RAE 역사는 0.78이고 CLIcK 역사는 0.46입니다. 같은 영역인데 벤치마다 0.32가 벌어지니까, 벤치 하나 보고 이 모델 역사 잘한다고 말하면 안 되는 거였습니다.</p>
<table><thead><tr><th>영역</th><th>벤치 A</th><th>벤치 B</th><th>차이</th></tr></thead><tbody><tr><td>법률</td><td>CLIcK 생활 법률 0.88</td><td>KMMLU 전문 법률 0.18</td><td>0.70</td></tr><tr><td>역사</td><td>HAE-RAE 역사 0.78</td><td>CLIcK 역사 0.46</td><td>0.32</td></tr></tbody></table>
<p>ko_commongen_v2에서는 평가 설계의 함정을 하나 봤습니다. 개념 몇 개를 주고 자연스러운 문장을 고르라는 건데, 선택지가 A나 B 같은 레이블이 아니라 문장 자체예요. 문장 네 개의 로그확률을 그대로 비교하면 짧은 문장이 유리해지거든요. 그래서 acc는 0.20으로 찍는 것보다 낮게 나오고, 길이로 정규화한 acc_norm이 0.30입니다. 지표 하나만 봤으면 모델이 문장을 아예 못 고른다고 결론 냈을 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="재는-도중에-멈춘-이야기">재는 도중에 멈춘 이야기<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EC%9E%AC%EB%8A%94-%EB%8F%84%EC%A4%91%EC%97%90-%EB%A9%88%EC%B6%98-%EC%9D%B4%EC%95%BC%EA%B8%B0" class="hash-link" aria-label="재는 도중에 멈춘 이야기에 대한 직접 링크" title="재는 도중에 멈춘 이야기에 대한 직접 링크" translate="no">​</a></h2>
<p>말이 좀 돌아갔는데, 사실 이 결과가 나오기까지 하루가 더 걸렸습니다. 처음 한 번은 limit 없이 전부 돌리려고 했거든요. KMMLU-Pro 2,822문항을 걸어놓고 보니 진행률 표시에 문제당 140초, 예상 완료 110시간이라고 찍혀 있었습니다. 앞서 돌린 KMMLU가 문제당 1.4초였는데 갑자기 100배가 느려진 겁니다.</p>
<p>한참을 원인을 찾았습니다. 프롬프트가 긴가 싶어서 재보니 평균 346자로 짧았고, 디스크가 느린가 싶어서 재보니 15.9GB/s로 정상이었어요. 그러다 문항 단위 로그를 뜯어보니까 61번까지는 3초씩 가다가 62번에서 17분을 멈추고, 그 다음부터 다시 2초로 돌아오더라고요. 지속적으로 느린 게 아니라 간헐적으로 한 번씩 멈추는 거였습니다. tqdm이 직전 구간 속도로 남은 시간을 계산하니까 스톨 직후에 110시간이라는 숫자가 튀어나온 거고요.</p>
<p>그러면 왜 멈추냐가 다음 질문이었는데, 여기서 제가 잘못 짚었습니다. 벤치를 태스크마다 새 프로세스로 돌리고 있었으니까 모델을 올리고 안 내려서 메모리가 쌓이는 건가 싶었어요. 확인해보니 아니었습니다. 남은 프로세스는 없었고, 실행 중인 걸 죽이니까 여유 메모리가 0.1GB에서 16GB로 바로 돌아왔어요. 진짜 원인은 다른 앱들이었습니다. 가상머신 하나가 6.2GB, 크롬 헬퍼들이 합쳐서 4.5GB, node가 1GB. 이것들이 15GB를 이미 먹고 있는 상태에서 모델 15.5GB를 얹으니까 macOS가 페이지를 회수하는 순간마다 모델이 스왑으로 밀려나는 거였습니다.</p>
<p>그런데 이때 vm_stat을 보면 free가 0.3GB인데 memory_pressure는 여유 90%라고 해요. 48GB 맥에서 메모리가 안 남는다는 게 이해가 안 됐는데, macOS가 free를 최소로 유지하고 나머지를 캐시로 쓰기 때문에 free 수치 자체는 정상인 거였습니다. 진짜 압박은 swapusage랑 같이 봐야 보이더라고요.</p>
<p>해결은 두 줄이었습니다. 모델 가중치를 wired memory에 고정해버리면 회수 대상에서 빠집니다.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> mlx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">core </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> mx</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GB </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1024</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1024</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1024</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">mx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">set_wired_limit</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">20</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> GB</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">mx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">set_cache_limit</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> GB</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>이걸 걸기 전엔 생성 평가에서 문항 하나에 1,233초가 걸렸는데 걸고 나니 16-76초로 돌아왔습니다. 결국에는 메모리가 쌓인 게 아니라 메모리를 지켜주지 않은 게 문제였던 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그런데-리더보드랑-다르네">그런데 리더보드랑 다르네<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EA%B7%B8%EB%9F%B0%EB%8D%B0-%EB%A6%AC%EB%8D%94%EB%B3%B4%EB%93%9C%EB%9E%91-%EB%8B%A4%EB%A5%B4%EB%84%A4" class="hash-link" aria-label="그런데 리더보드랑 다르네에 대한 직접 링크" title="그런데 리더보드랑 다르네에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 이상한 걸 발견했어요. 공식 리더보드에 같은 모델이 올라와 있는데 KMMLU-Pro가 0.549예요. 저는 0.42가 나왔거든요. 0.13 차이입니다.</p>
<p>자연스럽게 4bit 탓인가 싶었죠. 그런데 다른 벤치를 같이 놓고 보니까 그게 아니더라고요.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/05-3e2982c6f1998fb0eeae986b528565bb.png" width="2400" height="1080" class="zoomable_B8z7 img_ev3q"></p>
<table><thead><tr><th>벤치</th><th>리더보드</th><th>내 측정</th><th>차이</th></tr></thead><tbody><tr><td>Ko-MuSR</td><td>0.55</td><td>0.54</td><td>-0.01</td></tr><tr><td>CLIcK</td><td>0.763</td><td>0.717</td><td>-0.046</td></tr><tr><td>KMMLU-Pro</td><td>0.549</td><td>0.420</td><td>-0.129</td></tr></tbody></table>
<p>양자화가 성능을 깎았다면 모든 벤치가 고르게 내려가야 합니다. 근데 Ko-MuSR은 거의 일치하고 KMMLU-Pro만 크게 벌어져요. 추론이 필요한 벤치일수록 차이가 커지는 패턴이었습니다.</p>
<p>표본 크기도 봤습니다. 50문제에서 0.42였는데 100문제로 늘리니까 0.47로 올라가더라고요. 일부는 표본 때문이 맞는데 0.13을 다 설명하진 못해요.</p>
<p>그래서 논문을 열어봤습니다. KMMLU-Pro 논문[4]에 평가 프로토콜이 나와 있었어요.</p>
<blockquote>
<p>By default, we apply a zero-shot Chain-of-Thought (CoT) prompt written in Korean. ... We use greedy decoding for non-reasoning models, while for reasoning models, we adopt the temperature of 0.6 and top-p of 0.95.</p>
</blockquote>
<p>공식 평가는 CoT 생성 방식이었습니다. 모델이 실제로 풀이를 쓰고 답을 뽑는 겁니다.</p>
<p>저는 loglikelihood로 쟀으니까 애초에 같은 시험이 아니었던 겁니다. 이 모델은 reasoning 모델이라 thinking을 켜고 재는 게 공식인데, 저는 chat template을 꺼서 thinking 자체가 불가능한 상태로 잰 거고요. 심지어 논문은 reasoning 모델이 한국어 프롬프트에서 더 못하는 경우가 있어서 한국어랑 영어 둘 다 돌리고 높은 쪽을 보고했다고 합니다. 결국에는 같은 벤치 이름 아래 전혀 다른 시험이 두 개 있었던 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="cot로-다시-재봤더니">CoT로 다시 재봤더니<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#cot%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EC%9E%AC%EB%B4%A4%EB%8D%94%EB%8B%88" class="hash-link" aria-label="CoT로 다시 재봤더니에 대한 직접 링크" title="CoT로 다시 재봤더니에 대한 직접 링크" translate="no">​</a></h2>
<p>그럼 CoT로 재면 0.549에 가까워지겠네 싶었죠. 그래서 해봤는데 반대로 나왔습니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/06-374da0dc07df6a842dac050c3adcd3b4.png" width="2352" height="420" class="zoomable_B8z7 img_ev3q"></p>
<p>먼저 lm-eval의 생성 경로로 돌렸는데 이게 또 멈췄습니다. 프로세스가 36GB까지 부풀면서 50분 동안 CPU를 3분밖에 안 쓰더라고요. 문항마다 KV 캐시가 쌓이는데 해제를 안 하는 것 같았습니다. 그래서 lm-eval을 거치지 않고 문항마다 generate 하고 캐시를 비우는 루프를 직접 짰어요.</p>
<p>thinking을 끄고 CoT 프롬프트만 줬을 때 30문제에서 0.30이 나왔습니다. loglikelihood보다 낮아요. 예측이 D랑 E로 쏠리는 편향도 보였고요. 정답 분포는 균등한데 모델은 D를 열 번, E를 일곱 번 골랐습니다. thinking이 없는 CoT는 그냥 생성이라서, 풀이를 쓰다가 엉뚱한 데로 흘러갈 여지가 확률 비교보다 큰 것 같더라고요.</p>
<p>그래서 thinking을 켰습니다. 그랬더니 안 끝나요. 3,072토큰을 다 쓰고도 think 태그가 안 닫혀요. 6,144토큰으로 올렸더니 문제 하나에 7분을 쓰고 20,000자를 뱉었는데도 결론에 안 도달합니다. 민법의 법원(法源)을 묻는 5지선다 문제였는데, 생성 끝부분을 보면 이렇습니다.</p>
<blockquote>
<p>I recall a Korean bar exam question: "관습법에 관한 설명으로 옳지 않은 것은?" One option: "법원은 관...</p>
</blockquote>
<blockquote>
<p>Art 726: - Art 727: - Art 728: - Art 729: - Art 730: - Art 731: - Art 732: - Art 733:</p>
</blockquote>
<blockquote>
<p>mandatory? Let's recall hierarchy: "제정법이 있는 경우, 관습법은 적용할 수 없다. 다만, 제정법이 관습에 위임하는 경우, 또는 제정</p>
</blockquote>
<p>한국어 문제인데 영어로 생각하고, 기억나는 판례를 더듬다가, 어느 순간 민법 조문 번호를 726조부터 하염없이 나열합니다. 발산하는 겁니다. 재미있는 건 같은 문제를 thinking 끄고 물어보면 83초 만에 정답 D를 맞힙니다. 풀이도 멀쩡해요. 제정법이 관습법보다 상위라는 걸 짚고 D가 그 순서를 뒤집었다고 정확히 지적합니다. 생각을 안 하면 맞히고 생각을 하면 못 끝내는 상황인 거죠.</p>
<p>의미 있는 표본을 재려면 서너 시간이 필요한데 그마저 답이 나온다는 보장이 없어서 여기서 멈췄습니다.</p>
<p>여기서 하나 더 해봤습니다. Qwen3 계열에는 thinking budget이라는 게 있어요. 예산만큼만 생각하게 두고, 그 안에 안 끝나면 전환 문구를 끼워 강제로 사고를 닫고 답을 받는 방식입니다. 답이 안 나오는 상황 자체를 없앨 수 있는 겁니다.</p>
<p>budget 1024로 30문항을 돌리니 전부 답이 나왔습니다. 30문항 전부 예산에 걸렸는데도요. 그런데 점수는 0.27로 thinking off보다도 낮았어요. 1,024토큰이면 사고의 앞부분만 있는 상태라, 거기서 답을 내게 하면 찍는 거랑 크게 다르지 않은 겁니다. 2048로 올리니 0.40이 나왔습니다. loglikelihood랑 거의 같은 수준까지 올라온 거예요. 문항당 3분씩 걸렸고, 이번에도 30문항 전부 예산에 걸렸습니다.</p>
<p>그래서 4096까지 올려봤습니다. 문항당 6분씩 세 시간이 걸렸는데 0.43이 나왔어요. 2048에서 0.13 올랐던 게 4096에서는 0.03 오른 겁니다. 문항별로 대조하면 2048과 4096이 아홉 문항을 같이 맞히고 각각 세 개, 네 개만 따로 맞혀서 사실상 같은 결과였습니다. 30문항 중 29문항이 여전히 예산에 걸렸는데도요.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/07-f48bfb349baea279f738248adb547ffc.png" width="2400" height="1350" class="zoomable_B8z7 img_ev3q"></p>
<table><thead><tr><th>조건</th><th>acc</th><th>증가분</th><th>예산에 걸린 문항</th><th>문항당</th><th>생성 길이</th></tr></thead><tbody><tr><td>thinking off</td><td>0.30</td><td>&nbsp;</td><td>해당 없음</td><td>30초</td><td>661자</td></tr><tr><td>budget 1024</td><td>0.27</td><td>-0.03</td><td>30/30</td><td>90초</td><td>3,405자</td></tr><tr><td>budget 2048</td><td>0.40</td><td>+0.13</td><td>30/30</td><td>190초</td><td>6,295자</td></tr><tr><td>budget 4096</td><td>0.43</td><td>+0.03</td><td>29/30</td><td>347초</td><td>12,784자</td></tr></tbody></table>
<p>문항별로 어느 조건이 맞혔는지 겹쳐 보면 이렇습니다.</p>
<table><thead><tr><th>비교</th><th>둘 다 정답</th><th>앞만 정답</th><th>뒤만 정답</th><th>둘 다 오답</th></tr></thead><tbody><tr><td>off vs 1024</td><td>4</td><td>5</td><td>4</td><td>17</td></tr><tr><td>off vs 2048</td><td>5</td><td>4</td><td>7</td><td>14</td></tr><tr><td>2048 vs 4096</td><td>9</td><td>3</td><td>4</td><td>14</td></tr></tbody></table>
<p>결국에는 곡선이 2048 근처에서 꺾인 건데, 이 4bit 모델은 예산을 더 줘도 0.42-0.43이 상한으로 보입니다. 리더보드의 0.549까지 남은 0.12는 예산으로는 안 메워지고, 양자화 자체나 프롬프트 언어 같은 다른 요인의 몫인 셈입니다. 30문항 표본이라 오차가 0.09쯤 되니 숫자 하나하나는 확정적이지 않지만, off 0.30에서 1024 0.27, 2048 0.40, 4096 0.43으로 방향은 일관됐습니다.</p>
<p>정리하면 이렇습니다. 리더보드와의 차이는 양자화가 정확도를 직접 깎아서가 아니었습니다. 평가 방식이 달랐고, 그 방식의 핵심인 thinking이 4bit 모델에서는 안정적으로 수렴하지 않았습니다. 예산을 걸어 억지로 끝내면 점수가 올라오긴 하는데 2048 근처에서 꺾여서, 예산만으로는 리더보드 수치에 닿지 않습니다. 양자화가 정확도가 아니라 추론 경로의 안정성을 건드렸을 가능성이 있는 겁니다. 확증하려면 원본 정밀도 모델로 같은 실험을 해야 하는데 이 맥에서는 불가능합니다. 가설로만 남겨둡니다.</p>
<p>참고로 논문을 보면 공식 평가는 A100 16장으로 4일이 걸렸다고 합니다. 단일 맥에서 재현할 규모가 애초에 아니었던 겁니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그래서-업무에-쓸-수-있나">그래서 업무에 쓸 수 있나<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EA%B7%B8%EB%9E%98%EC%84%9C-%EC%97%85%EB%AC%B4%EC%97%90-%EC%93%B8-%EC%88%98-%EC%9E%88%EB%82%98" class="hash-link" aria-label="그래서 업무에 쓸 수 있나에 대한 직접 링크" title="그래서 업무에 쓸 수 있나에 대한 직접 링크" translate="no">​</a></h2>
<p>처음 질문으로 돌아오면 이렇습니다.</p>
<p>문서 분류나 한국 문화 맥락을 읽는 지식형 작업 같은 것들은 4bit로도 쓸 만합니다. 벤치 수치가 그렇게 말하고 있고, 문항을 직접 보면서도 그런 느낌이었어요.</p>
<p>추론이 필요한 것들은 좀 다릅니다. 이 모델의 성능은 thinking에서 나오는데, 4bit에서 thinking이 느리고 불안정합니다. 업무에 붙이려면 thinking을 켜야 하고, thinking budget으로 답이 안 나오는 건 막을 수 있지만 점수가 2048 근처에서 꺾이니 문제 하나에 3분을 쓰고 0.40을 얻는 게 현실적인 선입니다. 이건 원본 정밀도로 검증해야 하는데 로컬에서는 방법이 없어요.</p>
<p>시간 얘기에는 단서를 하나 달아야 합니다. 여기 나온 문항당 3분, 6분은 M4 Pro에서 mlx_lm으로 요청을 하나씩 순서대로 처리한 시간이에요. 실제 서비스는 vLLM 같은 서빙 엔진이 continuous batching으로 요청 수십 개를 한 번에 돌리고, LMCache 같은 것들이 KV cache를 저장해뒀다가 같은 프롬프트 앞부분을 다시 계산하지 않게 해줍니다. 그러니 처리량 기준으로는 이 글의 숫자보다 훨씬 빠르고, 벤치 자체도 문항을 배치로 던졌으면 세 시간이 아니라 몇십 분에 끝났을 겁니다.</p>
<p>다만 요청 하나가 끝나는 시간은 다른 문제예요. thinking이 4,000토큰을 뽑는 동안 사용자는 기다려야 하고, 그 속도는 서빙 엔진이 아니라 하드웨어가 정합니다. 이 맥은 초당 9.6토큰인데 A100이면 그 몇 배가 나오니까, 같은 4,096 예산이라도 서버에서는 6분이 아니라 2분 안쪽이 되는 겁니다. 결국에는 느리다는 건 이 환경의 이야기이고 인프라를 갖추면 달라지지만, 0.43이라는 상한은 인프라를 바꿔도 그대로입니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/08-3b45defb4330108b13d9cb85a7e1dc00.png" width="2352" height="1662" class="zoomable_B8z7 img_ev3q"></p>
<p>여기서 하나 짚을 게 있습니다. loglikelihood 0.42랑 thinking 4096의 0.43이 같다는 건, 이 모델이 4,096토큰을 생각해도 확률만 읽은 것과 같은 답을 낸다는 뜻이에요. 사고가 더해주는 게 없는 겁니다. 그러니 선택지가 정해진 작업, 그러니까 문서를 카테고리 몇 개 중 하나로 분류한다거나 의도를 골라내는 것들이라면 thinking을 켤 이유가 없습니다. logprobs 비교가 같은 정확도를 180배 빠르게 줍니다.</p>
<p>다만 두 가지는 조심해야 합니다. KMMLU-Pro가 아는 걸 꺼내는 문제라서 사고가 소용없었을 가능성이 크고, 다단계 추론이 필요한 Ko-MuSR에서는 loglikelihood가 2지선다에서 0.52로 찍는 수준이었으니 거기서는 뒤집힐 수 있습니다. 그리고 원본 정밀도는 thinking으로 0.549까지 가니까, 4bit에서 사고의 이득이 사라진 거라면 양자화가 그 몫을 깎았다는 얘기가 됩니다.</p>
<p>모델을 벤치하는 방법 자체는 이렇게 정리가 됩니다. 모델을 직접 로드하는 경로를 잡고, 객관식이면 chat template을 끄고, 생성 평가면 wired memory를 고정하고, 리더보드와 비교하려면 그쪽 프로토콜을 먼저 읽어야 합니다. 그리고 reasoning 모델이면 thinking을 켠 상태의 수치와 끈 상태의 수치가 완전히 다른 물건이라는 걸 알고 봐야 합니다.</p>
<p>가장 크게 배운 건 이겁니다. 벤치 수치는 그 프로토콜에서의 수치일 뿐이다. 같은 벤치 이름이어도 loglikelihood인지 생성인지, thinking을 켰는지, 프롬프트가 한국어인지 영어인지에 따라 숫자가 다 달라집니다. 리더보드 수치를 보고 이 모델 좋다고 판단하는 건 그 프로토콜과 그 하드웨어에서 좋다는 뜻이고, 내 환경에서는 내가 재봐야 압니다.</p>
<p>결국에는 벤치 수치를 믿을 게 아니라 벤치를 돌리는 법을 알아야 했던 거죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="삽질에서-건진-것">삽질에서 건진 것<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EC%82%BD%EC%A7%88%EC%97%90%EC%84%9C-%EA%B1%B4%EC%A7%84-%EA%B2%83" class="hash-link" aria-label="삽질에서 건진 것에 대한 직접 링크" title="삽질에서 건진 것에 대한 직접 링크" translate="no">​</a></h2>
<p>장시간 벤치 산출물은 처음부터 영구 경로에 쓰세요. 임시 디렉토리에 두고 돌리다가 세션이 재시작되면서 결과랑 리포트가 한 번 통째로 날아갔습니다. ㅜ</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/09/04/qwen3-8-27b-on-mac#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] EleutherAI, lm-evaluation-harness. <a href="https://github.com/EleutherAI/lm-evaluation-harness" target="_blank" rel="noopener noreferrer" class="">https://github.com/EleutherAI/lm-evaluation-harness</a></p>
<p>[2] LM Studio, Open Responses API 발표. <a href="https://lmstudio.ai/blog/openresponses" target="_blank" rel="noopener noreferrer" class="">https://lmstudio.ai/blog/openresponses</a></p>
<p>[3] mlx_lm 0.31.3의 evaluate 서브커맨드. python3 -m mlx_lm evaluate --help 참조</p>
<p>[4] Hong et al., From KMMLU-Redux to Pro: A Professional Korean Benchmark Suite for LLM Evaluation. arXiv:2507.08924. 데이터셋은 LGAI-EXAONE/KMMLU-Pro (HF, gated)</p>
<p>[5] thunder-research-group/SNU_Ko-MuSR (HF, gated). 원본 MuSR의 한국어판</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[화면의 시대에서 코드의 시대로]]></title>
            <link>https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code</link>
            <guid>https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[도곡동 그라파나 밋업의 발표 다섯 개를 듣고 남은 두 질문, 그라파나의 경계를 어디에 그을 것인가와 화면을 누가 읽게 할 것인가를 정리했습니다.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-ef5e569853a820c1b7a1bcb0b6cdc170.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p>8월 30일 일요일, 어제 도곡동에서 열린 그라파나 밋업에 다녀왔습니다. 발표가 다섯 개였는데 스펙트럼이 되게 넓었죠. 셀프스토리지 스타트업부터 삼성전자 반도체, AI 스타트업까지. 저도 고객사 관측 스택을 LGTM으로 운영하는 입장이라 도구 팁이나 몇 개 주워오자는 마음으로 갔는데, 다녀와보니 이게 도구 얘기가 아니더라고요. 다섯 팀이 전부 다른 문제를 들고 나왔는데 결이 같았습니다. 결국에는 두 가지 질문으로 모이더라고요.</p>
<p>그라파나의 경계를 어디에 그을 것인가, 그리고 이 화면들을 누가 읽게 할 것인가.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/02-446fdaa55926c1cbd3fb082cc2347c6e.png" width="978" height="1352" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="다섯-개의-발표-짧은-스케치">다섯 개의 발표, 짧은 스케치<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EB%8B%A4%EC%84%AF-%EA%B0%9C%EC%9D%98-%EB%B0%9C%ED%91%9C-%EC%A7%A7%EC%9D%80-%EC%8A%A4%EC%BC%80%EC%B9%98" class="hash-link" aria-label="다섯 개의 발표, 짧은 스케치에 대한 직접 링크" title="다섯 개의 발표, 짧은 스케치에 대한 직접 링크" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/03-0b0d6251a1428b92a19fb3298beb79d3.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p>세컨신드롬의 미니창고 다락 발표는 제목부터 그라파나 그렇게 쓰는 거 아닌데?였습니다. 프론트엔드 리소스가 없어서 그라파나를 읽기 전용 어드민처럼 쓰다가, 쓰기 기능까지 붙였다가, 선을 그은 이야기죠.</p>
<p>AB180은 10년 동안 난립한 얼럿을 갈아엎은 이야기를 했습니다. 그라파나 콘솔에서 손으로 만들고, 뉴렐릭에서 만들고, AWS 콘솔에서 만들고, 테라폼으로도 만들다 보니 뭐가 어디 있는지 아무도 모르는 상태가 됐다는 거죠. 개편의 기준이 흥미로웠는데, 에이전틱 코딩이 잘 먹히는 구조를 최우선으로 잡았습니다.</p>
<p>세 번째 발표는 넛지 헬스케어의 Tempo 2를 RF1로 운영하다 겪은 장애 분석이었습니다. 인제스터 아홉 대 중 한 대가 OOM 났는데 메트릭 수신량이 90퍼센트 무너졌죠. 직관적으로는 11퍼센트만 죽어야 하는데요. 원인은 배치로 묶인 리퀘스트의 병렬 쓰기 중 하나만 실패해도 전체가 실패 처리되는 코드 경로였고, 배치에 트레이스가 N개 들어 있으면 성공 확률이 (8/9)의 N승으로 지수적으로 떨어지는 구조였습니다. 실측 배치 평균이 26개였으니 답이 나오죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/04-a3251555207158147c014a9814732664.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p>삼성전자 반도체 쪽에서 플랫폼을 운영하시는 분은 사고 세 개를 풀어놓으셨습니다. 10년 방치된 형상관리 서버의 디스크 폴트로 데이터를 하루치 날린 이야기, 5년 묵은 국제망 지연의 범인이 스위치 한 대의 버퍼 오버플로우 버그였다는 이야기, 그리고 지금 GPU 100장으로 임직원 9천 명에게 LLM을 서빙하면서 테스트를 줄이고 모니터링으로 버티는 이야기까지요.</p>
<p>마지막으로 사이오닉 AI는 시켜만 놓으면 되는 그라파나라는 제목으로, 에이전트가 대시보드를 만들고 읽게 하는 구조를 보여줬습니다. 이제 인간이 개입하는 게 오히려 속도를 늦춘다는 도발적인 전제로 시작했는데, 발표 주제를 당일 새벽에 바꿨다고 하시더라고요. 그 속도감 자체가 발표 내용과 묘하게 어울렸습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="다들-선을-긋고-있더라">다들 선을 긋고 있더라<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EB%8B%A4%EB%93%A4-%EC%84%A0%EC%9D%84-%EA%B8%8B%EA%B3%A0-%EC%9E%88%EB%8D%94%EB%9D%BC" class="hash-link" aria-label="다들 선을 긋고 있더라에 대한 직접 링크" title="다들 선을 긋고 있더라에 대한 직접 링크" translate="no">​</a></h2>
<p>첫 번째 공통점부터 얘기하면, 다섯 팀이 전부 경계 이야기를 하고 있었습니다.</p>
<p>다락이 제일 노골적이었죠. 읽기 전용 어드민까지는 되게 잘 먹혔답니다. 요청이 오면 그라파나로 하루 만에 화면을 만들어서 2주에서 4주 지켜보고, 아무도 안 보면 드랍하고, 의미가 있으면 그때 어드민으로 본격 개발하는 방식이요. MVP 탐색 도구로는 최고였다는 겁니다. 그런데 Business Forms 플러그인[1]으로 POST API를 붙여서 운영 처리까지 시키는 순간 얘기가 달라졌다고 하더라고요. 리드 권한과 리드라이트 권한을 나눠 관리하던 체계에 그라파나가 쓰기 주체로 끼어들면서 관리 포인트가 늘고, 화면 하나 고칠 때마다 프론트도 보고 그라파나도 봐야 하는 상태가 된 거죠. 그래서 변화가 적은 간단한 기능까지만 하기로 선을 그었습니다.</p>
<p>AB180의 얼럿 프록시도 같은 구조의 결정입니다. 그라파나 얼럿은 판정 엔진으로만 쓰고, 판정 이후의 모든 것들은 자체 프록시 API 서버가 받아서 팬아웃합니다. 슬랙 포매팅, ack와 리졸브 버튼, 담당자 멘션, 그룹핑, 재알림 같은 것들을 전부 프록시 뒤로 모았죠. 그라파나가 잘하는 것과 조직이 직접 쥐어야 하는 것 사이에 명확한 금을 그은 겁니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/05-b20fdd8dc6636c111c5d46afc95f6a29.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/06-d4fa38b5d538d6f3929f5c2903268aee.png" width="1568" height="204" class="zoomable_B8z7 img_ev3q"></p>
<p>Tempo 3.0은 이 경계 이야기의 아키텍처 버전이었습니다. 쓰기 내구성의 경계를 인제스터 쿼럼에서 카프카 같은 듀러블 스트림으로 옮겼죠[2]. 스트림에 적재되는 순간 쓰기가 완료되고, 블록 빌더와 메트릭스 제너레이터는 독립 컨슈머로 각자 소비합니다. 한 컴포넌트의 실패가 다른 경로를 무너뜨리던 결합이 경계 이동으로 사라진 겁니다. 물론 발표자분도 짚었지만 공짜는 아니에요. 카프카 운영과 컨슈머 랙 관리라는 새 숙제가 생기죠.</p>
<p>경계 안쪽을 파고든 사례도 있었습니다. 다락은 전사가 그라파나를 쓰기 시작하면서 DB 세션이 계속 늘었는데, 5퍼센트 단위로 계약 증감을 보는 장표가 사실상 같은 쿼리를 12번 날리는 구조였다고 해요. 파티셔닝과 인덱싱으로는 별로 안 잡히더랍니다. 답은 그라파나의 대시보드 데이터 소스였죠. 데이터를 한 번만 가져오고 트랜스포메이션으로 12개 패널을 나눠 그리니 세션이 뚝 떨어졌다는 겁니다. 경계를 긋는 것과 별개로, 그은 경계 안에서는 도구가 주는 것들을 끝까지 써먹는 모습이었어요.</p>
<p>결국에는 도구를 잘 쓴다는 게 기능을 많이 쓴다는 뜻이 아니라 어디서 멈출지를 안다는 뜻이더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="에이전트한테는-화면이-아니라-코드를-주더라">에이전트한테는 화면이 아니라 코드를 주더라<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%ED%95%9C%ED%85%8C%EB%8A%94-%ED%99%94%EB%A9%B4%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A3%BC%EB%8D%94%EB%9D%BC" class="hash-link" aria-label="에이전트한테는 화면이 아니라 코드를 주더라에 대한 직접 링크" title="에이전트한테는 화면이 아니라 코드를 주더라에 대한 직접 링크" translate="no">​</a></h2>
<p>두 번째 공통점이 제가 이 밋업에서 진짜 가져온 것들입니다.</p>
<p>에이전트 시대의 관측성은 화면이 아니라 코드다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/07-b8da39852d6558841df135179e6dfb3a.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p>사이오닉의 여정이 이걸 압축해서 보여줍니다. 처음에는 클로드 코드에 브라우저를 띄워서 그라파나를 직접 조작하게 했답니다. 되긴 되는데 너무 오래 걸리고, 언제 끝날지 모르고, 제대로 못 하더라는 거죠. 그래서 그라파나 Git Sync[3]로 방향을 틀었습니다. 대시보드가 Git의 코드가 되니까 에이전트는 기존 YAML과 팀의 패턴을 읽고, 대시보드를 직접 만드는 게 아니라 PR을 올립니다. 여기서 되게 영리한 부분이, Git Sync가 PR에 달아주는 비포와 애프터 프리뷰 렌더[4]를 에이전트의 눈으로 쓴다는 점이에요. 커밋을 올리고, 렌더된 패널을 읽고, 노 데이터가 떴으면 스스로 고치고. 사람이 하던 리뷰 루프를 그대로 에이전트한테 학습시킨 거죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/08-b5a3a82dc6b195d577054bd2b43e6317.png" width="4000" height="3000" class="zoomable_B8z7 img_ev3q"></p>
<p>AB180은 반대편에서 같은 결론에 도착했습니다. 얼럿 개편의 목표 자체가 코드화, 중앙화, 자동화였는데, 이 세 가지가 사실 에이전틱 코딩이 잘 돌아가기 위한 조건이거든요. 대표님이 수신자로 남아 있던 오래된 얼럿들을 정리할 때, 예외 조건까지 자연어로 지시하면 에이전트가 코드를 고치고 푸시하면 자동 배포까지 흘러갑니다. 발표자분은 MCP도 써봤는데 너무 느려서 안 쓴다고 하셨어요. 코드를 직접 고치는 게 빠르고, 눈에 보이니까 마음도 편하다는 거죠.</p>
<p>서로 모르는 두 회사가 각자 부딪혀보고 같은 자리에 도착했다는 게 포인트입니다. 에이전트한테 사람 쓰라고 만든 인터페이스를 쥐여주면, 아니죠, 쥐여줄 수는 있는데 느리고 불안합니다. 에이전트가 잘하는 형태로 세계 쪽을 바꿔주는 게 답이더라는 겁니다. 대시보드가 코드가 되고, 변경이 PR이 되고, 검증이 CI와 프리뷰가 되면 에이전트는 원래 잘하던 일을 하면 되니까요.</p>
<p>읽는 쪽도 마찬가지였습니다. 사이오닉은 옵저버라는 별도 에이전트를 뒀는데, 얼럿매니저가 알람을 던지면 옵저버가 미리 학습해둔 서비스 매핑을 따라 트레이스와 로그와 소스코드를 분석해서 초기 진단을 스레드로 남깁니다. 에러 영향도와 신뢰도까지 붙여서요. 재미있는 건 그 다음이에요. 그 회사 개발자들은 이제 거의 에디터를 안 쓰고 각자 에이전트로 개발한다는데, 그래서 진단 결과를 사람만 읽는 게 아니라 개발자의 에이전트가 MCP로 받아다 쓰는 플로우를 그리고 있답니다. 에이전트가 에이전트를 읽는 A2A 구조를 만들려고 노력 중이라는 표현을 쓰셨어요.</p>
<p>삼성 쪽 발표는 이 흐름의 전제 조건을 보여줬다고 생각합니다. 모델이 일주일에 두 개씩 나오는 상황에서 정식 카나리 절차로는 속도를 못 따라가니까, 테스트를 줄이는 대신 배포 전에 대시보드부터 전부 띄워놓고 문제가 보이면 즉시 원상복구하는 쪽으로 전략을 뒤집었다고 하셨죠. 리스크를 제로로 만드는 게 아니라 최소 리스크에서 빠르게 되돌리는 구성. 이게 가능하려면 관측이 배포보다 먼저 준비되어 있어야 합니다. 사이클을 빨리 돌리는 쪽이 이기는 시대에, 관측은 사이클의 결과물이 아니라 선행 조건이 되는 것들이죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/09-d28b54f1b2531eb24321a23930726409.png" width="1568" height="228" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="관측을-운영하는-입장에서">관측을 운영하는 입장에서<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EA%B4%80%EC%B8%A1%EC%9D%84-%EC%9A%B4%EC%98%81%ED%95%98%EB%8A%94-%EC%9E%85%EC%9E%A5%EC%97%90%EC%84%9C" class="hash-link" aria-label="관측을 운영하는 입장에서에 대한 직접 링크" title="관측을 운영하는 입장에서에 대한 직접 링크" translate="no">​</a></h2>
<p>말이 좀 돌아갔는데, 저희처럼 고객 인프라를 관측하고 운영하는 쪽에서 이 밋업을 어떻게 소화해야 하나를 좀 붙여보겠습니다.</p>
<p>먼저 순서에 대한 확신입니다. 장애 분석을 에이전트한테 시키는 시도는 지금 다들 하고 있죠. 그런데 이번에 확인한 건, 에이전트를 붙이기 전에 관측 자산부터 에이전트가 읽을 수 있는 형태로 바꾸는 게 먼저라는 겁니다. 사이오닉의 옵저버 에이전트가 초기 진단을 낼 수 있는 건 서비스 매핑과 배포 형상이 미리 정리되어 있기 때문이고, AB180의 에이전트가 얼럿을 일괄 수정할 수 있는 건 얼럿이 전부 한 레포의 YAML이기 때문이거든요. 에이전트의 성능을 올리는 것보다 에이전트가 밟고 설 땅을 고르는 게 먼저더라고요.</p>
<p>그리고 삼성 발표의 명제, 옵저버빌리티는 사고 후의 도구가 아니라 사고 전의 습관이라는 말은 에이전트 시대에 오히려 더 무거워집니다. 그분 표현으로는 원인이 사실 되게 작대요. 디스크 한 장, 장비 한 대. 그걸 건초더미에서 바늘 찾듯 찾아야 하는데, 데이터가 미리 쌓여 있지 않으면 사람이든 에이전트든 찾을 방법 자체가 없죠. 에이전트가 읽는 주체가 되어도 읽을거리는 사고 전에 사람이 습관으로 쌓아둬야 합니다.</p>
<p>MCP 얘기도 잠깐 하면, 이번 밋업에서 MCP에 대한 태도가 두 갈래로 갈렸다는 게 흥미로웠습니다. AB180은 느려서 버렸고, 사이오닉은 개발자의 에이전트가 옵저버를 MCP로 읽어가는 플로우를 그리고 있습니다. 모순처럼 보이는데 뜯어보면 용도가 달라요. 상태를 바꾸는 조작은 코드와 PR로, 분석 결과를 가져오는 조회는 MCP로. 쓰기는 리뷰와 형상이 남아야 하니 Git을 타는 게 맞고, 읽기는 왕복 한 번이면 되니 MCP가 맞는 거죠. 도구 자체의 우열이 아니라 어느 자리에 두느냐의 문제였습니다.</p>
<p>하나 더, AB180 Q&amp;A에서 나온 답변이 계속 남습니다. 서서히 잠식되는 비용 같은 미지의 문제를 미리 감지할 수 있느냐는 질문에, 10년 해보니 쉽지 않더라, 분기 점검 때 하나씩 진짜 걸리더라, 그래서 주기 점검이 답인 것 같다고 하셨거든요. 이벤트 기반으로 다 잡겠다는 꿈은 저도 있지만, 미지의 문제는 정의상 얼럿 룰이 없습니다. 주기적으로 사람이 눈으로 훑는 시간은 에이전트가 아무리 좋아져도 당분간 빼기 어려운 것 같아요. 굳이 연결하면 이 훑는 일이야말로 나중에 에이전트한테 넘기기 좋은 후보겠지만, 그것도 훑을 데이터가 쌓여 있어야 가능한 얘기죠.</p>
<p>이 기준을 저한테 되돌려보면 좀 뜨끔한 것들이 있습니다. 저도 그동안 대시보드를 만들 때 보는 사람이 이해할까만 따졌지, 읽는 쪽이 맥락 없이 와도 판단할 수 있게 되어 있나는 잘 안 따졌거든요. 삼성 발표에 좋은 예가 있었는데, GPU 지표를 평균의 평균으로 뭉쳐 보여주면서 대신 이 수치는 정확하지 않다, 일정 수준을 넘으면 지연이 생긴다는 설명을 대시보드에 같이 적어놨다고 합니다. 그랬더니 사용자들이 지금 느린 게 내 문제가 아니라 GPU 문제구나를 스스로 판단하고, 심지어 여유 있는 모델로 알아서 옮겨가더라는 거죠. 수치의 한계까지 화면에 적어두는 것. 읽는 주체가 사람이든 에이전트든, 판단에 필요한 맥락을 데이터 옆에 붙여두는 습관부터가 시작인 것 같습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="마치며">마치며<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EB%A7%88%EC%B9%98%EB%A9%B0" class="hash-link" aria-label="마치며에 대한 직접 링크" title="마치며에 대한 직접 링크" translate="no">​</a></h2>
<p>다섯 발표를 한 문장씩으로 접으면 이렇게 됩니다. 다락은 그라파나로 어디까지 버틸 수 있는지를, AB180은 어디부터 직접 쥐어야 하는지를, Tempo 발표는 경계를 옮기면 구조가 어떻게 풀리는지를, 삼성은 그 모든 것 이전에 쌓는 습관을, 사이오닉은 쌓인 것을 읽는 주체가 바뀌고 있음을 보여줬습니다.</p>
<p>결국에는 관측성이라는 게 대시보드를 예쁘게 만드는 일이 아니라, 시스템이 스스로를 설명할 수 있는 형태로 세계를 정리해두는 일이더라고요. 그 설명을 읽는 쪽이 사람에서 에이전트로 넓어지고 있을 뿐입니다. 그리고 읽는 쪽이 누구든, 쌓는 쪽의 습관과 경계 감각은 여전히 사람의 몫이었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/08/31/grafana-meetup-screens-to-code#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] Business Forms 패널 플러그인. VolkovLabs 제작, 그라파나 대시보드에서 데이터 입력과 API 호출을 지원한다. <a href="https://grafana.com/grafana/plugins/volkovlabs-form-panel/" target="_blank" rel="noopener noreferrer" class="">https://grafana.com/grafana/plugins/volkovlabs-form-panel/</a></p>
<p>[2] Tempo 3.0 릴리즈. 카프카 호환 메시지 큐를 쓰기 경로의 듀러블 WAL로 두면서 RF3 요구사항을 제거했다. <a href="https://grafana.com/blog/tempo-3-0-release-all-the-latest-features/" target="_blank" rel="noopener noreferrer" class="">https://grafana.com/blog/tempo-3-0-release-all-the-latest-features/</a></p>
<p>[3] Grafana Git Sync. 대시보드와 폴더를 Git 저장소와 양방향 동기화하는 기능으로 2026년 4월 GA. <a href="https://grafana.com/whats-new/2026-04-20-git-sync-for-grafana-dashboards-and-folders-now-generally-available/" target="_blank" rel="noopener noreferrer" class="">https://grafana.com/whats-new/2026-04-20-git-sync-for-grafana-dashboards-and-folders-now-generally-available/</a></p>
<p>[4] Git Sync의 PR 프리뷰. PR이 열리면 원본과 수정본 대시보드의 스크린샷과 프리뷰 링크가 코멘트로 달린다. <a href="https://grafana.com/docs/grafana/latest/as-code/observability-as-code/git-sync/git-sync-setup/set-up-extend/" target="_blank" rel="noopener noreferrer" class="">https://grafana.com/docs/grafana/latest/as-code/observability-as-code/git-sync/git-sync-setup/set-up-extend/</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[운영 DB에 쿼리 타임아웃을 걸어야 하는 이유]]></title>
            <link>https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout</link>
            <guid>https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[파라미터 값 하나가 비어 있어 RDS MariaDB가 엿새에 걸쳐 스토리지 고갈로 죽어간 장애를 추적한 과정과, 운영 DB에 statement timeout이 필수인 이유를 정리했습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>파라미터 값 하나가 비어 있었고, DB는 엿새에 걸쳐 조용히 죽어갔습니다.<br>
<!-- -->쿼리 두 개가 끝나지 않은 채 디스크를 갉아먹는 동안 어떤 로그에도, 어떤 알림에도 흔적이 없었어요.<br>
<!-- -->RDS MariaDB 스토리지 고갈 장애를 추적한 과정과, 그 끝에서 내린 결론을 정리했습니다.</p>
</blockquote>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-317de3051998041f6444327067a5560d.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결론부터-말하겠습니다">결론부터 말하겠습니다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EA%B2%B0%EB%A1%A0%EB%B6%80%ED%84%B0-%EB%A7%90%ED%95%98%EA%B2%A0%EC%8A%B5%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="결론부터 말하겠습니다에 대한 직접 링크" title="결론부터 말하겠습니다에 대한 직접 링크" translate="no">​</a></h2>
<p>운영 데이터베이스에는 statement timeout이 걸려 있어야 한다.</p>
<p>MariaDB라면 <code>max_statement_time</code>,</p>
<p>MySQL이라면 <code>max_execution_time</code>,</p>
<p>PostgreSQL이라면 <code>statement_timeout</code>입니다.</p>
<p>이름은 달라도 역할은 같아요. 지정한 시간을 넘긴 쿼리를 강제로 끊는 값이죠.</p>
<p>저는 얼마 전까지 이 값을 필수라고 생각하지 않았습니다. 정상적인 배치가 끊길 수 있으니 함부로 걸면 안 되는 값, 필요해지면 그때 거는 값 정도로 여겼어요. 그런데 이 값 하나가 비어 있어서 DB가 엿새에 걸쳐 죽어가는 걸 처음부터 끝까지 추적하고 나서 생각이 완전히 바뀌었습니다.</p>
<p>왜 그렇게 됐는지, 사건을 따라가면서 말씀드리죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="장애-당일---알림은-사후-통보였다">장애 당일 - 알림은 사후 통보였다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%9E%A5%EC%95%A0-%EB%8B%B9%EC%9D%BC---%EC%95%8C%EB%A6%BC%EC%9D%80-%EC%82%AC%ED%9B%84-%ED%86%B5%EB%B3%B4%EC%98%80%EB%8B%A4" class="hash-link" aria-label="장애 당일 - 알림은 사후 통보였다에 대한 직접 링크" title="장애 당일 - 알림은 사후 통보였다에 대한 직접 링크" translate="no">​</a></h2>
<p>밤 9시가 조금 넘어 critical 알림이 왔습니다. 저희가 운영하는 백엔드, RDS MariaDB 10.11(db.m7g.xlarge, 4 vCPU)의 여유 스토리지 부족 알림이었어요.</p>
<p>접속해보니 이미 응답이 없었습니다. 대시보드에서 <code>DatabaseConnections</code>가 0으로 떨어져 있고 <code>CPUUtilization</code>도 1.7%까지 내려가 있더라고요. RDS가 인스턴스를 <code>STORAGE_FULL</code> 상태로 전환하면서 신규 접속을 차단한 상태였습니다.[1]</p>
<p>스토리지를 증설해 서비스를 살렸고, 그동안 홈페이지 응답 시간은 0.02초에서 최대 13.9초까지 올라가 있었고요.</p>
<p>복구하고 나서 알림 룰의 State history부터 열어봤습니다.</p>
<table><thead><tr><th>시각 (KST)</th><th>전이</th><th>FreeStorageSpace</th></tr></thead><tbody><tr><td>21:03:20</td><td>Normal → Pending</td><td>2.26 GiB</td></tr><tr><td>21:08:20</td><td>Pending → Alerting</td><td>0.00 GiB</td></tr><tr><td>23:51:20</td><td>Alerting → Normal</td><td>131.56 GiB</td></tr></tbody></table>
<p>DB가 멈춘 건 21시 5분이고 알림이 Alerting으로 넘어간 건 21시 8분입니다.</p>
<p>알림이 장애를 알려준 게 아니라 장애가 끝난 뒤에 울린 거예요. 임계값은 10GiB였는데, 나중에 계산해보니 이 환경에서 그건 세 시간짜리 여유였고, 마지막 45GiB는 10분 만에 사라졌습니다. 임계값을 다섯 배로 올렸어도 결과는 같았겠죠.</p>
<p>단순 용량 부족이 아니라는 뜻이죠. 원인을 찾으러 들어갔습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="추적-1---트래픽-때문이-아니었다">추적 1 - 트래픽 때문이 아니었다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%B6%94%EC%A0%81-1---%ED%8A%B8%EB%9E%98%ED%94%BD-%EB%95%8C%EB%AC%B8%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4" class="hash-link" aria-label="추적 1 - 트래픽 때문이 아니었다에 대한 직접 링크" title="추적 1 - 트래픽 때문이 아니었다에 대한 직접 링크" translate="no">​</a></h2>
<p>가장 먼저 의심할 건 뭘까요. 사용량 증가죠. 대시보드의 스토리지 패널을 열고 시간 범위를 일주일로 늘려봤습니다.</p>
<p>처음엔 그냥 우하향 그래프구나 했는데, 자세히 보니 좀 이상했어요. 자로 그은 것처럼 직선입니다. 보통 이런 그래프는 낮에 가파르고 새벽에 완만해지면서 계단처럼 울퉁불퉁하거든요. 패널을 테이블 뷰로 바꿔서 시간별 값을 확인했습니다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">08-25 03:00   210.27 GiB</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">08-25 04:00   207.83 GiB     -2.45</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">08-26 14:00   100.25 GiB     -3.16</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">08-26 15:00    97.09 GiB     -3.16</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">08-26 16:00    93.90 GiB     -3.19</span><br></div></code></pre></div></div>
<p>새벽 3시와 낮 2시의 감소량이 같습니다. 시간당 3.2GiB에서 소수점 둘째 자리까지 흔들리지 않아요. 홈페이지는 낮과 새벽의 트래픽 차이가 큰데, 감소 속도는 시간대와 무관했죠. 사용량이 아니라 시간에만 비례해서 뭔가가 쌓이고 있다는 뜻이죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/02-532960bf3e9073e4e64c86bdb7ab3422.png" width="1760" height="600" class="zoomable_B8z7 img_ev3q"></p>
<p>그래프에서 두 가지가 더 보였습니다. 08-21 오전 9시까지는 385.88GiB에서 선이 완전히 평평합니다. 그러다 10시부터 기울기가 생기고, 08-24 오후 4시에 기울기가 한 번 더 꺾여 두 배가 돼요. 계단이 두 번 생긴 거죠. 기억해두세요. 이 두 시각이 나중에 다시 나옵니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="추적-2---백업도-덤프도-아니었다">추적 2 - 백업도, 덤프도 아니었다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%B6%94%EC%A0%81-2---%EB%B0%B1%EC%97%85%EB%8F%84-%EB%8D%A4%ED%94%84%EB%8F%84-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4" class="hash-link" aria-label="추적 2 - 백업도, 덤프도 아니었다에 대한 직접 링크" title="추적 2 - 백업도, 덤프도 아니었다에 대한 직접 링크" translate="no">​</a></h2>
<p>고갈 직전 구간으로 범위를 좁히고 해상도를 1분까지 낮췄습니다.</p>
<table><thead><tr><th>시각</th><th>ReadThr</th><th>WriteThr</th><th>NetTx</th><th>NetRx</th><th>QueueDepth</th></tr></thead><tbody><tr><td>20:57</td><td>0.01</td><td>0.94</td><td>0.17</td><td>0.01</td><td>0.12</td></tr><tr><td>20:58</td><td>63.47</td><td>50.69</td><td>0.35</td><td>0.01</td><td>13.29</td></tr><tr><td>20:59</td><td>192.02</td><td>191.21</td><td>0.24</td><td>0.01</td><td>42.58</td></tr><tr><td>21:00</td><td>182.65</td><td>194.84</td><td>0.08</td><td>0.02</td><td>42.70</td></tr></tbody></table>
<p>마지막 몇 분간 읽기가 192MiB/s까지 치솟았습니다. <code>mysqldump</code> 같은 게 테이블을 통째로 읽어간 그림이 떠오르죠. 그런데 네트워크 지표를 같이 띄워보니 아니었습니다. 읽기 선은 천장을 치는데 네트워크 송신 선은 바닥에 붙어 있었어요. 폭증 구간에 송신이 오히려 0.08MiB/s로 줄어듭니다.</p>
<p>192MiB/s로 읽었는데 밖으로 나간 게 없다. 그러면 외부 덤프는 아니죠. 그리고 <code>ReadThroughput</code>과 <code>WriteThroughput</code>이 192 대 191로 거의 1대 1이에요. 인스턴스가 자기 데이터를 읽어서 자기 디스크에 다시 쓰고 있었습니다. <code>DiskQueueDepth</code> 42는 I/O가 완전히 포화됐다는 뜻이고요.</p>
<p>테이블 재구축, 대량 복사, 디스크로 넘친 임시 테이블. 이 셋 중 하나로 좁혀졌습니다. 이제 그 시각에 무슨 쿼리가 돌았는지 보면 되죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="추적-3---로그가-전부-비어-있었다">추적 3 - 로그가 전부 비어 있었다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%B6%94%EC%A0%81-3---%EB%A1%9C%EA%B7%B8%EA%B0%80-%EC%A0%84%EB%B6%80-%EB%B9%84%EC%96%B4-%EC%9E%88%EC%97%88%EB%8B%A4" class="hash-link" aria-label="추적 3 - 로그가 전부 비어 있었다에 대한 직접 링크" title="추적 3 - 로그가 전부 비어 있었다에 대한 직접 링크" translate="no">​</a></h2>
<p>CloudWatch Logs의 RDS 로그가 export되도록 설정해두었고, 이부분을 확인해봤습니다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">/aws/rds/instance/&lt;db&gt;/audit</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/aws/rds/instance/&lt;db&gt;/error</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/aws/rds/instance/&lt;db&gt;/slowquery</span><br></div></code></pre></div></div>
<p>audit 로그까지 있으니 그 시각의 쿼리가 그대로 나오겠다 싶었죠. Logs Insights로 사고 구간을 돌렸습니다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">fields @timestamp, @message | sort @timestamp asc | limit 400</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">20260827 11:50:02,ip-...,&lt;app_user&gt;,10.x.x.x,113421,4160707413,QUERY,&lt;db&gt;,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  'UPDATE BOARD_BBS SET HIT = HIT + 1 WHERE BOARD_SEQ = ...',0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">20260827 11:50:59,ip-...,rdsadmin,localhost,35390,4161247441,QUERY,mysql,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  'SET STATEMENT max_statement_time=60 FOR INSERT INTO mysql.rds_heartbeat2 ...',0</span><br></div></code></pre></div></div>
<p>게시글 조회수 UPDATE, 다운로드 카운트 UPDATE, RDS 관리 계정의 heartbeat. 분당 10~30건 수준이고 192MiB/s를 만들 만한 건 하나도 없었고요.</p>
<p>slow query 로그를 봤습니다. 비어 있었어요. 5분씩 걸린 쿼리가 있었다면 반드시 남았어야 하는데요. error 로그도 봤습니다. 역시 비어 있었습니다. DB가 죽었는데 에러 한 줄이 없어요.</p>
<p>지표는 분명히 뭔가가 디스크를 갈아먹고 있다고 말하는데, 로그는 아무 일도 없었다고 말하는 상황. 솔직히 여기서 좀 막혔습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="끝나지-않은-쿼리는-로그에-남지-않는다">끝나지 않은 쿼리는 로그에 남지 않는다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EB%81%9D%EB%82%98%EC%A7%80-%EC%95%8A%EC%9D%80-%EC%BF%BC%EB%A6%AC%EB%8A%94-%EB%A1%9C%EA%B7%B8%EC%97%90-%EB%82%A8%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4" class="hash-link" aria-label="끝나지 않은 쿼리는 로그에 남지 않는다에 대한 직접 링크" title="끝나지 않은 쿼리는 로그에 남지 않는다에 대한 직접 링크" translate="no">​</a></h2>
<p>한참 보다가 질문을 바꿨습니다. 무엇이 있었나가 아니라 왜 없는가로요.</p>
<p>audit 로그와 slow query 로그의 공통점이 하나 있죠. 둘 다 쿼리가 끝난 시점에 한 줄을 씁니다. 실행 시간, 검사한 행 수, 반환 코드 같은 걸 남기려면 쿼리가 끝나야 알 수 있으니까요. 위에서 본 audit 라인 맨 끝의 <code>0</code>도 반환 코드입니다. 그 값을 적으려면 쿼리가 종료돼야 하죠.</p>
<p>그러면 답이 나오죠. 끝나지 않은 쿼리는 어느 쪽에도 남지 않습니다.</p>
<p>저는 완료된 쿼리 목록에서 범인을 찾고 있었는데, 범인은 애초에 그 목록에 들어갈 수 없는 종류였던 거예요.</p>
<p>이 지점이 이 글의 주장과 바로 닿아 있습니다. 타임아웃이 없는 환경에서 폭주하는 쿼리는 실패하지 않습니다. 그냥 계속 실행됩니다. 그리고 실패하지 않는 한 에러 로그도, slow query 로그도, 애플리케이션 예외도 없어요. 개발자 입장에서는 저장 버튼을 눌렀는데 응답이 안 와서 새로고침을 한 번 했을 뿐이고, 그 뒤로는 아무 이상 신호가 없는 거죠.</p>
<p>실패는 시끄럽고, 실행 중은 조용합니다. 타임아웃은 성능 튜닝 수단이기 전에, 조용한 폭주를 시끄러운 실패로 바꿔주는 장치더라고요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="세션-두-개가-엿새를-살아-있었다">세션 두 개가 엿새를 살아 있었다<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%84%B8%EC%85%98-%EB%91%90-%EA%B0%9C%EA%B0%80-%EC%97%BF%EC%83%88%EB%A5%BC-%EC%82%B4%EC%95%84-%EC%9E%88%EC%97%88%EB%8B%A4" class="hash-link" aria-label="세션 두 개가 엿새를 살아 있었다에 대한 직접 링크" title="세션 두 개가 엿새를 살아 있었다에 대한 직접 링크" translate="no">​</a></h2>
<p>쿼리가 끝나지 않고 계속 돌고 있었다면 세션 점유로 나타나야겠죠. CPU 패널을 일주일로 늘리고 <code>WriteThroughput</code>을 겹쳐 봤어요. 두 선이 똑같은 모양으로 두 번 계단을 밟고 있었습니다.</p>
<table><thead><tr><th>구간</th><th>CPU</th><th>WriteThroughput</th></tr></thead><tbody><tr><td>08-20 (정상)</td><td>1.1%</td><td>0.013 MiB/s</td></tr><tr><td>08-21 10:00 ~</td><td>26.0%</td><td>0.52 MiB/s</td></tr><tr><td>08-24 16:00 ~</td><td>50.9%</td><td>0.93 MiB/s</td></tr><tr><td>재시작 후</td><td>2.6%</td><td>0.028 MiB/s</td></tr></tbody></table>
<p>베이스라인 1.1%에서 +24.9%p씩 정확히 두 번 올라갑니다. <code>100 / 24.9 ≈ 4.0</code>. 4 vCPU 인스턴스에서 코어 하나를 온전히 점유하는 무언가가 두 번 생긴 거예요. 쓰기 0.93MiB/s를 환산하면 시간당 3.27GiB로, 여유 공간 감소 속도와 일치하죠.</p>
<p>그러다 지표 목록에서 <code>DBLoad</code>를 발견했습니다. Performance Insights가 이미 켜져 있었던 겁니다. PI를 켜면 <code>DBLoad</code>, <code>DBLoadCPU</code>, <code>DBLoadNonCPU</code>가 CloudWatch에도 게시되거든요.[2] 저는 그때까지 PI를 켜야 한다고 생각하고 있었는데, 지표 목록만 봤어도 됐을 일이었어요.</p>
<table><thead><tr><th>구간</th><th>DBLoad (AAS)</th><th>DBLoadCPU</th><th>DBLoadNonCPU</th></tr></thead><tbody><tr><td>08-20</td><td>0.00</td><td>0.00</td><td>0.00</td></tr><tr><td>08-21 10:00 ~</td><td>1.00</td><td>0.02</td><td>0.98</td></tr><tr><td>08-24 16:00 ~</td><td>2.00</td><td>0.03</td><td>1.97</td></tr><tr><td>재시작 후</td><td>0.06</td><td>0.00</td><td>0.04</td></tr></tbody></table>
<p>AAS는 활성 세션의 평균 개수인데, PI가 1초 간격으로 샘플링해서 만들거든요.[3]</p>
<p>이 값이 소수점까지 정확히 1.00, 정확히 2.00으로 고정됐다는 건 모든 샘플에서 활성 세션이 정확히 하나, 둘씩 잡혔다는 뜻이에요. 짧은 쿼리들이 우연히 그렇게 나올 확률은 없죠. 끝나지 않는 세션이 08-21에 하나, 08-24에 하나 더 생겨서 엿새를 살아 있었던 겁니다. 스토리지 그래프의 계단 두 개와 시각이 정확히 일치하고요.</p>
<p><code>DBLoadNonCPU</code>가 1.97이라는 것도 중요합니다. 부하의 98%가 연산이 아니라 I/O 대기였어요. CPU를 태우는 무한 루프가 아니라 디스크에 뭔가를 계속 쓰면서 도는 작업이었다는 뜻이죠.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/03-6054a5f21a84cc687471af85ff0250c7.png" width="1760" height="600" class="zoomable_B8z7 img_ev3q"></p>
<p>Performance Insights 콘솔에서 시간 범위를 사고 직전 두 시간으로 지정하고 Top SQL을 열었습니다.</p>
<table><thead><tr><th>항목</th><th>값</th></tr></thead><tbody><tr><td>Top SQL 1위 Load by waits (AAS)</td><td>1.99</td></tr><tr><td>나머지 24개 쿼리</td><td>전부 0.01 미만</td></tr><tr><td>1위 쿼리 Calls/sec</td><td>비어 있음</td></tr><tr><td>지배적 대기 이벤트</td><td><code>wait/io/table/sql/handler</code></td></tr></tbody></table>
<p>단일 쿼리가 부하 2.00을 혼자 채우고 있었습니다. 같은 쿼리가 두 번 걸린 채 세션 하나씩을 물고 있었던 거죠. <code>Calls/sec</code>가 비어 있는 게 되게 결정적이었어요. 두 시간이 넘도록 한 번도 완료된 적이 없다는 의미입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="범인은-순환-참조를-만난-이중-재귀">범인은 순환 참조를 만난 이중 재귀<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EB%B2%94%EC%9D%B8%EC%9D%80-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0%EB%A5%BC-%EB%A7%8C%EB%82%9C-%EC%9D%B4%EC%A4%91-%EC%9E%AC%EA%B7%80" class="hash-link" aria-label="범인은 순환 참조를 만난 이중 재귀에 대한 직접 링크" title="범인은 순환 참조를 만난 이중 재귀에 대한 직접 링크" translate="no">​</a></h2>
<p>그 쿼리는 CMS의 사이트 메뉴 트리를 펼치는 재귀 CTE였습니다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">WITH</span><span class="token plain"> RECURSIVE MENU_CTE </span><span class="token keyword" style="color:#00009f">AS</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic">-- 시작점</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> FN_MENU_ALL_NAME</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> FN_MENUCODE_ALL_PATH</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> MENU_INFO</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">   </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> PARENT_CODE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ?</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">UNION</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALL</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic">-- 재귀</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> FN_MENU_ALL_NAME</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> FN_MENUCODE_ALL_PATH</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> MENU_INFO B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">JOIN</span><span class="token plain"> MENU_CTE C </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> B</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">PARENT_CODE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> C</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">MENU_CODE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> MENU_CTE </span><span class="token keyword" style="color:#00009f">ORDER</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">BY</span><span class="token plain"> MENU_SORT</span><br></div></code></pre></div></div>
<p>재귀가 이중으로 걸려 있어요. 바깥의 <code>WITH RECURSIVE</code>가 <code>PARENT_CODE</code>를 따라 트리를 내려가고, 그렇게 나온 행마다 경로 문자열을 만드는 함수 두 개를 또 호출하죠. 이름을 보면 이 함수들도 상위 메뉴를 거슬러 올라가는 구조고요.</p>
<p>재귀 CTE는 앵커 결과를 내부 임시 테이블에 넣고, 재귀 부분을 조인해 나온 새 행을 다시 임시 테이블에 추가하는 식으로 돌다가 새 행이 없으면 종료됩니다.[4] 그런데 메뉴 데이터에 순환 참조가 있으면, 그러니까 A의 부모가 B이고 B의 부모가 다시 A인 상황이면 같은 노드가 계속 새 행으로 생성됩니다. <code>UNION ALL</code>이라 중복 제거도 없고, LEVEL과 경로 값이 매번 달라지니 서로 다른 행 취급이거든요. 종료 조건에 영영 도달하지 못합니다.</p>
<p>그동안 중간 결과는 임시 테이블에 계속 쌓이고, 메모리 한계를 넘으면 디스크로 내려가고요. 디스크에 임시 데이터가 무한히 누적되는 구조예요.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/04-d38ff3b1a2b2eecf086a0421273d6106.png" width="1150" height="199" class="zoomable_B8z7 img_ev3q"></p>
<p>관측된 것들이 전부 설명되죠. 세션이 안 끝나는 것(AAS 고정), 부하의 98%가 I/O 대기인 것(임시 테이블 읽기·쓰기), 쓰기가 시간에만 비례하는 것(반복 속도가 일정하니 누적도 일정), 네트워크 송신이 없는 것(결과를 반환하지 못하고 누적만 함), 어느 로그에도 없는 것(완료 시점이 안 옴), 그리고 재시작으로 전부 사라진 것(세션 종료와 함께 임시 테이블 회수)까지요.</p>
<p>참고로 Top SQL 2위에 거의 똑같이 생긴 재귀 CTE가 있었는데 부하는 0.01 미만이었습니다. 조직 계층을 도는 쿼리였어요. 같은 패턴이 정상 데이터에서 동작할 때의 모습인 거죠. 문제는 코드 패턴이 아니라 데이터에 있었던 거죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="만약-타임아웃이-있었다면">만약 타임아웃이 있었다면<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EB%A7%8C%EC%95%BD-%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83%EC%9D%B4-%EC%9E%88%EC%97%88%EB%8B%A4%EB%A9%B4" class="hash-link" aria-label="만약 타임아웃이 있었다면에 대한 직접 링크" title="만약 타임아웃이 있었다면에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 처음의 주장으로 돌아가겠습니다. 이 환경에 <code>max_statement_time</code>이 30초로 걸려 있었다고 해보죠.</p>
<p>08-21 오전 10시, 누군가 관리자 화면에서 메뉴를 편집합니다. 저장을 누르자 메뉴 트리를 다시 그리는 재귀 CTE가 돌기 시작하는데, 데이터 어딘가에 순환 참조가 생긴 뒤라 쿼리가 끝나지 않아요. 30초 뒤 쿼리가 강제 종료되죠. 화면에는 에러가 뜨고, 애플리케이션 로그에 예외가 남고, 사용자는 "메뉴 저장하면 30초 뒤에 에러 나요"라고 제보합니다. 개발자가 그날 순환 참조를 찾습니다. 버그 리포트 한 건짜리 사건입니다.</p>
<p>실제로는 어땠느냐. 쿼리는 죽지 않았고, 그래서 에러도 예외도 제보도 없었습니다. 사용자는 응답이 없길래 새로고침을 했을 테고, 커넥션 풀은 응답 없는 커넥션을 버리고 새로 연결했을 테니 화면은 결국 떴을 거예요. 그 뒤로 엿새 동안 아무 신호가 없다가, 무려 500GB 가까운 디스크가 전부 소진되고 나서야 전면 장애로 나타났습니다. 서비스 중단 세 시간, 복구 대응, 스토리지 증설 비용, 그리고 이 글을 쓰게 만든 추적까지요.</p>
<p>같은 버그입니다. 타임아웃이 있느냐 없느냐가 버그 리포트와 전면 장애를 갈랐어요.</p>
<p>하나 되게 재미있는 걸 audit 로그에서 봤습니다. RDS가 자기 관리용으로 돌리는 heartbeat 쿼리요.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">SET</span><span class="token plain"> STATEMENT max_statement_time</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">60</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FOR</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">INSERT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INTO</span><span class="token plain"> mysql</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">rds_heartbeat2 </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><br></div></code></pre></div></div>
<p>AWS는 자기네 내부 쿼리에 전부 타임아웃을 걸어놨더라고요.[5] 관리 주체가 자기 쿼리에는 걸어둔 안전장치를, 정작 그 위에서 도는 서비스 쿼리는 무제한으로 달리고 있었던 셈이죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="정상-쿼리가-끊기면-어떡하나에-대해">"정상 쿼리가 끊기면 어떡하나"에 대해<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%A0%95%EC%83%81-%EC%BF%BC%EB%A6%AC%EA%B0%80-%EB%81%8A%EA%B8%B0%EB%A9%B4-%EC%96%B4%EB%96%A1%ED%95%98%EB%82%98%EC%97%90-%EB%8C%80%ED%95%B4" class="hash-link" aria-label="&quot;정상 쿼리가 끊기면 어떡하나&quot;에 대해에 대한 직접 링크" title="&quot;정상 쿼리가 끊기면 어떡하나&quot;에 대해에 대한 직접 링크" translate="no">​</a></h2>
<p>이 값을 안 거는 이유는 대부분 하나입니다. 정상적인 장기 작업이 끊길까 봐요. 저도 그래서 미뤄왔고, 실제로 무시할 수 없는 걱정이죠.</p>
<p>그런데 이번에 워크로드를 실측해보니 생각과 좀 달랐습니다. audit 로그 기준으로 이 서비스의 정상 쿼리는 전부 밀리초 단위였어요. 가장 무거운 축인 검색 프로시저도 1초 안에 끝났고요. 웹 애플리케이션 트래픽만 받는 DB라면 대부분 비슷할 거예요. 30초짜리 타임아웃에 걸리는 정상 쿼리가 있다면, 그건 타임아웃 문제가 아니라 그 쿼리의 문제일 가능성이 높습니다.</p>
<p>그래도 일괄 적용이 좀 부담스러우면 단계가 있습니다.</p>
<ul>
<li class="">전역은 넉넉하게, 계정별로 좁게. MariaDB는 유저 단위로도 이 값을 다르게 줄 수 있거든요. 웹 애플리케이션 계정은 짧게, 배치 계정은 길게 주면 돼요.</li>
<li class="">문장 단위 예외. 정말 오래 걸려야 하는 작업은 <code>SET STATEMENT max_statement_time=3600 FOR ...</code>로 그 문장만 풀어줍니다. AWS가 heartbeat에 쓰는 방식 그대로요.</li>
<li class="">동적 파라미터라 재시작이 필요 없습니다. RDS 파라미터 그룹에서 바로 적용되고, 문제가 생기면 바로 되돌릴 수 있어요.</li>
</ul>
<p>값을 정하는 기준은 결국에는 워크로드 실측입니다. slow query 로그에서 정상 상태의 최대 실행 시간을 확인하고, 거기에 여유를 곱해서 잡으면 되고요. 저희는 정상 최대치의 수십 배를 줘도 이번 같은 폭주는 몇 초, 늦어도 몇 분 안에 끊을 수 있었어요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="타임아웃-하나로는-부족한-것들">타임아웃 하나로는 부족한 것들<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83-%ED%95%98%EB%82%98%EB%A1%9C%EB%8A%94-%EB%B6%80%EC%A1%B1%ED%95%9C-%EA%B2%83%EB%93%A4" class="hash-link" aria-label="타임아웃 하나로는 부족한 것들에 대한 직접 링크" title="타임아웃 하나로는 부족한 것들에 대한 직접 링크" translate="no">​</a></h2>
<p>그래서 타임아웃만 걸면 되느냐. 그건 아닙니다. 이번에 같이 정리한 것들이 있어요.</p>
<p><code>cte_max_recursion_depth</code>. 재귀 CTE의 반복 상한이고 MariaDB 기본값이 1000입니다. 메뉴 계층이 수십 단계를 넘을 일은 없으니 훨씬 낮췄습니다. 타임아웃이 시간의 상한이라면 이건 반복의 상한이죠. 겹치지 않는 안전장치가 하나 더 생기는 겁니다.</p>
<p><code>innodb_temp_data_file_path</code> 상한. 임시 테이블스페이스가 무한히 자라지 못하게 막습니다.</p>
<div class="language-ini codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ini codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">innodb_temp_data_file_path = ibtmp1:12M:autoextend:max:20G</span><br></div></code></pre></div></div>
<p>이걸 걸어두면 같은 일이 벌어져도 인스턴스 전체가 멈추는 대신 해당 쿼리만 실패합니다.[6] 한 번 커진 임시 테이블스페이스는 재시작 전까지 반환되지 않는 파일이라, 상한 없이 두면 이번처럼 조용히 전체를 잠식해요.</p>
<p>알림 재설계.</p>
<p>이번 사고에서 가장 먼저 나타난 신호는 스토리지가 아니라 CPU와 AAS였습니다. 08-21 오전 10시에 CPU가 1.1%에서 26%로 뛴 순간을 잡았다면 엿새를 벌었어요. 그런데 CPU 알림 임계값이 90%라 50.9%도 안 걸렸습니다. 평소가 1%인 DB에서 26%는 26배 증가인데도요. 그래서 스토리지 알림은 잔여량에서 잔여 시간 기준으로, CPU는 절대값이 아니라 평소 대비 급변으로 바꾸고, <code>DBLoad</code>가 일정 수준으로 지속되는 걸 잡는 룰을 추가했습니다. <code>noDataState</code>도 <code>OK</code>에서 <code>Alerting</code>으로 바꿨어요. 인스턴스가 죽어 지표가 끊기면 알림이 저절로 꺼지는, 가장 심각한 순간에 가장 조용해지는 설정이었거든요.</p>
<p>데이터 정합성.</p>
<p>순환 참조 자체를 찾아 고치는 건 애플리케이션과 데이터 영역이라 개발 쪽에 넘겼습니다. 재귀 CTE로 직접 찾을 수 있어요.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">WITH</span><span class="token plain"> RECURSIVE walk </span><span class="token keyword" style="color:#00009f">AS</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic">-- 시작점: 모든 노드</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> MENU_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> PARENT_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">AS</span><span class="token plain"> depth</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> CAST</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">MENU_CODE </span><span class="token keyword" style="color:#00009f">AS</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">CHAR</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2000</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">AS</span><span class="token plain"> path</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> MENU_INFO</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">UNION</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">ALL</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic">-- 재귀: 부모 방향으로 거슬러 올라가며 경로 누적</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">MENU_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> m</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">PARENT_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">depth </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> CONCAT</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">','</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> m</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">MENU_CODE</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> walk w</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">JOIN</span><span class="token plain"> MENU_INFO m </span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> m</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">MENU_CODE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">PARENT_CODE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">   </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">depth </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">50</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">     </span><span class="token operator" style="color:#393A34">AND</span><span class="token plain"> FIND_IN_SET</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">m</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">MENU_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic">-- 이미 지나온 노드면 멈춘다</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> MENU_CODE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> depth</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> path </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> walk </span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> depth </span><span class="token operator" style="color:#393A34">&gt;=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">50</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p><code>FIND_IN_SET</code> 조건이 방어막입니다. 이게 없으면 진단 쿼리 자체가 같은 함정에 빠져요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="아직-정리-안-된-것들">아직 정리 안 된 것들<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%95%84%EC%A7%81-%EC%A0%95%EB%A6%AC-%EC%95%88-%EB%90%9C-%EA%B2%83%EB%93%A4" class="hash-link" aria-label="아직 정리 안 된 것들에 대한 직접 링크" title="아직 정리 안 된 것들에 대한 직접 링크" translate="no">​</a></h2>
<p>솔직히 아직 확인 못 한 것들도 있습니다.</p>
<p>하나는 순환 참조의 실물이에요. 구조적으로는 순환이 있어야 모든 관측이 설명되는데, 실제로 어느 메뉴 코드가 순환하는지는 위 쿼리를 돌려봐야 압니다. 개발 쪽 회신을 기다리는 중이죠.</p>
<p>또 하나는 타임아웃 값 자체입니다. 너무 짧으면 정상 배치가 끊기고, 길면 이번 같은 상황에서 의미가 없어요. 결국에는 고객마다 워크로드가 달라서 일괄 기준을 못 만들고 계정 단위로 하나씩 실측해서 잡고 있는데, 이건 사례가 더 쌓여야 기준이 나올 것 같습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="정리하면">정리하면<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%A0%95%EB%A6%AC%ED%95%98%EB%A9%B4" class="hash-link" aria-label="정리하면에 대한 직접 링크" title="정리하면에 대한 직접 링크" translate="no">​</a></h2>
<p>말이 길어졌는데, 하고 싶었던 얘기는 처음의 한 문장입니다.</p>
<p>운영 데이터베이스에는 statement timeout이 걸려 있어야 한다.</p>
<p>이유를 세 줄로 줄이면 이렇습니다.</p>
<p>첫째, 타임아웃이 없으면 폭주는 실패하지 않고 그냥 계속 실행됩니다. 그리고 실행 중인 쿼리는 audit 로그에도 slow query 로그에도 남지 않아요. 완료 기반 로그는 끝난 것만 기록하니까요. 조용한 폭주를 시끄러운 실패로 바꾸는 게 타임아웃의 진짜 역할입니다.</p>
<p>둘째, 결국 같은 버그라도 타임아웃 유무가 결과의 크기를 가릅니다. 이번 건은 타임아웃이 있었다면 저장 버튼 에러, 버그 리포트 한 건이었을 일이에요. 없어서 엿새 뒤 전면 장애가 됐습니다.</p>
<p>셋째, 걱정만큼 위험하지 않습니다. 계정별 차등, 문장 단위 예외, 무중단 적용까지 다 가능해요. AWS도 자기 내부 쿼리에는 전부 걸어두고요.</p>
<p>결국에는 실패를 빨리, 시끄럽게 만드는 것들이 시스템을 지키더라고요. 에러가 안 나는 것과 문제가 없는 것은 다릅니다. 이번 사고에서 에러는 단 한 줄도 없었어요. 그게 문제였습니다.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] RDS는 인스턴스의 여유 스토리지가 고갈되면 상태를 <code>storage-full</code>로 전환하고 신규 연결과 쓰기를 차단합니다. 이 상태에서는 로그 파일 기록도 실패할 수 있어, 정작 사고 순간의 진단 정보가 남지 않는 경우가 생깁니다.</p>
<p>[2] Performance Insights를 활성화하면 <code>DBLoad</code>, <code>DBLoadCPU</code>, <code>DBLoadNonCPU</code>가 <code>AWS/RDS</code> 네임스페이스로 게시되어 일반 CloudWatch 지표처럼 조회하거나 알림을 걸 수 있습니다. SQL 단위 분해는 PI 콘솔이 필요하지만, 인스턴스 전체 부하 추이는 평소 쓰는 대시보드에 패널 하나 추가하는 것으로 볼 수 있습니다.</p>
<p>[3] Average Active Sessions. 특정 구간에 데이터베이스에서 활성 상태였던 세션의 평균 개수입니다. PI가 1초 간격으로 세션 상태를 샘플링해 산출합니다. AAS가 1이면 평균적으로 항상 한 개의 세션이 무언가를 실행 중이라는 뜻이고, vCPU 수와 비교하면 부하 수준을 가늠할 수 있습니다.</p>
<p>[4] 재귀 CTE는 앵커(비재귀) 부분의 결과를 내부 임시 테이블에 채운 뒤, 그 결과를 재귀 부분에 조인해 새로 생성된 행을 다시 임시 테이블에 추가하는 방식으로 반복합니다. 새 행이 나오지 않으면 종료됩니다. <code>UNION ALL</code>을 쓰면 중복 제거가 없으므로, 순환 참조가 있고 컬럼 값이 매번 달라지면 종료 조건에 도달하지 못합니다.</p>
<p>[5] MariaDB의 <code>max_statement_time</code>은 전역, 세션, 계정, 그리고 <code>SET STATEMENT ... FOR</code> 형태로 문장 단위까지 지정할 수 있습니다. 초 단위 실수값을 받으며, 지정 시간을 넘긴 statement는 중단됩니다. SELECT만이 아니라 모든 statement에 적용된다는 점이 MySQL의 <code>max_execution_time</code>(SELECT 한정)과 다릅니다.</p>
<p>[6] <code>innodb_temp_data_file_path</code>는 InnoDB 임시 테이블스페이스 파일의 경로, 초기 크기, 확장 방식, 최대 크기를 지정합니다. <code>max</code> 값을 지정하면 임시 테이블스페이스가 그 크기를 넘어 확장되지 않고 해당 쿼리가 에러로 종료됩니다. 이 파일은 서버 재시작 시 초기 크기로 다시 생성되므로, 한 번 커진 공간은 재시작 전까지 반환되지 않습니다. 정적 파라미터라 적용에는 재시작이 필요합니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="참고">참고<a href="https://nxtgen.co.kr/blog/2026/08/31/production-db-statement-timeout#%EC%B0%B8%EA%B3%A0" class="hash-link" aria-label="참고에 대한 직접 링크" title="참고에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://mariadb.com/kb/en/aborting-statements/" target="_blank" rel="noopener noreferrer" class="">Aborting Statements that Exceed a Certain Time to Execute, MariaDB</a></li>
<li class=""><a href="https://mariadb.com/kb/en/recursive-common-table-expressions-overview/" target="_blank" rel="noopener noreferrer" class="">Recursive Common Table Expressions Overview, MariaDB</a></li>
<li class=""><a href="https://mariadb.com/kb/en/mariadb-audit-plugin/" target="_blank" rel="noopener noreferrer" class="">MariaDB Audit Plugin, MariaDB</a></li>
<li class=""><a href="https://mariadb.com/kb/en/innodb-system-variables/" target="_blank" rel="noopener noreferrer" class="">InnoDB System Variables, MariaDB</a></li>
<li class=""><a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PerfInsights.Overview.html" target="_blank" rel="noopener noreferrer" class="">Amazon RDS Performance Insights</a></li>
<li class=""><a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Troubleshooting.html" target="_blank" rel="noopener noreferrer" class="">Amazon RDS의 스토리지 부족 문제 해결</a></li>
</ul>
<p>이 글의 수치와 타임라인은 실제 장애 대응 과정에서 확인한 것입니다. 고객사 식별 정보와 내부 리소스 이름은 익명화했습니다.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI 활용률 92%가 알려주지 않는 것 (FDE는 어떻게 사고해야 하는가)]]></title>
            <link>https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing</link>
            <guid>https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing</guid>
            <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AI 활용률 92%라는 숫자는 사람이 실제로 사고했는지 말해주지 않습니다. 해커톤에서 받은 질문을 계기로 FDE가 갖춰야 할 사고 방식을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>학생 5천 명에게 AI를 제공했고 활용률은 92%다. 하지만 실제 학습 성과는 불분명하다.<br>
<!-- -->다음 학기 AI 운영 전략을 제안하라.</p>
</blockquote>
<p>며칠 전 저희가 진행했던 해커톤에 참여했던 대학생이 이런 과제를 보여주면서 물었어요. 해커톤이 끝난 뒤에도 이 질문이 계속 남아 있었나 봅니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-5de56fbeae5f1481c964d7940c8ac50f.png" width="1272" height="1096" class="zoomable_B8z7 img_ev3q"></p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/02-3aaec69c54965fec30f29ec9f74f480d.png" width="800" height="1131" class="zoomable_B8z7 img_ev3q"></p>
<p><em>AI가 난무하는 시대에 이런 탐구정신을 나도 본받고 싶다.</em></p>
<p>AI를 쓰는 과정에서 사람이 진짜 사고했는지, 아니면 그냥 답만 받아 왔는지를 어떻게 구분하냐고요. 되게 좋은 질문이라 오늘은 이 얘기를 좀 해보려고 합니다.</p>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/03-ee2677f7eb5342d43fea2ae265dc6f44.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ai-활용률은-아무것도-증명하지-않는다">AI 활용률은 아무것도 증명하지 않는다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#ai-%ED%99%9C%EC%9A%A9%EB%A5%A0%EC%9D%80-%EC%95%84%EB%AC%B4%EA%B2%83%EB%8F%84-%EC%A6%9D%EB%AA%85%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4" class="hash-link" aria-label="AI 활용률은 아무것도 증명하지 않는다에 대한 직접 링크" title="AI 활용률은 아무것도 증명하지 않는다에 대한 직접 링크" translate="no">​</a></h2>
<p>AI 활용률 92%라는 숫자를 다시 보죠. 이 숫자는 사람들이 AI 화면을 얼마나 열었는지는 말해주지만, 그 안에서 무슨 일이 있었는지는 하나도 말해주지 않습니다. 문제를 정의하고 AI에게 검증을 요청한 92%일 수도 있고, 그냥 질문을 던지고 나온 답을 그대로 복붙한 92%일 수도 있어요. 숫자로는 이 둘이 구분이 안 됩니다.</p>
<p>이게 활용률만의 문제는 아니에요. 사람들이 흔히 사고의 증거라고 착각하는 신호들이 몇 개 더 있습니다.</p>
<p>대화 로그가 길면 뭔가 열심히 한 것 같죠. 그런데 질문을 열 번 던졌다는 게 열 번 생각했다는 뜻은 아닙니다. 같은 질문을 표현만 바꿔서 열 번 던졌을 수도 있어요. 결과물이 매끄러운 것도 마찬가지입니다. AI가 그럴듯한 오답을 내는 경우가 실제로 꽤 있거든요. 그럴듯하다는 게 문제예요. 산출물이 매끄러우면 사람들은 그 안의 사고 과정도 매끄러웠을 거라고 착각합니다. 질문의 개수, 대화의 길이, 결과물의 완성도, 활용률까지. 전부 겉에서 보이는 것들이고, 사고했는지를 확인하는 데는 가짜 신호에 가깝습니다.</p>
<p>그럼 진짜 신호는 뭘까요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="사고한다는-건-그릴-수-있다는-것이다">사고한다는 건 그릴 수 있다는 것이다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%82%AC%EA%B3%A0%ED%95%9C%EB%8B%A4%EB%8A%94-%EA%B1%B4-%EA%B7%B8%EB%A6%B4-%EC%88%98-%EC%9E%88%EB%8B%A4%EB%8A%94-%EA%B2%83%EC%9D%B4%EB%8B%A4" class="hash-link" aria-label="사고한다는 건 그릴 수 있다는 것이다에 대한 직접 링크" title="사고한다는 건 그릴 수 있다는 것이다에 대한 직접 링크" translate="no">​</a></h2>
<p>제가 아르바이트를 하는데 단골 손님의 장부를 따로 적고 있다고 해볼게요. 손님이 와서 식사를 하고 나 누구누구인데 이거 우리 장부에 달아놔 라고 말합니다.</p>
<p>이 한 문장 안에 이미 분기가 여러 개 들어 있어요. 단골인지 처음 온 사람인지에 따라 장부를 미리 준비해둘지 어디서 찾아올지가 갈리고, 이름과 시간을 적은 다음에는 누적 금액이 100만 원을 넘는지에 따라 그냥 기록할지 확인을 요청할지가 또 갈립니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="장부 예시로 본 분기 구조" src="https://nxtgen.co.kr/assets/images/04-dd2322593f9fdb7e4eae81ae1d6974b5.png" width="1400" height="246" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">장부 예시로 본 분기 구조</span></span></p>
<p>저는 이걸 자료구조(CS)에서 쓰는 그래프로 봅니다. 노드는 동그라미, 엣지는 방향을 가진 직선이죠.[1] 이 인풋과 아웃풋, 그 사이의 분기를 AI 없이 그릴 수 있는가. 이게 제가 생각하는 사고하는 행동의 정의입니다.</p>
<p>그리고 이 구조는 그대로 옮겨 붙습니다. 장부 시스템이든 특정 대학교의 예산 시스템이든 똑같은 모양으로 들어가요. 그러니까 이건 장부에 대한 얘기가 아니라, 어떤 문제든 이런 식으로 쪼갤 수 있는가에 대한 얘기입니다.</p>
<p>한 가지 더 볼 게 있어요. 그릴 줄 아는 사람은 새로운 케이스가 왔을 때 반응이 다릅니다. 어느 날 처음 보는 손님이 단골처럼 장부를 달아달라고 하면, 그리지 못하는 사람은 당황합니다. 그린 사람은 당황하지 않아요. 기존 분기 어디에 이 케이스가 안 들어맞는지가 바로 보이니까, 분기를 하나 더 추가하면 된다는 걸 압니다. 사고했는지는 예외가 나타났을 때 제일 잘 드러납니다. 평소에는 둘 다 비슷해 보여요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="관계자가-여럿이면-그래프도-여럿이다">관계자가 여럿이면 그래프도 여럿이다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EA%B4%80%EA%B3%84%EC%9E%90%EA%B0%80-%EC%97%AC%EB%9F%BF%EC%9D%B4%EB%A9%B4-%EA%B7%B8%EB%9E%98%ED%94%84%EB%8F%84-%EC%97%AC%EB%9F%BF%EC%9D%B4%EB%8B%A4" class="hash-link" aria-label="관계자가 여럿이면 그래프도 여럿이다에 대한 직접 링크" title="관계자가 여럿이면 그래프도 여럿이다에 대한 직접 링크" translate="no">​</a></h2>
<p>학생이 준 과제를 다시 보면, 상사, 교수, 담당자, 학생까지 관계자가 넷입니다. 이게 어려운 이유는 각자가 그리는 그래프가 다르기 때문이에요. 교수가 보는 성공은 학습 성과 개선이고, 담당자가 보는 성공은 예산 안에서 운영을 유지하는 것이고, 학생이 보는 성공은 과제를 편하게 끝내는 것입니다. 셋 다 같은 시스템을 보고 있는데 인풋과 아웃풋을 서로 다르게 정의하고 있는 거예요.</p>
<p>사고하는 사람은 이걸 하나의 그래프로 뭉개지 않습니다. 대신 각자의 그래프를 따로 그려보고, 어느 노드에서 이 그래프들이 겹치는지, 어디서 갈라지는지를 찾습니다. 겹치는 지점이 진짜 요구사항이고, 갈라지는 지점이 조율해야 할 지점이죠. 이해관계자 충돌은 없애야 할 문제가 아니라, 그래프가 여러 장이라는 뜻으로 받아들이면 오히려 다루기 쉬워집니다.</p>
<p>예를 들어 이 과제에서 담당자의 그래프와 교수의 그래프가 겹치는 지점은 하나예요. 활용률이 아니라 실제 성과로 예산을 정당화해야 한다는 것. 반면 학생의 그래프는 이 지점을 아예 지나가지 않을 수도 있습니다. 학생 입장에서는 그냥 과제를 무사히 제출하는 게 목표니까요. 이 어긋남을 미리 그려보지 않으면, 다음 학기 전략을 냈을 때 교수는 만족하는데 정작 학생들은 반발하는 상황이 벌어집니다.</p>
<p>이건 저희가 FDE로 일할 때 항상 마주치는 일이기도 해요. 저희와 구축 대상, 특정 대학의 사업단이나 교수님이 각자 다른 그래프를 갖고 있는 거고, 그 사이 간격은 원래 큽니다. 도메인을 아는 쪽과 기술을 아는 쪽이 다르니까요. 그 간격을 메우는 게 결국 소통이고, 소통도 따지고 보면 상대방의 그래프를 내 쪽으로 옮겨 그려보는 일입니다.</p>
<p>여기서 흔히 하는 실수가 있어요. 간격이 크니까 일단 우리 쪽 그래프부터 완성해놓고 나중에 맞춰보자고 넘어가는 겁니다. 그러면 늦습니다. 완성된 그래프 두 장을 나중에 겹쳐보면 어긋난 부분이 한두 곳이 아니에요. 처음부터 상대방의 그래프를 대충이라도 같이 그려가면서 가는 쪽이, 나중에 다시 그리는 것보다 훨씬 빠릅니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="이-능력은-저절로-드러나지-않는다">이 능력은 저절로 드러나지 않는다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%9D%B4-%EB%8A%A5%EB%A0%A5%EC%9D%80-%EC%A0%80%EC%A0%88%EB%A1%9C-%EB%93%9C%EB%9F%AC%EB%82%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4" class="hash-link" aria-label="이 능력은 저절로 드러나지 않는다에 대한 직접 링크" title="이 능력은 저절로 드러나지 않는다에 대한 직접 링크" translate="no">​</a></h2>
<p>왜 이게 지금 특히 문제가 되는지를 짚고 넘어가야 할 것 같아요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="AI 이전과 이후의 사고 경로" src="https://nxtgen.co.kr/assets/images/05-05c4473eb801f6a0bae655ab7ae3fab7.png" width="1400" height="739" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">AI 이전과 이후의 사고 경로</span></span></p>
<p>AI가 없던 시절에는 이 구조화 능력이 강제로 드러났습니다. 코드를 짜려면 무조건 먼저 구조를 손으로 설계해야 했거든요. 분기를 안 그리고는 코드 자체가 안 나왔어요. 그러니까 코드가 나왔다는 것 자체가 이미 그 사람이 구조화를 했다는 증거였습니다.</p>
<p>지금은 다릅니다. 문제를 AI에게 바로 던져도 결과가 나와요. 구조화 단계를 건너뛸 수 있게 된 겁니다. 건너뛰어도 결과물은 나오니까, 이 사람이 구조를 그렸는지 안 그렸는지가 겉으로는 안 보여요. 예전에는 관문이었던 게 지금은 선택지가 됐습니다.</p>
<p>그래서 활용률이나 결과물만 보고는 판단할 수가 없는 거예요. 관문이 사라졌으니, 관문을 지났는지를 따로 확인해야 합니다. 이게 이 글의 요지예요.</p>
<p>이게 서서히 일어나는 일이라는 것도 짚어야 할 것 같아요. 처음엔 익숙한 부분만 건너뜁니다. 어차피 답이 뻔한 곳이니까요. 그런데 이게 몇 번 반복되면 건너뛰는 게 기본값이 됩니다. 나중에는 뻔하지 않은 부분도 일단 던져보고 결과를 보는 쪽으로 습관이 굳어요. 본인은 여전히 잘하고 있다고 느낍니다. 결과물은 계속 나오니까요. 근육이 빠지고 있다는 걸 알아채는 시점은, 정말 낯선 문제를 만나서 던졌는데 결과가 이상한데 어디가 이상한지조차 짚어내지 못할 때입니다. 그때는 이미 늦은 경우가 많아요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="코드는-쉬워졌지만-이건-아니다">코드는 쉬워졌지만, 이건 아니다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%BD%94%EB%93%9C%EB%8A%94-%EC%89%AC%EC%9B%8C%EC%A1%8C%EC%A7%80%EB%A7%8C-%EC%9D%B4%EA%B1%B4-%EC%95%84%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="코드는 쉬워졌지만, 이건 아니다에 대한 직접 링크" title="코드는 쉬워졌지만, 이건 아니다에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 오해하기 쉬운 지점이 하나 있어요. 코드를 짜는 것 자체는 이제 어렵지 않습니다. 저희 FDE 업무에서도 뭔가를 코드화하는 부분에는 어려움이 없어요. 해자는[2] 이제 더 이상 코드가 아니게 되어버렸습니다.</p>
<p>그런데 방금 말한 그래프를 그리는 능력, 그러니까 문제를 인풋과 아웃풋과 분기로 쪼개는 능력은 코드 문법과는 다른 겁니다. 이건 AI가 대신 해주지 않아요. 정확히는, AI에게 이걸 대신 시키면 시키는 순간 이 사람이 사고했는지를 확인할 방법이 사라집니다. 코드는 누구나 짤 수 있게 됐지만, 그 코드가 왜 그렇게 생겨야 하는지를 그릴 수 있는 사람은 여전히 드뭅니다. 그러니까 해자는 코드에서 이 구조화 능력 쪽으로 옮겨간 겁니다.</p>
<p>이게 왜 옮겨가기만 하고 사라지지는 않느냐면, 구조화는 도메인마다 다시 그려야 하기 때문이에요. 장부 구조를 한 번 그려봤다고 해서 예산 시스템 구조가 저절로 나오는 게 아닙니다. 모양은 비슷해도 손님이 다르고 규칙이 다르고 예외가 다릅니다. 코드는 복사해서 재사용할 수 있지만, 이 도메인을 처음 보고 분기를 뽑아내는 능력은 매번 새로 써야 하는 근육이에요.</p>
<p>예를 들어 장부 구조를 그릴 줄 아는 사람에게 재고 관리 시스템을 준다고 해볼게요. 겉보기엔 비슷합니다. 인풋이 들어오고 분기가 있고 아웃풋이 나가죠. 그런데 재고는 마이너스가 될 수 없다는 규칙이 있고, 발주 시점이라는 시간 조건이 끼어들고, 여러 창고 사이의 이동이라는 관계가 추가됩니다. 장부에서 썼던 분기 감각은 도움이 되지만, 그대로 복사해서는 안 맞아요. 매번 그 도메인의 규칙을 새로 물어보고 다시 그려야 합니다. 코드는 한 번 짜면 재사용되지만, 이 그리는 능력은 매번 그 도메인 앞에서 다시 발휘돼야 하는 거예요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ai에게-정의를-맡기지-말고-평가를-맡겨라">AI에게 정의를 맡기지 말고 평가를 맡겨라<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#ai%EC%97%90%EA%B2%8C-%EC%A0%95%EC%9D%98%EB%A5%BC-%EB%A7%A1%EA%B8%B0%EC%A7%80-%EB%A7%90%EA%B3%A0-%ED%8F%89%EA%B0%80%EB%A5%BC-%EB%A7%A1%EA%B2%A8%EB%9D%BC" class="hash-link" aria-label="AI에게 정의를 맡기지 말고 평가를 맡겨라에 대한 직접 링크" title="AI에게 정의를 맡기지 말고 평가를 맡겨라에 대한 직접 링크" translate="no">​</a></h2>
<p>그래서 AI를 쓰는 두 가지 방식이 완전히 다른 결과를 냅니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="두 가지 AI 활용 방식" src="https://nxtgen.co.kr/assets/images/06-ff44274df9c4e3bfef61f265b23c05c0.png" width="1400" height="134" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">두 가지 AI 활용 방식</span></span></p>
<p>이거 어떻게 해야 될까요 라고 던지고 답을 받는 건 의존이에요. 반면 내가 문제를 정의하고, 내 나름의 방법을 만들고, 이걸 평가해달라고 AI에게 요청하는 건 다릅니다. 앞의 방식에서는 그래프를 그린 사람이 AI고, 뒤의 방식에서는 그래프를 그린 사람이 나예요. AI는 그 그림을 검토하는 역할일 뿐이고요.</p>
<p>말로는 비슷해 보이는데 실제로 던지는 질문이 다릅니다. 의존형은 이거 어떻게 해야 될까요 라고 묻습니다. 사고형은 내가 생각한 방법은 이건데, 이 부분에 구멍이 있는지, 더 나은 방법이 있는지 봐달라고 묻습니다. 둘 다 AI에게 물어보는 건 똑같아요. 그런데 앞의 질문은 답을 구하는 질문이고, 뒤의 질문은 이미 나온 답을 검증하는 질문입니다. 질문을 던지기 전에 이미 뭔가를 그려놨는지가 갈림길이에요.</p>
<p>똑같이 AI를 쓰고 있는데 한쪽은 사고를 넘겨버린 거고, 한쪽은 사고를 자기가 하고 검증만 맡긴 겁니다. 활용률로는 이 둘이 똑같아 보이지만, 실제로는 완전히 다른 일을 하고 있는 거죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="패턴을-본다-숫자를-보지-않는다">패턴을 본다, 숫자를 보지 않는다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%ED%8C%A8%ED%84%B4%EC%9D%84-%EB%B3%B8%EB%8B%A4-%EC%88%AB%EC%9E%90%EB%A5%BC-%EB%B3%B4%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4" class="hash-link" aria-label="패턴을 본다, 숫자를 보지 않는다에 대한 직접 링크" title="패턴을 본다, 숫자를 보지 않는다에 대한 직접 링크" translate="no">​</a></h2>
<p>활용률 92%가 가짜 신호라고 했는데, 그러면 실제로는 뭘 보냐고 물으실 수 있을 것 같아요.</p>
<p>숫자가 아니라 패턴을 봅니다. 어떤 사람이 AI를 어떤 순서로 쓰는지 보면, 문제를 정의하는 질문을 먼저 던지는지 아니면 바로 해결책부터 요구하는지가 보입니다. 막히는 지점에서 뭘 하는지도 보이고요. 막히면 처음부터 다른 질문을 던지는 사람이 있고, 막힌 지점을 정확히 짚어서 그 부분만 다시 물어보는 사람이 있습니다. 후자는 자기가 어디서 막혔는지 이미 알고 있다는 뜻이에요. 그게 그래프를 갖고 있다는 증거입니다.</p>
<p>장부 얘기로 다시 돌아가면, 막혔을 때 이거 어떻게 정리해야 되나요 라고 처음부터 다시 묻는 사람과, 단골 판단 기준을 어디에 저장해야 할지 모르겠어요 라고 묻는 사람은 다릅니다. 뒤의 질문은 이미 전체 그래프를 그려놓고 그 안의 한 노드에서 막힌 거예요. 저는 이 질문의 해상도를 봅니다.</p>
<p>이게 저희가 하네스나[3] 코딩 에이전트를 고객에게 직접 써보게 하고, 그 사용 패턴을 분석하는 이유이기도 해요. 얼마나 썼는지가 아니라 어떻게 썼는지를 보는 거죠. 활용률 92%는 얼마나 썼는지의 숫자입니다. 저희가 실제로 보는 건 그 92% 안에서 이 사람이 어떤 순서로 질문을 던졌고, 막혔을 때 어떻게 반응했는가입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="저한테도-적용됩니다">저한테도 적용됩니다<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%A0%80%ED%95%9C%ED%85%8C%EB%8F%84-%EC%A0%81%EC%9A%A9%EB%90%A9%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="저한테도 적용됩니다에 대한 직접 링크" title="저한테도 적용됩니다에 대한 직접 링크" translate="no">​</a></h2>
<p>이 글을 쓰면서 저한테도 물어봤어요. 저는 요즘 그래프를 얼마나 직접 그리고 있나.</p>
<p>솔직히 말하면 저도 가끔 익숙한 패턴이 나오면 그리지 않고 바로 코딩 에이전트한테 넘길 때가 있어요. 그게 틀렸다는 건 아니에요. 다만 그 순간 저도 관문을 하나 건너뛴 겁니다. 문제는 이걸 계속 건너뛰다 보면, 낯선 도메인을 만났을 때 그리는 근육 자체가 굳어 있다는 걸 그제야 알게 된다는 거예요. 제일 나쁜 시점에요.</p>
<p>남을 평가하는 기준을 만들어놓고 정작 저 자신한테는 안 물어보면 그 기준은 의미가 없죠. 저는 웬만하면 AI 없이 그래프를 먼저 그려봅니다. 내가 생각하는지, 생각한다고 착각하는지 확인하려고요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="FDE로 일하며 가장 중요한 &amp;quot;워크플로우&amp;quot; 작성. 가장 시간이 오래걸리지만 빼놓을 수 없는 단계." src="https://nxtgen.co.kr/assets/images/07-b919d5e7bafb19579f5c90405b7357ea.png" width="768" height="1160" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">FDE로 일하며 가장 중요한 &amp;quot;워크플로우&amp;quot; 작성. 가장 시간이 오래걸리지만 빼놓을 수 없는 단계.</span></span></p>
<p><em>FDE로 일하며 가장 중요한 "워크플로우" 작성. 가장 시간이 오래걸리지만 빼놓을 수 없는 단계.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그래서-이-사람에게-일을-맡길-수-있는가">그래서 이 사람에게 일을 맡길 수 있는가<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EA%B7%B8%EB%9E%98%EC%84%9C-%EC%9D%B4-%EC%82%AC%EB%9E%8C%EC%97%90%EA%B2%8C-%EC%9D%BC%EC%9D%84-%EB%A7%A1%EA%B8%B8-%EC%88%98-%EC%9E%88%EB%8A%94%EA%B0%80" class="hash-link" aria-label="그래서 이 사람에게 일을 맡길 수 있는가에 대한 직접 링크" title="그래서 이 사람에게 일을 맡길 수 있는가에 대한 직접 링크" translate="no">​</a></h2>
<p>결국 학생의 두 번째 질문, 이 사람에게 맡겨도 되겠다는 판단은 어디서 나오는가로 돌아옵니다.</p>
<p>제 답은 같아요. 그 사람 손에 애매한 문제를 하나 쥐여주고, 그걸 그래프로 그려보라고 하면 됩니다. AI 없이요. 손님 장부처럼 익숙한 소재가 아니어도 상관없습니다. 오히려 처음 보는 도메인일수록 좋아요. 그 사람이 진짜 구조화를 할 줄 아는지, 아니면 이전에 봤던 패턴을 외워서 흉내만 내는지가 낯선 문제에서 드러나거든요.</p>
<p>그리는 걸 지켜보면 뭘 먼저 묻는지가 보입니다. 그리지 못하는 사람은 바로 답을 내놓으려고 하고, 그리는 사람은 예외부터 묻습니다. 이게 처음 온 손님이면 어떻게 되나요, 라고요. 이 질문이 나오는 순간 저는 이 사람이 사고하고 있다는 걸 압니다. 그다음에 AI를 붙이든 안 붙이든 그건 부차적인 문제예요.</p>
<p>시간을 많이 들일 필요도 없어요. 10분이면 충분합니다. 화이트보드든 종이든 상관없고, AI 화면은 끄고 시작합니다. 완성된 그림을 보려는 게 아니라, 그리는 도중에 나오는 질문을 보려는 거니까요. 완벽하게 그리는 사람은 오히려 의심스럽습니다. 실제 업무는 그렇게 깔끔하지 않거든요. 제가 믿는 건 막혔을 때 정확히 어디서 막혔는지 짚어내는 사람입니다.</p>
<p>활용률 92%는 이 질문에 답을 주지 않습니다. 그림을 그릴 수 있는가, 그리고 예외 앞에서 무엇을 묻는가만이 답을 줍니다.</p>
<p>학생이 처음 물어본 두 개의 질문, 흔적을 어떻게 아는가와 맡겨도 되는가는 사실 하나였습니다. 흔적이 곧 신뢰의 근거였어요. 그리고 그 흔적은 화면 안에 있지 않았습니다. 화면 밖에서, 그 사람이 무엇을 먼저 그렸는가에 있었습니다.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] 컴퓨터과학에서 그래프는 노드(정점)와 엣지(간선)로 이루어진 구조입니다. 방향성 있는 엣지는 한쪽으로만 흐르는 관계를 나타내며, 여기서는 프로세스의 분기와 흐름을 표현하는 데 쓰였습니다.</p>
<p>[2] 여기서 해자는 경제적 해자, 즉 경쟁자가 쉽게 따라오지 못하게 막아주는 구조적 우위를 뜻합니다. 코드를 짤 수 있다는 것 자체는 더 이상 그런 우위가 되지 못한다는 의미로 쓰였습니다.</p>
<p>[3] 하네스는 모델 호출 주변을 감싸는 실행 골격을 말합니다. 프롬프트 구성, 도구 호출, 반복 제어, 실패 처리 같은 것들을 묶어 놓은 층입니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="참고">참고<a href="https://nxtgen.co.kr/blog/2026/08/24/ai-usage-rate-tells-nothing#%EC%B0%B8%EA%B3%A0" class="hash-link" aria-label="참고에 대한 직접 링크" title="참고에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class="">이 글은 실제 대학생에게 받은 질문을 계기로 썼습니다. FDE 조직을 어떻게 구성할지 다룬 지난 글의 후속편이기도 합니다.</li>
</ul>
<p>이 글의 주장과 판단은 제 경험에서 나온 것입니다.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[FDE는 혼자 다 하는 사람이 아니다.]]></title>
            <link>https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role</link>
            <guid>https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role</guid>
            <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AI팀과 SA팀을 FDE 조직으로 합친 이유와, 반년 넘게 FDE로 혼자도 팀으로도 일해 보며 내린 결론을 정리했습니다.]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>최근에 AI팀과 SA팀을 합쳐 FDE 조직으로 통합했습니다.<br>
<!-- -->반년 넘게 FDE로 일하면서 혼자도 해보고 팀으로도 해봤는데, 그 과정에서 내린 결론을 정리했습니다.</p>
</blockquote>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/01-9c5720c03a66057e0aaefd46ee40ea18.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ai-부서를-따로-둘-이유가-있을까">AI 부서를 따로 둘 이유가 있을까<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#ai-%EB%B6%80%EC%84%9C%EB%A5%BC-%EB%94%B0%EB%A1%9C-%EB%91%98-%EC%9D%B4%EC%9C%A0%EA%B0%80-%EC%9E%88%EC%9D%84%EA%B9%8C" class="hash-link" aria-label="AI 부서를 따로 둘 이유가 있을까에 대한 직접 링크" title="AI 부서를 따로 둘 이유가 있을까에 대한 직접 링크" translate="no">​</a></h2>
<p>먼저 조직 얘기부터 하겠습니다. 최근 저는 AI팀과 SA팀을 합쳐서 FDE/SA로 통합했어요.</p>
<p>통합한 이유는 간단합니다. AI가 더 이상 리서처의 영역이 아니라면, 굳이 구분된 부서로 둘 필요가 있을까 싶었거든요.</p>
<p>한번 생각해보죠. 요즘 AI 개발이라고 부르는 것들이 실제로 뭔지. 대부분은 이미 서빙되고 있는 모델을 가져다가 인퍼런스하고, 필요한 형태로 래핑하고, 혹은 하네스로 감싸서[1] 에이전트를 만드는 선에서 끝납니다. 모델 자체를 만드는 게 아니에요. 남이 만들어 놓은 것을 어떻게 엮을 것인가의 문제죠.</p>
<p>이걸 별도의 전문 부서가 해야 할 일이라고 부를 수 있을까요. 저는 아니라고 봤습니다.</p>
<p>물론 예외는 있습니다. 모델을 지속적으로 건드리는 일이라든지, 파운데이션 모델을 직접 다루는 일이라든지, MLOps로[2] 배포하고 운영하는 오퍼레이션 엔지니어라면 얘기가 완전히 다르죠. 그건 진짜 별도의 전문성이 필요한 영역입니다. 그게 아닌 이상 AI 디벨롭먼트 부서를 따로 운영하기는 좀 힘들 것 같더라고요.</p>
<p>결국에는 전체를 FDE로 가되, 솔루션 아키텍트라는 직군은 여전히 중요하니까 FDE/SA라는 이름을 붙였습니다. 아키텍처를 설계하는 능력이 사라진 게 아니라, 그 능력을 고객 안에서 쓰는 형태로 바뀐 것뿐이거든요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="AI팀과 SA팀을 FDE/SA로 통합" src="https://nxtgen.co.kr/assets/images/02-285db1097ac9a9a2d1497e7d0343d7d5.png" width="1400" height="630" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">AI팀과 SA팀을 FDE/SA로 통합</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="fde라는-롤-그리고-흔한-오해">FDE라는 롤, 그리고 흔한 오해<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#fde%EB%9D%BC%EB%8A%94-%EB%A1%A4-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%9D%94%ED%95%9C-%EC%98%A4%ED%95%B4" class="hash-link" aria-label="FDE라는 롤, 그리고 흔한 오해에 대한 직접 링크" title="FDE라는 롤, 그리고 흔한 오해에 대한 직접 링크" translate="no">​</a></h2>
<p>FDE는 팔란티어에서 먼저 만든 롤입니다.[3] Forward Deployed Engineer, 고객 안으로 들어가서 같이 팀을 이루고 목적을 이루는 엔지니어죠.</p>
<p>이게 왜 생겼는지를 보면 성격이 분명해집니다. 팔란티어는 온톨로지 시스템을 고객사에 내재화시켜야 했어요.[4] 제품을 팔고 끝나는 게 아니라, 고객의 데이터와 업무 구조를 그 시스템 위에 올려야 하는 겁니다. 그러려면 고객의 도메인을 아는 사람이 안에 들어가 있어야 하죠. AI 시스템을 구축하고 에이전트를 도입하는, 그런 고객 니즈를 충족하려고 만들어진 롤입니다.</p>
<p>그런데 포워드 디플로이드라는 이름 때문인지, 상주로 들어가서 한 명이 히어로처럼 모든 걸 처리하는 구조라고 생각하시는 분들이 좀 있습니다. 실제로도 그렇게 일하는 경우를 많이 봤어요. 고객사에 한 명 꽂아 넣고, 그 사람이 요구사항 받고 설계하고 구현하고 배포까지 다 하는 그림이죠.</p>
<p>그런데 실제로는 그렇지 않습니다. 적어도 저는 그렇게 해서는 안 된다는 결론에 도달했어요.</p>
<p>이 오해가 왜 문제냐면, 채용과 평가가 전부 거기에 맞춰지기 때문입니다. 혼자 다 하는 사람을 뽑으려고 하니까 기준이 계속 올라가고, 그런 사람은 시장에 거의 없어요. 어렵게 뽑아도 그 한 명이 감당할 수 있는 만큼이 그 프로젝트의 상한선이 됩니다. 조직이 아니라 개인의 역량에 프로젝트를 거는 구조가 되는 거죠.</p>
<p>저도 FDE로 전환한 이후에 여러 프로젝트를 혼자 해보고, 팀원들과 함께 해보기도 했습니다. 거기서 내린 결론은 이겁니다.</p>
<p><strong>혼자 일하는 것보다는 조직화된 소규모 팀으로 하나의 문제에 집중하는 것이 옳다.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="혼자-일하는-게-편한데-왜-안-되는가">혼자 일하는 게 편한데, 왜 안 되는가<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%ED%98%BC%EC%9E%90-%EC%9D%BC%ED%95%98%EB%8A%94-%EA%B2%8C-%ED%8E%B8%ED%95%9C%EB%8D%B0-%EC%99%9C-%EC%95%88-%EB%90%98%EB%8A%94%EA%B0%80" class="hash-link" aria-label="혼자 일하는 게 편한데, 왜 안 되는가에 대한 직접 링크" title="혼자 일하는 게 편한데, 왜 안 되는가에 대한 직접 링크" translate="no">​</a></h2>
<p>먼저 솔직하게 말하면, 저는 혼자 일하는 게 더 편합니다.</p>
<p>예전에 개발팀과 나눠서 일해 봤어요. 제가 고객 앞에 서서 PM 역할을 하고, 뒤에서 개발팀이 받아서 처리하는 식이었습니다. 혹은 AI 컴포넌트만 제가 개발하고 일반적인 코어 백엔드 서비스는 개발팀이 주도하기도 했고요.</p>
<p>그런데 하나의 통합된 팀으로 묶이지 않으면 힘들더라고요. 고객이 어제 한 말이 오늘 바뀌는데, 그걸 다른 부서에 전달하고 이해시키고 일정을 다시 잡는 데 드는 비용이 생각보다 큽니다. 다른 부서와 협업하는 것보다 빠르게 피드백을 주고받는 게 훨씬 중요했어요. 그래서 소통 비용이 큰 관계라면 차라리 혼자가 낫다는 게 제 결론이었습니다.</p>
<p>그러면 혼자 하면 되지 않느냐. 여기서 벽에 부딪힙니다.</p>
<p>혼자 일하면 반드시 마주하는 것들이 있어요. 무엇이 있을까요.</p>
<p>일단 시간이 무한하지 않습니다. 이게 전부라고 해도 될 만큼 큽니다. 하고 싶은 것과 제안하고 싶은 것은 머리에 다 떠오릅니다. 이렇게 하면 되겠다는 그림도 그려져요. 그런데 그걸 구현까지 제가 맡으면 시간이 감당이 안 됩니다.</p>
<p>경험이 쌓이면 나아지지 않느냐고 하실 수 있는데, 절반만 맞습니다. 이전 프로젝트 경험이 밑바탕이 되긴 해요. 예전에 했던 것들이 다음 프로젝트에서 판단을 빠르게 해주는 건 분명합니다. 그런데 기업마다 기간도 다르고 버짓도 달라서 똑같이 적용할 수가 없습니다. 저기서 통했던 구성이 여기서는 예산 때문에 못 쓰는 경우가 계속 생기거든요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="설계를-하다-보면-구현할-시간이-없다">설계를 하다 보면 구현할 시간이 없다<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%84%A4%EA%B3%84%EB%A5%BC-%ED%95%98%EB%8B%A4-%EB%B3%B4%EB%A9%B4-%EA%B5%AC%ED%98%84%ED%95%A0-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%97%86%EB%8B%A4" class="hash-link" aria-label="설계를 하다 보면 구현할 시간이 없다에 대한 직접 링크" title="설계를 하다 보면 구현할 시간이 없다에 대한 직접 링크" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/03-d712727849ebbf878daeaa7f62da7744.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<p>구체적인 예를 들어 보겠습니다.</p>
<p>엑셀에 수기로 입력해서 나중에 연말정산에 쓰는 업무가 있다고 합시다. 이걸 디지털 트랜스포메이션 하고 싶다는 요청이 들어옵니다.</p>
<p>방법론은 되게 많죠. 당장 떠오르는 것들도 많습니다. 문제는 그다음입니다.</p>
<p>사람이 원래 만들어 온 프로세스가 있어요. 원래 있던 프로세스가 있고, 그런 지침들도 있습니다. 원래 사람이 이렇게 일을 했다, 여기를 클릭하고 저기에 입력하고 이렇게 진행했다는 절차가 있는 거죠. 그러면 이걸 하나씩 판단해야 합니다.</p>
<p>그거를 API로 바꿀 수 있는지 봐야 하고, API가 없으면 에이전트가 실제로 그 화면에 들어가서 작업하게 할 것인지 정해야 합니다. 에이전트로 간다면 또 에이전트의 워크플로우가 필요하겠죠. 어디서 사람이 확인하고 어디서 자동으로 넘어갈지도 정해야 하고요.</p>
<p>이게 다 설계입니다. 그리고 그 설계하는 일들을 하다 보면 정작 구현할 시간이 부족해서 프로젝트가 끝나 버립니다.</p>
<p>그걸 어떻게 할 것인가에 대한 부분이 가장 커요. 방법을 몰라서가 아니라, 방법은 아는데 컴포넌트 하나하나까지 제가 다 구현하기가 실제로 되게 힘들다는 겁니다. 이게 제가 많이 부딪혔던 한계예요.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="혼자 할 때와 나눠 맡을 때" src="https://nxtgen.co.kr/assets/images/04-b6b052d86cbf50a7c0e048a07c187452.png" width="1400" height="802" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">혼자 할 때와 나눠 맡을 때</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="그래서-규모로-나눕니다">그래서 규모로 나눕니다<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EA%B7%B8%EB%9E%98%EC%84%9C-%EA%B7%9C%EB%AA%A8%EB%A1%9C-%EB%82%98%EB%88%95%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="그래서 규모로 나눕니다에 대한 직접 링크" title="그래서 규모로 나눕니다에 대한 직접 링크" translate="no">​</a></h2>
<p>모든 프로젝트에 팀이 필요하다는 얘기는 아닙니다. 저는 규모로 나눠서 봅니다.</p>
<p>전체가 아니라 부분 포지션만 가져가는 AX라면[5] 혼자 해도 전혀 문제가 없다고 생각하는 주의입니다. 소규모 엔지니어링이 필요한 정도, 에이전트 하네스 하나 붙이는 정도라면 오히려 혼자가 빠르죠. 앞에서 말한 소통 비용이 안 드니까요.</p>
<p>하지만 대규모거나, 고도화된 그래프 엔지니어링이[6] 필요하거나, 페르소나를 깎아야 하거나, 에이전트 하나가 아니라 정교한 시스템을 만들어야 한다면 다릅니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="프로젝트 규모에 따른 판단" src="https://nxtgen.co.kr/assets/images/05-8a40d48e468b77b19c72f197b8fd7e51.png" width="1317" height="2055" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">프로젝트 규모에 따른 판단</span></span></p>
<p>이런 것들에 대해서는 기존 프로세스를 AI에 맞는 형태로 바꾸는 작업이 먼저 필요한데, 그건 코드를 짜는 일과 성격이 완전히 다릅니다. 업무를 다시 그리는 일에 가까워요. 그 과정에서 요구되는 게 너무 많습니다.</p>
<p>결국에는 보조하는 사람이 필요했습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="다들-같은-얘기를-하고-있다">다들 같은 얘기를 하고 있다<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EB%8B%A4%EB%93%A4-%EA%B0%99%EC%9D%80-%EC%96%98%EA%B8%B0%EB%A5%BC-%ED%95%98%EA%B3%A0-%EC%9E%88%EB%8B%A4" class="hash-link" aria-label="다들 같은 얘기를 하고 있다에 대한 직접 링크" title="다들 같은 얘기를 하고 있다에 대한 직접 링크" translate="no">​</a></h2>
<p>제가 챙겨보는 유튜버들이 있습니다. 빌더 조쉬도 있고[7], 스페이스와이의 황현태 CEO님 글도 많이 봐요.[8] 그런데 팔란티어까지 포함해서 공통적으로 이야기하는 내용이 있더라고요.</p>
<p>롤 자체가 분리되어야 한다는 것, 그리고 소규모 팀을 이루어야 한다는 것입니다.</p>
<p>서로 다른 곳에서 서로 다른 맥락으로 말하는데 결론이 같으면, 그건 좀 믿어볼 만하다고 생각했습니다.</p>
<p>그럼 소규모 팀은 어떻게 생겼을까요.</p>
<p>일단 오케스트레이션을 할 수 있는 가장 높은 티어의 사람이 한 명 필요합니다. 전체 문제를 보고 누가 무엇을 할지 배분하는 사람이죠. 그리고 그를 보조해 줄 수 있는 두 사람이 필요합니다. 두 사람이라고 말한 건 저희 팀이 세 명이라서 그런 거고, 요는 보조할 수 있는 팀원이 있어야 한다는 겁니다. 황현태 CEO님이 말하는 팟도[9] 서너 명 규모예요.</p>
<p>이건 약간 PM과 PO의 개념이 있는 것 같습니다. 프로젝트 매니저를 맡을 수 있는 시니어 레벨이 최상위 레이어에서 고객과 소통하는 역할을 맡고, 나머지 인원이 테크와 서포트를 맡는 구조죠. 이 구조가 저는 되게 인상 깊었습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="결국-파악하는-게-fde의-일이다">결국 파악하는 게 FDE의 일이다<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EA%B2%B0%EA%B5%AD-%ED%8C%8C%EC%95%85%ED%95%98%EB%8A%94-%EA%B2%8C-fde%EC%9D%98-%EC%9D%BC%EC%9D%B4%EB%8B%A4" class="hash-link" aria-label="결국 파악하는 게 FDE의 일이다에 대한 직접 링크" title="결국 파악하는 게 FDE의 일이다에 대한 직접 링크" translate="no">​</a></h2>
<p>역할을 나누는 얘기를 하기 전에, FDE가 실제로 뭘 하는 사람인지 한번 짚고 가겠습니다.</p>
<p>제가 갖고 있는 건 도메인입니다. 고객의 업무가 어떻게 굴러가는지에 대한 지식이죠. 그런데 이 도메인 지식은 처음부터 제 안에 있는 게 아니에요. 고객에게 요구사항을 전달받거나, 아니면 고객도 정리하지 못한 것들을 제가 파악해서 만들어내는 겁니다.</p>
<p>사실 후자가 더 많습니다. 고객이 자기가 원하는 걸 정확하게 문서로 정리해서 주는 경우는 거의 없어요. 이거 좀 자동화하고 싶다는 말 한 줄에서 출발하는 경우가 대부분이죠.</p>
<p>결국 파악하는 게 FDE의 일입니다. 제품을 만드는 건 그다음이에요.</p>
<p>그래서 팀을 나눈다고 할 때도 이 파악하는 일을 누가 어디까지 맡을 것인가가 기준이 됩니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="에코와-델타">에코와 델타<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%97%90%EC%BD%94%EC%99%80-%EB%8D%B8%ED%83%80" class="hash-link" aria-label="에코와 델타에 대한 직접 링크" title="에코와 델타에 대한 직접 링크" translate="no">​</a></h2>
<p>그래서 저희도 에코와 델타라는 체계를 채택했습니다.</p>
<p>다만 팔란티어의 에코와는 성격이 좀 달라요.[10] 팔란티어에서 에코는 비즈니스 도메인 전문가를 모셔와서 그 도메인에 참여시키는 목적이 큽니다. 금융이면 금융을 아는 사람, 제조면 제조를 아는 사람을 데려오는 거죠.</p>
<p>저희는 그렇게 하기가 어렵습니다. 고객사의 업종이 매번 다르거든요. 그래서 제너럴한 지식을 가지고 고객의 요구사항을 이끌어내는 사람을 에코라고 부르기로 했습니다.</p>
<p>에코는 비즈니스 트랙에 가깝습니다. 고객 요구사항을 듣고 구체화하는데, 테크보다는 이 프로세스를 어떻게 정의할 것인가에 무게가 있어요. 인풋이 뭐고 아웃풋이 뭔지, 그 사이에 어떤 컴포넌트를 끼워야 하는지, 그리고 그게 실제로 동작 가능한지에 대한 간단한 PoC 검증까지가[11] 범위입니다. 어떻게 보면 전통적인 프리세일즈의 역할이죠.</p>
<p>델타는 에코가 구체화해 온 범위를 기술적으로 구현합니다.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="에코와 델타의 역할 분담" src="https://nxtgen.co.kr/assets/images/06-aae1ed58e7dfbabf3122ee7196afa0b2.png" width="1400" height="348" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">에코와 델타의 역할 분담</span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="겹치지-않으면-무너집니다">겹치지 않으면 무너집니다<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EA%B2%B9%EC%B9%98%EC%A7%80-%EC%95%8A%EC%9C%BC%EB%A9%B4-%EB%AC%B4%EB%84%88%EC%A7%91%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="겹치지 않으면 무너집니다에 대한 직접 링크" title="겹치지 않으면 무너집니다에 대한 직접 링크" translate="no">​</a></h2>
<p>여기서 제일 중요한 게 하나 있습니다. <strong>두 역할은 반드시 겹쳐야 합니다.</strong></p>
<p>델타가 고객 입장이나 요구사항을 전혀 모르면 어떻게 될까요. 에코한테 무리한 부탁을 하게 됩니다. 이건 기술적으로 이렇게 해야 하니까 고객을 설득해 오라는 식이죠. 반대로 에코가 테크를 모르면 델타한테 무리한 부탁을 하게 됩니다. 고객이 원하니까 이거 되게 해달라는 겁니다. 둘 다 프로젝트를 망칩니다.</p>
<p>사실 두 사람 다 전체 문제에 걸쳐 있긴 합니다. 에코가 기술을 아예 안 보는 것도 아니고, 델타가 고객을 아예 안 만나는 것도 아니에요. 다만 어느 쪽이 더 비즈니스, 그러니까 고객에게 밀접하게 있는가, 어느 쪽이 더 테크와 구현에 집중되어 있는가의 문제입니다. 완전히 잘라내는 게 아니라 무게중심을 어디에 두느냐인 거죠.</p>
<p>그래서 두 분야 간의 슬라이딩 윈도우는 필요하고, 그 안에서 최대한의 시너지를 내야 합니다. 도메인 지식과 구현 능력이 함께 뒷받쳐져야 하나의 시스템을 내재화하고 도입할 수 있는 거죠.</p>
<p><span class="figure_NiaA"><img decoding="async" loading="lazy" alt="요구사항이 넘어가는 흐름" src="https://nxtgen.co.kr/assets/images/07-e614a2cd2357d8358a74f76a669babd9.png" width="1400" height="1391" class="zoomable_B8z7 img_ev3q"><span class="caption_yEYa">요구사항이 넘어가는 흐름</span></span></p>
<p>저는 이 두 역할을 혼자서 다 해봤습니다. 에코처럼 일하다가 델타처럼 일하고, 그러다 다시 에코로 돌아오는 식이었어요. 소규모도 대규모도 진행해봤는데, 결국 시간적으로 부족한 것들이 있더라고요. 한 사람 안에서 두 역할이 겹치는 건 겹침이 아니라 그냥 과부하입니다.</p>
<p>결국 에코팀과 델타팀으로 나눠서 운영하는 것이 베스트였습니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="사람을-맞는-자리에-앉히는-일">사람을 맞는 자리에 앉히는 일<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%82%AC%EB%9E%8C%EC%9D%84-%EB%A7%9E%EB%8A%94-%EC%9E%90%EB%A6%AC%EC%97%90-%EC%95%89%ED%9E%88%EB%8A%94-%EC%9D%BC" class="hash-link" aria-label="사람을 맞는 자리에 앉히는 일에 대한 직접 링크" title="사람을 맞는 자리에 앉히는 일에 대한 직접 링크" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" src="https://nxtgen.co.kr/assets/images/08-0386982e493476826925ab4a635a2d29.png" width="1672" height="941" class="zoomable_B8z7 img_ev3q"></p>
<p>역할을 나눴으면 사람을 앉혀야 합니다. 그런데 팀을 운영하다 보면 사람들 성향이 다 같지가 않아요.</p>
<p>저희 팀만 봐도 비즈니스 성향이 뚜렷한 분과 테크 성향이 뚜렷한 분이 나뉘어 있습니다. 고객 앞에서 말이 술술 나오는 사람이 있고, 코드 앞에서 편안한 사람이 따로 있어요. 이런 분들을 좋은 포지션에 위치시키는 것도 팀장의 능력이겠죠.</p>
<p>최근에 유튜브를 봤는데 이런 얘기가 나오더군요. 내 팀원이 떠난다고 했을 때 가장 먼저 돌아봐야 할 것은, 이 사람을 올바른 포지션에 앉혔는가라는 겁니다.[12]</p>
<p>이 말이 오래 남았습니다. 보통은 처우나 조직 문화를 먼저 떠올리잖아요. 아니면 그 사람의 역량이 부족했나를 보게 되고요. 그런데 자리가 안 맞으면 그 사람은 매일 자기가 잘 못하는 일을 하면서 지내는 거예요. 그만큼 이제는 그 포지션이 갖고 있는 기술적인 한계보다, 그 포지션에 진짜 맞는 업무를 할 수 있는 사람이 필요한 거죠.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="아직-답을-못-찾은-것들">아직 답을 못 찾은 것들<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%95%84%EC%A7%81-%EB%8B%B5%EC%9D%84-%EB%AA%BB-%EC%B0%BE%EC%9D%80-%EA%B2%83%EB%93%A4" class="hash-link" aria-label="아직 답을 못 찾은 것들에 대한 직접 링크" title="아직 답을 못 찾은 것들에 대한 직접 링크" translate="no">​</a></h2>
<p>여기까지가 지금 제가 내린 결론인데, 솔직히 정리가 안 된 것들도 있습니다.</p>
<p>하나는 팀 크기입니다. 에코팀과 델타팀으로 운영하는 게 베스트였다고 했는데, 팀이 세 명인 상황에서 이게 팀을 둘로 쪼갠다는 뜻인지 한 팀 안에서 역할만 나눈다는 뜻인지 저도 아직 명확하지 않아요. 지금은 후자에 가깝게 하고 있습니다.</p>
<p>또 하나는 SA의 자리입니다. FDE/SA로 이름을 붙이긴 했는데, 솔루션 아키텍트가 에코에 가까운지 델타에 가까운지, 아니면 둘 다 걸치는 제3의 역할인지는 더 해봐야 알 것 같습니다.</p>
<p>마지막은 판단 기준이에요. 혼자 할 규모와 팀이 필요한 규모를 나눈다고 했지만, 실제로 프로젝트를 처음 받았을 때 그걸 어떻게 판별할지는 아직 감으로 하고 있습니다. 이건 사례가 더 쌓여야 기준이 나올 것 같아요.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="정리하면">정리하면<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%A0%95%EB%A6%AC%ED%95%98%EB%A9%B4" class="hash-link" aria-label="정리하면에 대한 직접 링크" title="정리하면에 대한 직접 링크" translate="no">​</a></h2>
<p>말이 좀 돌아갔는데, 하고 싶었던 얘기는 하나입니다.</p>
<p>FDE 조직은 소규모 팀으로 운영해야 한다.</p>
<p>에코와 델타로 나누든, 서너 명짜리 팟으로 구성하든 상관없습니다. 중요한 건 혼자 일하는 것이 마냥 좋은 것은 아니더라는 겁니다. 저는 혼자가 편한 사람인데도 그렇게 결론이 났어요.</p>
<p>혼자 할 때는 제가 못 하는 부분이 그대로 프로젝트의 한계가 됩니다. 설계에 시간을 쓰면 구현이 밀리고, 구현에 붙으면 고객과의 대화가 끊기죠. 팀이면 그게 안 됩니다. 정확히는, 안 되게 만들 수 있습니다.</p>
<p>소규모 팀을 구성하고, 각자 장점이 뚜렷한 사람을 맞는 자리에 앉혀서 최고의 프로젝트를 만들어내는 것, 결국에는 그게 중요하겠죠.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="주석">주석<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%A3%BC%EC%84%9D" class="hash-link" aria-label="주석에 대한 직접 링크" title="주석에 대한 직접 링크" translate="no">​</a></h2>
<p>[1] 여기서 하네스는 모델 호출 주변을 감싸는 실행 골격을 말합니다. 프롬프트 구성, 도구 호출, 반복 제어, 실패 처리 같은 것들을 묶어 놓은 층입니다. 모델을 바꾸는 게 아니라 모델을 쓰는 방식을 코드로 고정하는 작업에 가깝습니다.</p>
<p>[2] 머신러닝 모델의 학습, 배포, 모니터링, 재학습을 운영 관점에서 자동화하는 실무 영역입니다.</p>
<p>[3] Forward Deployed Engineer는 고객사 안에서 소프트웨어를 개발하고 배포하는 고객 대면 엔지니어를 뜻합니다. 위키피디아는 이 롤을 a customer-facing software engineer who develops and deploys software within a client company로 정의하고, The role was popularized by Palantir Technologies라고 적고 있습니다. <a href="https://en.wikipedia.org/wiki/Forward_Deployed_Engineer" target="_blank" rel="noopener noreferrer" class="">Forward Deployed Engineer, Wikipedia</a></p>
<p>[4] 팔란티어 Foundry의 온톨로지는 고객사의 데이터와 업무 개념, 그리고 그 위에서 실행되는 액션을 하나의 의미 계층으로 묶는 구조를 말합니다. 제품을 도입한다는 것이 곧 고객의 업무 구조를 이 계층 위에 올린다는 뜻이라, 고객 안에 들어가 일하는 롤이 필요해진 배경이기도 합니다.</p>
<p>[5] AX는 AI Transformation을 줄인 말로, 디지털 전환(DX)에 빗대어 쓰는 표현입니다. 조직의 업무 방식 자체를 AI에 맞게 다시 짜는 쪽에 무게가 있어서, 모델을 도입한다는 말보다 범위가 넓습니다. 앞서 인용한 스페이스와이도 FDE+AI 기반 AX 모델이라는 표현을 씁니다.</p>
<p>[6] 문서나 데이터를 개체와 관계의 그래프로 구성해 검색과 추론에 쓰는 방식을 가리킵니다. 단순 벡터 검색으로 답이 안 나오는 영역에서 최근 자주 언급됩니다.</p>
<p>[7] 빌더 조쉬 (Builder Josh) 유튜브 채널. 기업과 스타트업에서 실제로 쓰이는 AI를 다룹니다. <a href="https://www.youtube.com/@builderjoshkim" target="_blank" rel="noopener noreferrer" class="">youtube.com/@builderjoshkim</a></p>
<p>[8] 스페이스와이(SpaceY)는 2022년 1월 설립된 국내 스타트업으로 황현태 대표가 공동창업했습니다. 기업과 전문 인력을 잇는 DIO를 운영하며, FDE에 AI 에이전트를 결합한 FDE+AI 모델을 내세우고 있습니다. 황현태 대표는 실리콘밸리에서는 이미 제품형 B2B SaaS에서 고품질 인력이 현장에 들어가 커스터마이징하는 FDE 모델로 흐름이 이동하고 있다고 말합니다. <a href="https://kr.linkedin.com/in/ht-hwang" target="_blank" rel="noopener noreferrer" class="">황현태 대표 링크드인</a></p>
<p>[9] 팟은 하나의 목표를 함께 맡는 소수 인원의 실행 단위를 뜻합니다. 부서 단위 조직과 달리 문제 하나를 기준으로 묶였다가 흩어지는 구성을 가리킬 때 씁니다.</p>
<p>[10] 본문에서 말하는 에코와 델타는 팔란티어 방식을 그대로 가져온 것이 아니라 저희 조직에 맞게 다시 정의한 것입니다. 팔란티어 생태계에서 통용되는 구분에서는 델타가 데이터 파이프라인, 온톨로지 모델링, 에이전트 설계를 포함한 프로덕션 구현을 맡고, 에코는 구축된 솔루션이 실제 업무에 정착하고 계속 가치를 내도록 운영과 확산을 맡는 쪽에 가깝습니다. 본문의 에코는 그보다 앞단, 요구사항을 끌어내고 프로세스를 정의하는 역할에 무게가 있습니다. 같은 이름을 쓰지만 범위가 다르다는 점을 감안하고 읽어주세요.</p>
<p>[11] Proof of Concept. 여기서는 완성품을 만드는 것이 아니라, 정의한 프로세스가 실제로 동작할 수 있는지를 최소한으로 확인하는 수준을 뜻합니다.</p>
<p>[12] 기업의 핵심인재를 구분하는 질문 7가지 (퇴사한 이형). <a href="https://youtu.be/FPXPxtBOS7E?si=w6im7pWqiUKxlLka" target="_blank" rel="noopener noreferrer" class="">https://youtu.be/FPXPxtBOS7E?si=w6im7pWqiUKxlLka</a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="참고">참고<a href="https://nxtgen.co.kr/blog/2026/08/21/fde-is-not-a-solo-role#%EC%B0%B8%EA%B3%A0" class="hash-link" aria-label="참고에 대한 직접 링크" title="참고에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://en.wikipedia.org/wiki/Forward_Deployed_Engineer" target="_blank" rel="noopener noreferrer" class="">Forward Deployed Engineer, Wikipedia</a></li>
<li class=""><a href="https://blog.palantir.com/dev-versus-delta-demystifying-engineering-roles-at-palantir-ad44c2a6e87" target="_blank" rel="noopener noreferrer" class="">Dev versus Delta: Demystifying engineering roles at Palantir, Palantir Blog</a></li>
<li class=""><a href="https://blog.palantir.com/a-day-in-the-life-of-a-palantir-deployment-strategist-951cb59a5a96" target="_blank" rel="noopener noreferrer" class="">A Day in the Life of a Palantir Deployment Strategist, Palantir Blog</a></li>
<li class=""><a href="https://www.youtube.com/@builderjoshkim" target="_blank" rel="noopener noreferrer" class="">빌더 조쉬 Builder Josh, YouTube</a></li>
<li class=""><a href="https://www.newstap.co.kr/news/articleView.html?idxno=318276" target="_blank" rel="noopener noreferrer" class="">AI가 엔지니어를 관리한다, 스페이스와이 FDE+AI 모델, 뉴스탭</a></li>
</ul>
<p>이 글의 주장과 판단은 제 경험에서 나온 것입니다. 위 자료는 용어와 사실관계를 확인하는 데 참고했습니다.</p>]]></content:encoded>
        </item>
    </channel>
</rss>