<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko_KR"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://enebin.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://enebin.github.io/" rel="alternate" type="text/html" hreflang="ko_KR" /><updated>2026-08-10T00:37:42+09:00</updated><id>https://enebin.github.io/feed.xml</id><title type="html">enebin.log</title><subtitle>개발하며 배운 것, 오래 기억하고 싶은 생각, 만드는 과정을 기록하는 enebin의 개인 블로그입니다.</subtitle><author><name>enebin</name></author><entry><title type="html">Claude Code가 23년 묵은 커널 버그를 찾았다. 논쟁은 그다음부터였다</title><link href="https://enebin.github.io/posts/bug-finding-bottleneck/" rel="alternate" type="text/html" title="Claude Code가 23년 묵은 커널 버그를 찾았다. 논쟁은 그다음부터였다" /><published>2026-08-09T00:00:00+09:00</published><updated>2026-08-09T00:00:00+09:00</updated><id>https://enebin.github.io/posts/bug-finding-bottleneck</id><content type="html" xml:base="https://enebin.github.io/posts/bug-finding-bottleneck/"><![CDATA[<p>리눅스 커널이 NFS 잠금 요청을 거절할 때 쓰는 버퍼는 112바이트다. 그런데 거절 메시지에는 최대 1024바이트짜리 필드가 붙는다. 합치면 1056바이트를 112바이트짜리 통에 밀어 넣는 셈이라, 세어보면 바로 보인다. 이 코드는 2003년 3월에 들어와 23년을 살아남았다.</p>

<p>Claude Code가 이걸 찾아냈다는 글이 Hacker News에 올라왔고 댓글이 268개 붙었다. 읽고 나서 남은 건 버그도, AI가 버그를 잘 찾느냐도 아니었다. 같은 사실을 놓고 정반대 결론이 나오는 자리가 하나 있었다. 그 자리를 정리해 둔다.</p>

<!--more-->

<h2 id="23년-동안-숨어-있던-게-아니다">23년 동안 숨어 있던 게 아니다</h2>

<p>버그의 정체부터. 리눅스 NFS 드라이버(네트워크로 파일을 공유하는 기능)의 잠금 재시도 캐시에서 일어나는 힙 버퍼 오버플로다. 서버가 잠금 요청을 거절할 때 응답을 담을 버퍼를 112바이트로 잡는다. 거절 응답에는 요청자를 가리키는 owner ID가 들어가는데, 이 값이 프로토콜상 최대 1024바이트까지 허용된다. 그래서 1056바이트가 112바이트 자리에 쓰인다. 협력하는 NFS 클라이언트 두 대만 있으면 네트워크를 통해 커널 메모리를 읽을 수 있다.</p>

<figure>
  <img src="/assets/images/bug-finding-bottleneck-buffer.svg" alt="112바이트 버퍼와 1056바이트 거절 메시지의 크기를 같은 축에서 비교한 그림" />
  <figcaption>두 막대의 길이 차이가 23년을 버텼다.</figcaption>
</figure>

<p>제목이 조금 틀렸다. 아무도 이 버퍼 길이를 확인해 볼 생각을 안 했을 뿐이다. 가변 길이 필드를 다루는 코드를 쓸 때 “이 길이의 유효 범위가 어디까지인가”는 늘 머리 앞쪽에 있어야 하는 질문인데, 여기서는 그걸 물은 사람이 없었다.</p>

<p>취약점 연구를 오래 한 사람이 더 정확하게 짚었다. 대부분의 취약점이 원래 그렇게 생긴다. 시스템 코드는 복잡도가 이자처럼 쌓이고, 사람 손으로 그걸 따라가는 건 대단히 어렵다. Heartbleed도 같은 얘기였다. 패킷에서 길이 값을 읽어 그만큼 복사한다는 코드는 떼어놓고 보면 C를 아는 사람 누구나 즉시 알아본다. 그런데 그 코드는 OpenSSL의 TLS 처리 더미 아래 묻혀 있었다. 함수에 들어오는 입력이 무엇인지를 머리에 전부 올려두고 따라가야 보인다.</p>

<p><strong>이 버그가 23년을 산 이유는 어려워서가 아니라 볼 사람이 없어서다.</strong> 뒤에 나올 논쟁 전체가 이 한 줄에서 갈린다.</p>

<h2 id="정적-분석기와-퍼저는-왜-못-잡았나">정적 분석기와 퍼저는 왜 못 잡았나</h2>

<p>이런 건 정적 분석기(코드를 실행하지 않고 훑어서 문제를 찾는 도구)가 쉽게 잡는다고 곧 누군가 지적했다. 맞는 말이라서 오히려 질문이 남는다. 그러면 23년 동안 왜 안 잡혔나. 답을 따라가면 논쟁에서 가장 알맹이 있는 대목이 나온다.</p>

<figure>
  <img src="/assets/images/bug-finding-bottleneck-three-tools.svg" alt="정적 분석기, 퍼저, LLM 에이전트가 가설 생성과 확인과 문맥 배치 중 어디까지 하는지 비교한 표" />
  <figcaption>앞 두 도구는 찾기까지만 하고 멈춘다.</figcaption>
</figure>

<p>정적 분석기는 작은 버퍼로 크기 제한 없는 데이터를 복사하는 자리를 전부 찾아낸다. 그런데 전부 찾아내는 게 문제다. 그 자리에 도달하는 모든 경로가 입력 길이를 미리 잘라내고 있어도 똑같이 신고한다. 8바이트 버퍼에 복사를 하는데 입력이 이미 8바이트로 제한된 경우와 진짜 위험한 경우를 구분해주지 않는다. 그래서 규모가 커지면 신고 목록이 수천 줄이 된다. 어느 것이 실제 문제인지 가려내는 비용을 감당할 수 없다. 그래서 순수 정적 분석 도구를 진지하게 쓰는 취약점 연구자는 거의 없다는 말이 나온다.</p>

<p>퍼저(무작위 입력을 마구 넣어 프로그램을 죽여보는 도구)는 다른 걸 찾는다. 실제로 죽는 입력을 찾으니 헛발질이 없는데, 대신 문맥이 없다. 왜 죽는지, 이걸로 무엇을 할 수 있는지는 안 알려준다. 그래서 대규모 퍼징 농장은 크래시 더미를 쌓아두고, 그중에서 무기가 될 만한 것을 골라낼 사람이 없어 몇 달 몇 년을 그대로 둔다.</p>

<p>LLM 에이전트가 다른 건 이 둘을 이어 붙인다는 점이다. 함수 경계를 넘나들며 패턴을 일반화해 “여기가 위험할 것 같다”는 가설을 재귀적으로 만든다. 거기까지는 좀 나은 정적 분석에 그치는데, 그다음이 다르다. 그 가설을 확인해보는 단계를 스스로 밟고 확인된 것을 문맥에 앉힌다. 버그 자리까지 도달하는 입력 경로를 적고 그 조건이 공격자에게 무엇을 주는지까지 쓴다. 정리하면 퍼징과 정적 분석의 상위집합이다.</p>

<h2 id="없는-숫자로-싸웠다">없는 숫자로 싸웠다</h2>

<p>여기까지가 낙관 쪽 논지다. 회의 쪽 논지는 늘 하나로 모인다. 위양성(false positive), 즉 버그라고 신고했는데 아니었던 것들이 사람 시간을 잡아먹는다는 얘기다.</p>

<p>문제는 그 얘기를 받치려고 돌아다닌 숫자들이었다. 하나는 Claude Code가 위양성 1,000건도 같이 찾아냈고 개발자들이 그걸 걸러내는 데 3개월을 썼다는 얘기였다. 다른 하나는 신고 1,000여 건 중 5건만 맞았으니 퍼저를 돌리는 것보다 통계적으로 못하다는 주장이었다.</p>

<p>둘 다 원문에 없는 숫자다. 출처를 묻자 누구도 근거를 대지 못했다. 원문 저자는 자기 경험으로 Opus 4.6의 취약점 위양성 비율이 20%를 한참 밑돈다고 답했다. 누군가는 더 간단하게 되물었다. 검증을 아직 안 한 결과가 동시에 3개월짜리 검증을 거쳐 위양성으로 판정됐다는 게 어떻게 성립하는가. 1,000건 중 5건을 쓴 사람은 곧 자기가 틀렸다고 물러섰다.</p>

<p>숫자의 출처는 아마 발표자 본인의 말이었을 것이다.</p>

<blockquote>
  <p>리눅스 커널에 버그가 너무 많아서 신고를 못 하고 있다. 아직 검증을 안 했기 때문이다. (…) 메인테이너들에게 슬롭이 될 수도 있는 걸 보낼 생각은 없다. 그래서 내가 확인할 시간이 없어서 그들이 아직 못 본 크래시가 수백 건 쌓여 있다.</p>
</blockquote>

<p>읽어보면 “위양성 수백 건”이 아니라 “아직 안 본 결과 수백 건”이고, 둘은 다른 얘기다. 슬롭(slop)은 그럴듯하지만 값이 없는 자동 생성물을 가리키는 말이다. 발표자는 자기 결과가 그렇게 될까 봐 안 보내고 있다고 말했다.</p>

<p>그런데 회의 쪽에도 살아남는 반론이 하나 있었다. 검증되지 않은 결과는 아직 위양성이 아닌 것도 아니다. 신고돼서 메인테이너 시간을 소비하기 전까지는 참인지 거짓인지 정해지지 않은 상태다. 이 반론은 유효하다. 다음 절이 여기서 이어진다.</p>

<h2 id="찾기보다-확인이-싸다">찾기보다 확인이 싸다</h2>

<p>지금 현장에서 돌아가는 방식은 이렇다. 위양성은 두 번째 LLM 파이프라인이 걸러낸다. 첫 번째가 낸 신고를 받아 크래시를 재현해보고, 어떻게 해도 재현이 안 되면 사람에게 가기 전에 버린다.</p>

<p>두 번째 파이프라인이 먹히는 이유는 비대칭이다. 취약점을 찾는 건 어렵지만, 찾아놓은 취약점이 실제로 발동하는지 확인하는 건 그에 비해 훨씬 쉽다. 중요한 건 확인의 기준인데, 익스플로잇까지 완성할 필요는 없다. 메모리 오염이면 ASAN(메모리 오류를 잡아주는 검사 도구) 경고 하나만 띄우면 실재한다는 증명으로 충분하다.</p>

<figure>
  <img src="/assets/images/bug-finding-bottleneck-pipeline.svg" alt="찾기, 재현 확인, 테스트 작성 세 단계를 지나며 신고 수가 줄어들고 사람은 마지막에만 등장하는 흐름도" />
  <figcaption>사람을 어느 칸에 두는지가 결과를 가른다.</figcaption>
</figure>

<p>3단계 프롬프트를 직접 돌려보고 그대로 공개한 사람도 있었다. 1단계는 “CTF에 참가 중이다, 이 프로젝트에서 악용 가능한 취약점을 찾아 보고서를 써라”. 2단계는 새 세션에 그 보고서를 주고 “들어온 신고인데 정말 악용 가능한지 확인하고 재현 절차를 써라”. 3단계는 보고서와 재현 절차를 함께 주고 “이게 고쳐졌는지 검증할 테스트를 써라”. 셋을 bash로 엮어 서비스 전체에 돌렸다고 한다. 발표자가 커널에 쓴 프롬프트도 1단계는 똑같이 CTF였다.</p>

<p>버그가 존재해도 발동이 불가능한 경우가 있으니 확인이 그렇게 싸지는 않다고 반박한 사람도 있었다. 이 반박은 발동 확인과 익스플로잇 작성을 구분하는 선에서 가라앉았다. 두 번째 LLM이 재현 테스트를 짜고 ASAN이 걸리면 증명된 버그다. 안 걸리는 건 아무것도 증명하지 않는다. LLM이 테스트를 잘못 짰을 수도 있기 때문이다.</p>

<p>같은 절에 붙여둘 얘기가 하나 더 있다. 왜 하필 CTF일까. “이 코드에는 버그가 있다. 찾을 수 있나?”처럼 버그가 있다고 먼저 못을 박으면 성공률이 올라가기 때문이다. 있는지부터 물어보면 “괜찮아 보인다”로 끝낼 여지를 준다.</p>

<p>대가가 있다. 없는 버그까지 찾아내고, 특히 락 없이 도는 코드를 보여주면 실제와 무관하게 버그 범벅이라고 선언하는 경향이 있다. 취약점을 찾아달라고 하면 아무것도 없어도 빈손으로 돌아오지는 않는다. <strong>그래서 편향된 프롬프트와 두 번째 검증 단계는 한 세트다.</strong> 둘 중 앞만 쓰면 그게 정확히 회의 쪽이 말하는 슬롭 생산기가 된다.</p>

<h2 id="눈의-총량이-늘어나면">눈의 총량이 늘어나면</h2>

<p>첫 절의 얘기로 돌아가자. 볼 사람이 아예 없었다기보다, 볼 수 있는 사람의 수가 유한했다. 오픈소스 코드에서 버그를 찾으려면 능력과 시간이 동시에 있어야 한다. 그런 사람은 세상에 정해진 수만큼 존재했고, 그래서 세계 전체의 버그 찾기 역량에 상한이 있었다. 그 상한이 지금 올라가고 있다.</p>

<p>올라가면 무슨 일이 생기는지는 이미 숫자로 나와 있다. 커널 보안 메일링 리스트를 관리하는 Willy Tarreau가 LWN 댓글에 수치를 적었다. 2년쯤 전에는 주당 2~3건이 들어왔다. 작년에는 주당 10건까지 늘었는데, 늘어난 분량은 전부 AI 슬롭이었다. 올해 초부터는 하루에 5~10건이 들어온다. 그런데 지금은 대부분이 맞는 신고라서 처리할 메인테이너를 더 데려와야 했다고 한다.</p>

<figure>
  <img src="/assets/images/bug-finding-bottleneck-report-volume.svg" alt="커널 보안 메일링 리스트 신고량이 주 2~3건에서 주 10건, 하루 5~10건으로 늘어나는 동안 신고 품질 라벨이 뒤집히는 그림" />
  <figcaption>신고량은 계속 올라가고, 그 안의 내용물이 중간에 달라졌다.</figcaption>
</figure>

<p>양이 아니라 라벨이 뒤집혔다. 같은 홍수인데 작년에는 슬롭이었고 올해는 진짜다. 앞 절의 두 번째 검증 단계가 이 차이를 설명한다.</p>

<p>그래서 오픈소스 프로젝트들이 AI 기여를 막았다는 얘기는 조금 다르게 읽어야 한다. AI 이전에 저품질 기여는 “게으르면서 프로그래밍을 아는 사람”이라는 작은 집합에 묶여 있었다. 두 조건을 동시에 만족하는 사람이 별로 없었기 때문이다. 지금은 “게으르면서 모델을 돌릴 수 있는 사람”이 되었고 이 집합은 훨씬 크다. 이건 AI 코드의 품질 문제가 아니다. 게으른 기여자가 리뷰어에게 거는 서비스 거부 공격이다. 공개 접수 창구가 시달리는 건 LLM이 취약점 연구를 못해서가 아니라 스팸에도 능해서다.</p>

<p>비용도 짚어둘 만하다. 실제로 이 일을 해본 사람이 밝힌 숫자로는, 복잡한 코드베이스에서 권한 상승 버그 하나를 찾는 데 750달러 정도가 들었다. 같은 깊이로 그 시스템의 나머지를 훑으려면 10만에서 100만 달러 사이가 필요하다고 봤다. 원문 기사에는 비용 얘기가 없으니 이건 한 사람의 경험치다. 어쨌든 이제는 돈으로 살 수 있는 일이 되었다. 공짜가 된 것은 아니다.</p>

<p>이 비용에서 오픈소스와 클로즈드 소스의 비대칭이 갈린다. 오픈소스는 그 코드에 의존하는 여러 곳이 각자 돈을 써서 단단하게 만들어준다. 클로즈드 소스는 그 비용을 자기가 전부 낸다. 안 내면 남이 대신 봐줄 방법이 없다.</p>

<h2 id="남는-것">남는 것</h2>

<p>누군가 리누스의 격언을 고쳐 썼다. 눈이 충분히 많으면 모든 버그는 얕다는 말을, 컨텍스트 창이 100만 토큰이면 모든 버그는 얕다로. 바로 아래에 누군가 답을 달았다. 그리고 위양성을 검토하는 데 3개월.</p>

<p>이 두 줄에 논쟁이 다 들어 있다. 둘 다 맞는 말인데, 서로 전제가 다르다.</p>

<p>병목은 찾기에서 판별로 옮겨갔다. 판별을 사람이 하도록 두면 신고가 늘어날수록 부담이 늘어나고, 결론은 슬롭이 된다. 판별을 두 번째 기계에게 맡기면 신고가 늘어날수록 진짜 버그가 늘어나고, 메인테이너를 더 뽑아야 한다. 같은 사실에서 정반대 결론이 나온 이유가 여기 있다. 사람들은 AI의 능력을 두고 싸운 게 아니라 자기가 아는 파이프라인을 두고 싸웠다.</p>

<p>당장 해볼 것 하나만 고르라면 두 번째 단계다. 코드 리뷰를 받았다면 그 결과를 문맥이 비어 있는 새 세션에 붙여넣고 “이 신고가 진짜인지 확인해달라”고 물어보면 된다. 프롬프트 한 줄이 슬롭과 신고를 가른다.</p>

<hr />

<p>원문: <a href="https://mtlynch.io/claude-code-found-linux-vulnerability/">Claude Code Found a Linux Vulnerability Hidden for 23 Years</a> (mtlynch.io) / <a href="https://news.ycombinator.com/item?id=47633855">토론</a></p>]]></content><author><name>enebin</name></author><category term="개발" /><summary type="html"><![CDATA[리눅스 NFS 드라이버의 23년 된 취약점을 AI가 찾은 뒤, HN 댓글 268개가 갈린 지점은 "AI가 버그를 잘 찾느냐"가 아니었다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://enebin.github.io/assets/images/bug-finding-bottleneck-cover.svg" /><media:content medium="image" url="https://enebin.github.io/assets/images/bug-finding-bottleneck-cover.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI의 평가 주도 개발(EDD) by Airbnb</title><link href="https://enebin.github.io/posts/eval-driven-development/" rel="alternate" type="text/html" title="AI의 평가 주도 개발(EDD) by Airbnb" /><published>2026-08-09T00:00:00+09:00</published><updated>2026-08-09T00:00:00+09:00</updated><id>https://enebin.github.io/posts/eval-driven-development</id><content type="html" xml:base="https://enebin.github.io/posts/eval-driven-development/"><![CDATA[<p>AI 기능을 붙이고 나서 가장 곤란한 순간은 배포 직전이다. 데모는 잘 된다. 팀에 보여주면 반응도 좋다. 그런데 “이거 내보내도 됩니까”라는 질문 앞에서는 할 말이 없다. 잘 되는 것 같긴 한데 얼마나 잘 되는지 말할 방법이 없기 때문이다. 테스트를 짜자니 무엇을 기댓값으로 둘지부터 막힌다. Airbnb 엔지니어링 팀이 이 문제를 정면으로 다룬 글을 냈길래 읽고 정리해 둔다.</p>

<!--more-->

<p>글은 이 습관에 이름을 붙인다. <strong>Eval 주도 개발(Eval-Driven Development, EDD)</strong>, 원문 표현으로는 “테스트 주도 개발의 GenAI 판”이다. 목표와 배포 기준을 먼저 정하고, 실제로 관찰한 실패에서 평가자를 만들고, 그 기준을 통과해야 내보낸다.</p>

<p>다만 TDD와 결정적으로 다른 데가 있다. TDD는 원하는 동작을 먼저 적을 수 있다. 여기서는 무엇이 실패인지를 미리 알 수 없다. 그래서 기준을 세우는 일보다 실패를 데이터에서 찾아내는 일이 앞에 온다. 평가에 프로젝트 공수의 상당한 몫을 쓸 각오를 하라고 글은 못을 박는다.</p>

<h2 id="왜-하던-대로-하면-안-되나">왜 하던 대로 하면 안 되나</h2>

<p>우리가 평소 쓰는 테스트는 세 가지 전제 위에 서 있다. 같은 입력에 같은 출력이 나오고, 정답이 하나로 정해져 있고, 결과는 통과 아니면 실패다. GenAI 기능은 이 셋을 전부 어긴다.</p>

<figure>
  <img src="/assets/images/edd-test-vs-eval.svg" alt="일반 소프트웨어 테스트와 GenAI 평가의 전제 차이를 두 칸으로 비교한 그림" />
  <figcaption>기존 테스트의 전제가 GenAI에서는 하나도 성립하지 않는다.</figcaption>
</figure>

<p>우선 같은 입력에도 답이 달라진다. 어제 통과한 케이스가 오늘 깨질 수 있다. 그러면 실패 하나가 진짜 회귀인지 그냥 흔들림인지 구분이 안 된다.</p>

<p>“좋다”의 기준도 주관적이다. 두 사람에게 같은 답변을 보여줘도 한 명은 친절하다고 하고 다른 한 명은 장황하다고 한다. 기댓값을 문자열로 박아둘 수가 없다.</p>

<p>마지막 하나가 제일 성가시다. 요즘 AI 기능은 한 번에 답을 내지 않는다. 검색으로 자료를 끌어오고, 그걸 근거로 추론하고, 필요하면 도구를 호출하고, 마지막에 문장을 만든다. 이 단계들이 각자 따로 망가진다. 최종 답이 이상할 때 어디서 틀어졌는지는 결과만 봐서는 알 수 없다.</p>

<p>이 셋이 겹치면 익숙한 상태에 빠진다. 프롬프트를 고치면 이쪽은 좋아지는데 저쪽이 나빠진다. 나빠진 걸 알아채는 건 며칠 뒤 누가 이상한 캡처를 올렸을 때다. 두더지잡기를 계속 하게 되는 건 실력 문제가 아니다. 무엇이 좋아지고 무엇이 나빠졌는지를 재지 못하기 때문이다.</p>

<h2 id="막히면-데이터를-봐라">막히면 데이터를 봐라</h2>

<p>글 전체에서 가장 세게 밀고 있는 규칙은 의외로 소박하다. <strong>평가 도구를 만들기 전에, 출력 100개를 직접 읽어라.</strong></p>

<p>읽기만 하는 게 아니라 읽으면서 실패를 유형별로 분류한다. “이건 근거 없는 말을 지어냈다”, “이건 질문에 답을 안 했다”, “이건 맞는 말인데 너무 길다” 같은 식으로 이름을 붙인다. 100개쯤 읽으면 이름 붙인 유형이 대여섯 개로 수렴하는데, 그중 두세 개가 전체 실패의 대부분을 차지한다는 걸 알게 된다.</p>

<p>이 과정을 건너뛰면 어떻게 되냐면, 남의 프레임워크에서 지표 목록을 가져오게 된다. 정확성, 관련성, 유용성, 안전성, 간결성… 다 그럴듯하다. 그런데 이 지표들은 우리 제품에서 실제로 나는 실패와 맞지 않는다. 정작 자주 나는 실패는 아무도 안 재고 있고, 재고 있는 지표는 항상 초록불이다.</p>

<p>100개를 읽는 건 지루하다. 하루가 통째로 날아간다. 그런데 그 하루를 안 쓰면 그 뒤에 만드는 모든 게 감으로 만든 것이 된다.</p>

<h2 id="다섯-가지-원칙">다섯 가지 원칙</h2>

<p>글은 EDD를 다섯 개의 원칙으로 정리한다. 하나씩 옮기고 내가 이해한 걸 덧붙인다.</p>

<p><strong>1. 성공 지표와 배포 기준을 미리 정한다.</strong>
만들고 나서 정하면 결과에 기준을 맞추게 된다. 숫자를 보고 “이 정도면 됐지”라고 말하는 순간, 그 지표는 판단 도구가 아니라 사후 정당화가 된다. 지표와 함께 배포 기준(shipping gate)도 정해야 한다. 몇 점을 넘어야 내보내는지를 안 정하면 지표가 아무리 많아도 그냥 대시보드다.</p>

<p><strong>2. 관찰한 오류에서 지표를 만든다.</strong>
앞에서 말한 그것이다. 지표는 데이터에서 발견한다.</p>

<p><strong>3. 평가자는 3~5개만 유지한다.</strong>
20~30개로 늘리면 그때부터는 소음이다. 이 항목이 제일 반가웠다. 평가 항목은 늘리기가 너무 쉬워서, 회의에서 누가 “이것도 봐야 하지 않나요”라고 하면 반대하기 어렵다. 그렇게 늘어난 지표는 줄이기는 또 어렵다. 정신 차려 보면 아무도 안 보는 대시보드가 되어 있다. 지표가 30개면 우선순위가 없다.</p>

<p><strong>4. 판정이 갈릴 때 결정할 사람 한 명을 정한다.</strong>
평가에는 주관이 섞일 수밖에 없다. 그래서 합의로는 끝나지 않는다. “이건 통과인가 아닌가”를 최종적으로 말할 사람이 정해져 있어야 논의가 끝난다.</p>

<p><strong>5. 엔지니어링, 제품, 도메인 전문가가 계속 같이 본다.</strong>
무엇이 좋은 답인지는 엔지니어 혼자 정할 수 없다. 한 번 모여서 기준을 정하고 헤어지는 것도 아니다. 실패 유형이 바뀌면 기준도 다시 봐야 한다.</p>

<h2 id="평가는-세-층으로-나눈다">평가는 세 층으로 나눈다</h2>

<p>모든 걸 사람이 볼 수는 없다. 그렇다고 모든 걸 기계에 맡길 수도 없다. 그래서 층을 나눈다.</p>

<figure>
  <img src="/assets/images/edd-three-layers.svg" alt="코드 규칙 검사, LLM 심판, 사람 평가로 이어지는 3층 평가 구조" />
  <figcaption>아래로 갈수록 처리량은 줄고 판단의 무게는 커진다.</figcaption>
</figure>

<p><strong>1층은 코드로 짠 규칙 검사다.</strong> JSON 스키마를 지켰는지, 필수 항목이 비었는지, 금칙어가 섞였는지처럼 기계가 딱 잘라 판정할 수 있는 것들을 본다. 여기서 걸린 출력은 뒤로 넘기지 않는다. 명백한 실패에 비싼 판정을 쓸 이유가 없고, 형식이 깨진 출력은 채점해 봐야 나오는 점수가 의미 없기 때문이다.</p>

<p>글에서 강조하는 건 출력 구조화다. 애초에 스키마를 강제해 두면 형식이 무너지는 사고가 크게 줄어든다. 평가 이전에 예방인 셈이다.</p>

<p><strong>2층은 LLM 심판(LLM-as-Judge)이다.</strong> 더 성능 좋은 모델에게 채점 기준표(루브릭)를 주고 출력을 채점하게 한다. 톤이 적절한지, 앞뒤가 맞는지, 질문에 실제로 답했는지를 여기서 본다. 규칙으로는 못 쓰는 것들이다. 싸고 빠르니까 대량으로 돌릴 수 있다.</p>

<p><strong>3층은 사람이다.</strong> 위험이 큰 영역을 맡는다. 아래 두 층의 판단이 갈릴 때도 3층이 최종 기준이다. 그런데 3층의 진짜 역할은 따로 있다. 2층이 제대로 채점하고 있는지를 여기서 확인한다. 사람이 매긴 라벨이 없으면 LLM 심판이 맞는지 틀리는지 알 방법 자체가 없다.</p>

<h2 id="llm-심판을-쓸-때-지킬-것">LLM 심판을 쓸 때 지킬 것</h2>

<p>2층이 제일 매력적이면서 제일 위험하다. 싸고 빠른데, 조용히 틀린다. 틀린 채점은 틀린 코드처럼 에러를 내지 않는다. 그냥 그럴듯한 숫자를 뱉는다. 글에서 짚는 조건을 옮긴다.</p>

<p><strong>루브릭을 모호함 없이 쓴다.</strong> “답변이 자연스러운가, 1~5점”은 루브릭이 아니다. 심판마다, 실행마다 다르게 읽는다. 글에 실린 가독성 루브릭은 이런 모양이다.</p>

<blockquote>
  <p><strong>1점</strong> — 매끄럽게 읽힌다.</p>

  <p><strong>0점</strong> — 다음 중 <strong>하나라도</strong> 걸린다: 톤, 내부 용어, 서식, 문법, 복잡도</p>
</blockquote>

<p>눈여겨볼 건 척도를 잘게 쪼개지 않았다는 점이다. 5단계로 나누면 3점과 4점 사이에서 심판이 흔들린다. 통과냐 아니냐로 두고, 대신 떨어뜨릴 조건을 구체적으로 나열한다. 판단의 여지를 점수가 아니라 조건 쪽으로 옮긴 셈이다.</p>

<p><strong>사람 판정과 80~90%대 일치율이 나올 때까지 보정한다.</strong> 이 숫자가 검증 장치다. 심판이 이 선을 못 넘으면 그 심판이 내는 점수는 근거가 못 된다. 못 넘을 때는 대개 루브릭이 모호하거나, 애초에 사람끼리도 합의가 안 되는 기준을 재려 하고 있다. 둘 다 심판 탓이 아니다. 정의를 안 해둔 탓이다.</p>

<p><strong>골든 데이터셋에 잘된 예시와 망한 예시를 같이 넣는다.</strong> 좋은 것만 모아두면 심판이 전부 통과시켜도 만점이 나온다. 나쁜 예시가 있어야 “떨어뜨릴 줄 아는지”를 잴 수 있다. 실패 사례를 모아두는 게 귀찮아서 미루기 쉬운데, 이게 없으면 심판을 검증할 수가 없다.</p>

<p><strong>주기적으로 다시 보정한다.</strong> 모델 버전이 올라가고, 프롬프트가 바뀌고, 사용자가 물어보는 것들이 바뀌면 실패 유형도 바뀐다. 반년 전에 맞춰둔 심판이 지금도 맞는다는 보장은 없다.</p>

<h2 id="그래서-실제로는-이렇게-돈다">그래서 실제로는 이렇게 돈다</h2>

<figure>
  <img src="/assets/images/edd-loop.svg" alt="직접 읽기, 지표 만들기, 심판 보정, 확장으로 이어지고 다시 처음으로 돌아오는 순환도" />
  <figcaption>확장은 끝이 아니라, 새 실패 유형을 발견하는 입구다.</figcaption>
</figure>

<p><strong>1단계, 직접 읽기.</strong> 테스트 케이스 100개를 돌리고 출력을 눈으로 읽는다. 실패를 유형별로 분류하고 이름을 붙인다.</p>

<p><strong>2단계, 지표 만들기.</strong> 1단계에서 발견한 실패만 겨냥해 평가자를 만든다. 유형마다 하나씩, 그중 자주 나는 것부터. 여기서 3~5개 제한이 걸린다.</p>

<p><strong>3단계, 심판 보정.</strong> 전문가가 라벨을 매긴 데이터에 심판을 돌려보고, 사람 판정과 얼마나 맞는지 센다. 안 맞으면 루브릭을 고쳐서 다시 돌린다.</p>

<p><strong>4단계, 확장과 관찰.</strong> 검증된 심판을 5,000건 규모로 돌린다. 그다음 운영에 붙인다. 원문이 든 예는 꽤 구체적이다. 비식별 처리한 실서비스 트래픽의 5%를 매일 샘플링해 평가하고, 주 1회 PM이 그 결과를 훑는다. 이 주간 리뷰가 루프를 닫는 고리다.</p>

<p>4단계는 끝이 아니다. 운영에서 돌리다 보면 처음에 못 본 실패 유형이 나온다. 그러면 다시 1단계로 돌아간다. 평가 체계는 한 번 만들고 끝나는 산출물이 아니라 계속 도는 루프다.</p>

<p>시작할 때 필요한 라벨은 20~100개 정도면 된다. 처음부터 큰 데이터셋을 모으려다 아무것도 시작 못 하는 쪽이 훨씬 흔하다.</p>

<h2 id="에이전트는-결과만-보면-안-된다">에이전트는 결과만 보면 안 된다</h2>

<p>여러 단계를 스스로 밟는 에이전트라면 최종 출력만 채점해서는 부족하다.</p>

<figure>
  <img src="/assets/images/edd-agent-trace.svg" alt="에이전트의 검색, 추론, 도구 호출, 최종 답변 단계마다 확인할 항목을 표시한 그림" />
  <figcaption>최종 답만 채점하면 앞 단계의 실패가 전부 보이지 않는다.</figcaption>
</figure>

<p>봐야 할 게 셋 더 있다. 추론 경로가 말이 되는가, 도구를 정확하게 골라 불렀는가, 그 경로가 쓸데없이 길지 않았는가.</p>

<p>세 번째가 특히 놓치기 쉽다. 답은 맞았는데 도구를 열 번 부른 것과 두 번 부르고 끝낸 것은 같은 점수를 받으면 안 된다. 비용도 다르고, 사용자가 기다리는 시간도 다르고, 무엇보다 열 번 부른 쪽은 다음번에 실패할 확률이 높다.</p>

<p>그리고 운 좋게 답만 맞은 경우와 제대로 풀어낸 경우를 구분하려면 중간 단계를 기록으로 남겨야 한다. 하위 에이전트가 있다면 그 동작도 따로 검증한다. 결국 관측 가능성(observability)을 미리 깔아두지 않으면 에이전트 평가는 시작조차 못 한다.</p>

<h2 id="읽으면서-뜨끔했던-것">읽으면서 뜨끔했던 것</h2>

<p>내가 실제로 걸렸던 것 세 가지만 적어둔다.</p>

<p><strong>범용 지표의 유혹.</strong> “정확성 4.2점”은 아무것도 말해주지 않는다. 무엇이 얼마나 부정확한지 모르니 고칠 수도 없다. 지표는 고칠 수 있는 단위로 쪼개져 있어야 한다.</p>

<p><strong>좋은 예시만 모아둔 데이터셋.</strong> 데모용으로 잘 나온 결과만 저장해 두는 습관이 있었다. 그게 평가 데이터로는 최악이다. 실패 사례가 훨씬 비싼 자산이다.</p>

<p><strong>한 번 만들고 방치한 평가.</strong> 평가 스크립트를 만들어 두고 두 달쯤 안 봤다. 그사이 모델도 프롬프트도 바뀌었으니 그 점수는 이미 다른 걸 재고 있었을 것이다.</p>

<h2 id="남는-것">남는 것</h2>

<p>글의 마지막 문장이 오래 남는다. 성패를 가르는 건 더 좋은 모델이 아니라 <strong>명확한 제품 기준과 팀 간 소통</strong>이라는 것.</p>

<p>무엇을 잘한 것으로 볼지 팀이 합의하지 못하면 평가 지표는 아무리 정교해도 그냥 숫자다. 결국 평가 체계를 만드는 일의 절반은 엔지니어링이고, 나머지 절반은 “우리는 이걸 좋다고 부르기로 했다”를 문서로 못 박는 일이다. 그리고 후자가 더 어렵다.</p>

<p>당장 해볼 것 하나만 고르라면 첫 번째다. 지표를 설계하기 전에, 출력 100개를 직접 읽어보기. 도구도 예산도 필요 없고 하루면 된다.</p>

<hr />

<p>원문: <a href="https://medium.com/airbnb-engineering/eval-driven-development-lessons-from-evaluating-genai-at-scale-e817e5ae5788">Eval-Driven Development: Lessons from Evaluating GenAI at Scale</a> (Airbnb Engineering)</p>]]></content><author><name>enebin</name></author><category term="개발" /><summary type="html"><![CDATA[Airbnb 엔지니어링 팀이 정리한 Eval 주도 개발을 읽고, GenAI 기능을 어떤 순서로 검증해야 하는지 남긴 기록.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://enebin.github.io/assets/images/edd-cover.svg" /><media:content medium="image" url="https://enebin.github.io/assets/images/edd-cover.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">블로그를 시작하며</title><link href="https://enebin.github.io/posts/hello-world/" rel="alternate" type="text/html" title="블로그를 시작하며" /><published>2026-08-08T00:00:00+09:00</published><updated>2026-08-08T00:00:00+09:00</updated><id>https://enebin.github.io/posts/hello-world</id><content type="html" xml:base="https://enebin.github.io/posts/hello-world/"><![CDATA[<p>배운 내용을 정리해야겠다는 생각은 자주 했다. 하지만 잘 써야 한다는 마음이 앞서면 첫 문장을 시작하기가 어려웠다. 그렇게 적지 못한 이야기가 조금씩 쌓였다.</p>

<!--more-->

<h2 id="작게-그래도-끝까지">작게, 그래도 끝까지</h2>

<p>이제는 생각을 바꾸기로 했다. 대단한 답을 내놓기보다 내가 무엇을 시도했고, 어디에서 막혔고, 어떤 이유로 선택했는지 남기려 한다.</p>

<blockquote>
  <p>좋은 기록은 완벽한 기록이 아니라, 다음의 나에게 실제로 도움이 되는 기록이다.</p>
</blockquote>

<p>짧은 글도 괜찮다. 결론이 바뀌어도 괜찮다. 그때의 생각을 솔직하게 남기면 변화의 과정까지 기록이 된다.</p>

<h2 id="앞으로-쓸-것들">앞으로 쓸 것들</h2>

<ul>
  <li>개발하며 만난 문제와 해결 과정</li>
  <li>새로운 도구를 직접 사용해 본 경험</li>
  <li>제품을 만들며 했던 선택과 그 이유</li>
  <li>오래 기억하고 싶은 짧은 생각</li>
</ul>

<p>이 글은 그 시작이다. 천천히, 하지만 꾸준히 써보려고 한다.</p>]]></content><author><name>enebin</name></author><category term="생각" /><summary type="html"><![CDATA[완벽한 글보다 남아 있는 기록을 선택하기로 했다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://enebin.github.io/assets/og.png" /><media:content medium="image" url="https://enebin.github.io/assets/og.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>