<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>ebson</title>
    <link>https://ebson.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 2 Jul 2026 13:40:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>ebson</managingEditor>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 한 글자 오타로 .wmv가 매핑을 비껴가던 버그를 고치다</title>
      <link>https://ebson.tistory.com/529</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 제가 다룬 기여 중에는 인자 순서가 바뀐 것도, 큰 정수가 좁혀진 것도, 목록에서 한 줄이 빠진 것도 있었습니다. 이번에 다룰 LangChain4j 수정은 그중에서도 가장 작습니다. 바뀐 것은 코드 한 곳의 글자 한 개입니다. 그런데 그 한 글자가, 흔히 쓰는 비디오 파일 하나가 제 짝을 못 찾고 엉뚱한 답을 받게 만들고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 한 건의 기여를, 무엇이 어긋나 있었고 그것을 어떻게 알아챘으며 한 글자를 고치는 데에도 왜 신경 쓸 거리가 있었는지 처음부터 끝까지 따라가며 정리한 기록입니다. 결과만 보면 글자 하나를 바꾼 변경이지만, 그 글자가 틀렸다고 확신하기까지, 그리고 그것을 고친 흔적을 깔끔히 남기기까지 거친 과정이 저에게는 더 오래 남았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;파일-확장자로-mime-타입을-알아내는-작은-표&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;파일 확장자로 MIME 타입을 알아내는 작은 표&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j는 이미지나 오디오, 비디오 같은 미디어 파일을 LLM에 넘길 때, 그 파일이 어떤 종류인지를 MIME 타입이라는 문자열로 함께 알려 줍니다. 예를 들어 mp4 비디오라면&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/mp4, png 이미지라면&lt;span&gt;&amp;nbsp;&lt;/span&gt;image/png&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 식입니다. 모델에게 &quot;이건 이런 형식의 파일이다&quot;라고 알려 주는 꼬리표인 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;이 꼬리표를 붙여 주는 일을&lt;span&gt;&amp;nbsp;&lt;/span&gt;CustomMimeTypesFileTypeDetector라는 클래스가 맡습니다. 이 클래스는 안에 작은 표를 하나 들고 있습니다. 파일 확장자를 열쇠로 삼고, 그에 대응하는 MIME 타입을 값으로 둔 표입니다. 파일 경로가 들어오면 확장자를 떼어내 이 표에서 찾아보고, 맞는 항목이 있으면 그 MIME 타입을 돌려줍니다. 표에 없으면 자바 표준 라이브러리가 제공하는 기본 판별 기능으로 넘겨 거기서 답을 얻습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;제가 들여다본 것은 이 표를 채우는 부분이었습니다. 표는 클래스가 처음 불릴 때 한 번에 만들어지는데, 비디오 형식들을 죽 등록하는 구역이 있었습니다. 그 구역 위에는 출처를 밝힌 주석도 달려 있었습니다. 구글의 Vertex AI Gemini가 받아들이는 비디오 형식 목록을 따랐다는 내용이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;값은-wmv를-가리키는데-열쇠는-다른-글자였습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;값은 wmv를 가리키는데 열쇠는 다른 글자였습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;비디오 등록 구역을 한 줄씩 읽어 내려가다 한 항목에서 멈칫했습니다. 이 표는 등록하는 코드가 열쇠와 값을 나란히 적는 형태라, 두 값을 한눈에 비교하기 좋았습니다. 대부분의 줄은 이런 모양이었습니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;29&quot; data-info=&quot;java {data-source-line=&amp;quot;29&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;defaultMappings.put(&quot;mp4&quot;,    &quot;video/mp4&quot;);
defaultMappings.put(&quot;mov&quot;,    &quot;video/mov&quot;);
defaultMappings.put(&quot;avi&quot;,    &quot;video/avi&quot;);
defaultMappings.put(&quot;flv&quot;,    &quot;video/x-flv&quot;);
defaultMappings.put(&quot;webm&quot;,   &quot;video/webm&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;여기서 규칙이 분명히 보입니다. 열쇠 자리에는 파일 확장자가, 값 자리에는 그 확장자에 대응하는 MIME 타입이 들어갑니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;mp4라는 확장자에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/mp4가,&lt;span&gt;&amp;nbsp;&lt;/span&gt;mov에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/mov가 짝지어집니다. 비디오 구역에는 모두 열 개의 항목이 있었는데, 그중 아홉은 이 규칙, 즉 &quot;열쇠는 곧 확장자&quot;라는 패턴을 지키고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;그런데 나머지 한 줄만 결이 달랐습니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;41&quot; data-info=&quot;java {data-source-line=&amp;quot;41&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;defaultMappings.put(&quot;mmv&quot;,    &quot;video/wmv&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;값은&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv입니다. WMV라는 비디오 형식을 가리킵니다. WMV는 윈도우 환경에서 흔히 보이는 비디오 형식이고, 그 파일 확장자는&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv입니다. 그렇다면 이 줄의 열쇠는 당연히&lt;span&gt;&amp;nbsp;&lt;/span&gt;wmv여야 했습니다. 그런데 열쇠 자리에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv가 적혀 있었습니다. 첫 글자가 w가 아니라 m이었던 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;여기서 제가 확신을 얻은 부분은, 값이 의도를 증언하고 있었다는 점입니다. 만약 값까지 애매했다면 이게 오타인지 아니면 제가 모르는 어떤 형식인지 헷갈렸을 겁니다. 하지만 값은 분명히&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv였습니다. 이 줄을 쓴 사람의 의도가 WMV 비디오를 등록하려던 것이었음을, 값이 그대로 말해 주고 있었습니다. 게다가 주석이 출처로 가리킨 Vertex AI Gemini의 비디오 형식 목록에도 WMV는 있지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;.mmv라는 확장자는 없습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;.mmv는 알려진 비디오 확장자가 아닙니다. 열쇠 자리의&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv는 그저 잘못 친 글자였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;한-글자-오타가-두-가지-문제를-동시에-만들고-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size26&quot;&gt;한 글자 오타가 두 가지 문제를 동시에 만들고 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;이 오타가 흥미로웠던 것은, 한 글자가 어긋났을 뿐인데 그 결과가 두 방향으로 동시에 어긋나 있었다는 점입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째는 정작 등록하려던&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일이 이 표에서 빠져 버린 것입니다. 표를 찾을 때는 들어온 확장자와 열쇠가 정확히 일치해야 합니다. 그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일의 확장자는&lt;span&gt;&amp;nbsp;&lt;/span&gt;wmv이고, 표에 등록된 열쇠는&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv이니 둘은 만나지 못합니다. 그래서&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일이 들어오면 이 표에서는 짝을 못 찾고, 앞서 말한 자바 표준 라이브러리의 기본 판별로 넘어갑니다. 그쪽이 돌려주는 값은 이 프로젝트의 표가 의도한&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv와 같다는 보장이 없습니다. 실제로 표준 라이브러리는 같은 WMV를 두고&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/x-ms-wmv처럼 다른 문자열을 돌려주곤 합니다. 결국&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일은 이 프로젝트가 정해 둔 꼬리표가 아니라 엉뚱한 곳에서 온 꼬리표를 달게 됩니다. 오류가 나며 멈추는 게 아니라, 그저 다른 값이 조용히 붙는 종류의 어긋남이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;두 번째는 잘못 등록된&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv라는 열쇠가 영영 쓸모없는 항목이 되어 버린 것입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;.mmv는 실제로 존재하는 파일 확장자가 아니니, 어떤 파일도 이 열쇠와 짝지어지지 않습니다. 표 안에 자리는 차지하고 있지만 어느 입력으로도 닿을 수 없는, 말하자면 죽은 항목이었습니다. 누군가 이 표를 읽다가&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv가 무슨 형식인지 찾아보려 한다면 헛수고를 하게 될 자리이기도 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;요약하면, 있어야 할&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;매핑은 없고, 없어도 되는&lt;span&gt;&amp;nbsp;&lt;/span&gt;.mmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;매핑만 덩그러니 남아 있는 상태였습니다. 한 글자가 두 가지를 한꺼번에 어긋나게 한 셈입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;이-표가-실제로-쓰이는-길을-확인했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;59&quot; data-ke-size=&quot;size26&quot;&gt;이 표가 실제로 쓰이는 길을 확인했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;61&quot; data-ke-size=&quot;size16&quot;&gt;오타라는 확신은 섰지만, 이 매핑이 실제로 누군가에게 영향을 주는지도 따져 보고 싶었습니다. 표가 잘못되어 있어도 아무도 그 길을 지나가지 않는다면 영향은 없을 테니까요.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;61&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;이 표를 가진 판별기가 어디에서 불리는지 따라가 보니, Gemini나 Vertex AI 같은 구글 계열 모델로 미디어를 넘기는 변환 코드들이 이걸 쓰고 있었습니다. 사용자가 미디어의 MIME 타입을 직접 지정하지 않으면, 이 변환 코드가 판별기를 불러 확장자로부터 MIME 타입을 채워 넣습니다. 그러니 사용자가&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;비디오를 MIME 타입 없이 이런 모델의 입력으로 넘기면, 방금 본 어긋난 경로를 그대로 지나가게 됩니다. 흔히 쓰는 비디오 형식 하나가 일관되지 않은 꼬리표를 받는 상황이, 일부러 만들어야 닿는 특수한 경우가 아니라 평범한 사용에서 일어날 수 있다는 뜻이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;값은-그대로-두고-열쇠-한-글자만-고쳤습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size26&quot;&gt;값은 그대로 두고 열쇠 한 글자만 고쳤습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size16&quot;&gt;고치는 방법은 분명했습니다. 열쇠를&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;wmv로 바로잡으면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;69&quot; data-info=&quot;java {data-source-line=&amp;quot;69&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;defaultMappings.put(&quot;wmv&quot;, &quot;video/wmv&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 의식적으로 정한 것은 값을 건드리지 않는다는 점이었습니다. 값&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv는 표준으로 통용되는 MIME 타입 이름과는 조금 다릅니다. 앞서 본 것처럼 자바 표준 라이브러리는 같은 형식을&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/x-ms-wmv로 부르기도 합니다. 그렇다고 값을 더 &quot;표준적인&quot; 쪽으로 바꾸고 싶은 마음이 들 수도 있지만, 같은 표의 다른 항목들도&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/mov나&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/avi처럼 출처인 Vertex 문서의 표기를 그대로 따르고 있었습니다. 이 표는 그 나름의 일관된 표기 규칙을 가지고 있었던 것입니다. 제가 고쳐야 할 것은 열쇠의 오타이지 값의 표기 방식이 아니었습니다. 값까지 손대면 동작이 달라지고 변경의 범위가 넓어집니다. 틀린 한 곳만 정확히 고치고 나머지는 그대로 두는 것이, 변경을 작게 유지하면서 의도를 분명히 하는 길이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;이 변경에는 예상치 못한 잔주름이 하나 있었습니다. 이 표를 채우는 줄들은 보다시피 열쇠와 값 사이에 공백을 여러 칸 넣어 세로로 줄을 맞춰 둔 형태였습니다. 그런데 이 프로젝트의 코드 형식 검사 도구는 직전 기준과 달라진 줄만 골라 형식을 다시 맞춥니다. 그래서 제가 고친 그 한 줄만 도구가 단일 공백 형태로 정리하고, 손대지 않은 형제 줄들은 원래의 세로 정렬을 그대로 유지하게 됩니다. 결과적으로 제가 고친 줄만 정렬이 어긋난 것처럼 보입니다. 처음에는 이게 잘못된 것 같아 형제 줄까지 맞춰 정렬을 다시 할까 고민했지만, 그렇게 하면 제 변경과 무관한 줄들까지 diff에 끌려 들어옵니다. 형식 도구가 그 한 줄만 그렇게 만든다면 그것이 도구가 강제하는 올바른 상태이고, 보기에 조금 어긋나더라도 제 변경은 정확히 한 줄에만 머무는 것이 맞았습니다. 기여 규칙에서 &quot;기존 코드를 함께 재포맷하지 말라&quot;고 한 이유를, 이 작은 잔주름에서 체감했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;한-줄짜리-검증을-더했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size26&quot;&gt;한 줄짜리 검증을 더했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size16&quot;&gt;코드를 한 글자 고친 만큼, 이것이 정말 맞는지를 보여 주는 테스트를 더했습니다. 이 저장소는 테스트가 없는 변경은 검토하지 않는다는 규칙을 분명히 두고 있고, 매핑 표의 동작은 환경에 좌우되지 않고 늘 같은 결과를 내므로 단위 테스트로 깔끔하게 확인할 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;다행히 이 클래스의 테스트에는 기본 매핑을 확인하는 단언들이 이미 여럿 있었습니다. 특정 확장자를 넣었을 때 기대하는 MIME 타입이 나오는지를 보는 형태였습니다. 그 패턴을 그대로 따라,&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일을 판별기에 넣었을 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv가 나오는지를 확인하는 단언을 더했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;83&quot; data-info=&quot;java {data-source-line=&amp;quot;83&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@Test
void should_return_a_mime_type_for_wmv_from_default_mapping() {
    // given
    CustomMimeTypesFileTypeDetector detector = new CustomMimeTypesFileTypeDetector();

    // when
    String mimeType = detector.probeContentType(&quot;video.wmv&quot;);

    // then
    assertThat(mimeType).isEqualTo(&quot;video/wmv&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;이 테스트는 고치기 전 코드에서는 실패합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;.wmv가 표를 비껴가 표준 라이브러리의 다른 값을 받기 때문입니다. 그리고 열쇠를 바로잡은 뒤에는 통과합니다. 버그를 재현하는 테스트를 먼저 두고 그것을 통과시키는 방식으로, 같은 오타가 다시 들어오면 곧장 걸리도록 그물을 만들어 둔 것입니다. 이번 변경은 매핑의 열쇠를 바로잡는 일이라, 잘못된 입력을 막는 음성 테스트는 따로 의미가 없었습니다. 그래서 PR 본문에 이 테스트가 양성 케이스만 가지는 이유를 솔직히 적어 두었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;그 밖의 기여 절차도 차례로 밟았습니다. 버그를 먼저 이슈로 공유하고, 그 이슈 번호를&lt;span&gt;&amp;nbsp;&lt;/span&gt;Closes #이슈번호로 연결한 PR을 올렸습니다. squash merge 정책에 맞춰 제목은 영어 명령형으로, 어떤 클래스에서 무엇을 고치는지가 한 줄에 드러나도록 다듬었습니다. PR 본문에는 형식 검사 도구가 고친 줄의 정렬을 어떻게 바꾸는지, 그리고 모듈과 핵심 모듈의 테스트를 돌려 모두 통과했다는 점을 함께 적었습니다. 변경 자체는 한 글자였지만, 그 한 글자가 왜 맞는지와 그 흔적이 어떻게 남는지를 정직하게 설명하는 일에 더 많은 손이 갔습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;한-글자에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size26&quot;&gt;한 글자에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 것은 글자 하나입니다. 거기에 그 글자가 맞는지 확인하는 테스트 한 덩어리가 붙은 정도입니다. 그런데 그 한 글자에 도달하기까지의 과정에서 얻은 것은 분량과 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 짝지어진 데이터에서는 한쪽이 다른 쪽의 의도를 증언해 준다는 감각입니다. 이번 오타를 확신할 수 있었던 것은 열쇠와 값이 함께 있었기 때문입니다. 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;video/wmv라고 분명히 말하고 있었기에, 열쇠의&lt;span&gt;&amp;nbsp;&lt;/span&gt;mmv가 틀렸다고 단언할 수 있었습니다. 만약 둘 중 하나만 있었다면 이것이 오타인지 의도인지 가리기 어려웠을 겁니다. 짝지어진 두 값이 서로 어긋날 때, 둘 중 무엇이 옳은지를 나머지 형제 항목들과 비교해 가려내는 일이 이런 종류의 버그를 다루는 방법이라는 것을 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 작은 오타가 만드는 결과가 의외로 여러 방향으로 번질 수 있다는 점입니다. 한 글자가 틀렸을 뿐인데, 있어야 할 매핑은 사라지고 없어도 될 죽은 항목은 남았으며, 그 영향은 흔히 쓰는 비디오 형식의 꼬리표가 어긋나는 데까지 이어졌습니다. 사소해 보이는 곳일수록 그것이 닿는 경로를 한 번 따라가 보는 일이 필요하다는 것을 느꼈습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 한 줄을 고칠 때에도 그 흔적을 어떻게 남길지가 중요하다는 점입니다. 형식 검사 도구가 고친 줄의 정렬을 바꾸더라도, 제 변경과 무관한 줄까지 끌어들이지 않는 것이 옳았습니다. 보기에 조금 어긋나는 것을 감수하더라도, diff가 정확히 제가 의도한 곳에만 머물게 하는 편이 검토하는 사람에게 더 정직한 변경이 됩니다. 작은 수정일수록 그 경계를 분명히 지키는 일이 오히려 더 까다로울 수 있다는 것을, 이번에 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;111&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 글자 하나를 고친 것으로 무언가를 다 안다고 말할 수는 없습니다. 다만 표를 천천히 읽고, 형제 항목과 비교해 무엇이 옳은지를 가리고, 영향이 닿는 경로를 확인하고, 변경을 꼭 필요한 자리에만 한정하는 이 과정 자체가, 더 큰 변경을 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 표에서 어긋난 한 글자를 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;115&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5511&quot;&gt;langchain4j/langchain4j#5511&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/529</guid>
      <comments>https://ebson.tistory.com/529#entry529comment</comments>
      <pubDate>Tue, 23 Jun 2026 15:02:17 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - BOM에서 빠진 docling 문서 파서 모듈을 채워 넣은 과정</title>
      <link>https://ebson.tistory.com/528</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 제가 고친 것들은 대부분 코드의 한 줄이 잘못 동작하는 종류였습니다. 값이 좁혀지거나, 인자가 자리를 바꾸거나, 참조가 끊기는 식이었습니다. 그런데 이번에 다룰 LangChain4j 기여는 조금 결이 다릅니다. 잘못 쓰인 코드가 있어서가 아니라, 있어야 할 한 줄이 아예 빠져 있어서 생긴 문제였습니다. 그리고 그 빠진 자리는 자바 소스가 아니라 빌드 설정 파일, 그중에서도 BOM이라고 부르는 곳이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 한 건의 기여를, 무엇이 빠져 있었고 그것을 어떻게 확인했으며 소스가 없는 파일을 어떻게 검증했는지 처음부터 끝까지 따라가며 정리한 기록입니다. 최종 변경은 의존성 항목 하나를 더한 작은 수정이지만, 그 한 항목이 정말 빠진 게 맞는지, 그리고 어떤 버전으로 채워야 하는지를 확신하기까지 거친 확인 과정이 저에게는 더 오래 남았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;여러-모듈의-버전을-한곳에서-맞춰-주는-bom이라는-파일&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;여러 모듈의 버전을 한곳에서 맞춰 주는 BOM이라는 파일&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 BOM이 무엇인지부터 짚어 두겠습니다. LangChain4j처럼 모듈이 수십 개로 나뉜 프로젝트를 쓸 때, 사용자는 보통 그중 몇 개만 골라서 의존성에 추가합니다. 그런데 모듈마다 버전을 일일이 적다 보면, 서로 호환되지 않는 버전을 섞어 쓰는 실수가 생기기 쉽습니다. 이런 번거로움을 덜어 주는 것이 BOM(Bill of Materials)입니다. BOM은 &quot;이 프로젝트의 모듈들은 서로 이런 버전으로 맞춰 쓰세요&quot;라는 목록을 한곳에 모아 둔 특별한 pom 파일입니다. 사용자가 이 BOM 하나만 가져오면, 개별 모듈의 버전을 직접 적지 않아도 BOM이 정해 둔 호환 버전을 받게 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j에서 이 역할을 하는 것이&lt;span&gt;&amp;nbsp;&lt;/span&gt;langchain4j-bom&lt;span&gt;&amp;nbsp;&lt;/span&gt;모듈입니다. 이 모듈에는 자바 코드가 한 줄도 없습니다. 오직&lt;span&gt;&amp;nbsp;&lt;/span&gt;dependencyManagement&lt;span&gt;&amp;nbsp;&lt;/span&gt;블록 안에 프로젝트의 모듈들을 죽 나열해 두고, 각각 어떤 버전으로 관리할지를 적어 둔 것이 전부입니다. 말하자면 코드가 아니라 목록인 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;제가 들여다본 것은 바로 이 목록이었습니다. 코드의 동작을 따라가는 대신, &quot;발행되는 모듈들이 이 목록에 빠짐없이 들어 있는가&quot;를 확인하는 일이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이 목록이 왜 중요한지는 BOM이 빠졌을 때 사용자가 겪는 일을 떠올려 보면 분명해집니다. BOM에 모듈이 등록되어 있으면, 사용자는 의존성에 그 모듈을 적을 때 버전을 생략할 수 있습니다. BOM이 버전을 대신 정해 주기 때문입니다. 그런데 어떤 모듈이 BOM에서 빠져 있으면, 그 모듈만큼은 사용자가 버전을 직접 찾아 적어야 합니다. 다른 모듈들은 버전 없이 깔끔하게 쓰는데 유독 한 모듈만 버전을 손수 적어야 한다면, 사용자는 &quot;이건 왜 다르지&quot; 하고 멈칫하게 됩니다. 게다가 그렇게 직접 적은 버전이 나머지 모듈들과 어긋나기라도 하면, BOM이 막아 주려던 바로 그 호환성 문제가 다시 고개를 듭니다. 빠진 한 줄은 단순히 불편한 정도가 아니라, BOM이 존재하는 이유 자체를 그 모듈에 한해 무력화하는 셈입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;발행되는-모듈과-목록을-나란히-놓고-비교했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;발행되는 모듈과 목록을 나란히 놓고 비교했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;목록에 빠진 항목을 찾으려면 비교할 기준이 필요했습니다. 기준은 프로젝트의 루트&lt;span&gt;&amp;nbsp;&lt;/span&gt;pom.xml에 있는 모듈 목록이었습니다. 여기에는 이 프로젝트가 빌드하는 모든 모듈이 등록되어 있습니다. 그중에서 실제로 사용자에게 배포되는, 즉 jar로 발행되는 모듈들을 추려 보고, 그 목록을 BOM의 항목들과 하나씩 맞춰 보는 방식이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;발행되는 모듈인지 아닌지는 각 모듈의 pom 파일을 보면 알 수 있습니다. 패키징이 jar이고(별도 선언이 없으면 기본값이 jar입니다), 부모로부터&lt;span&gt;&amp;nbsp;&lt;/span&gt;dev.langchain4j라는 그룹을 물려받으며, 배포를 건너뛰라는 설정이 없으면 발행 대상입니다. 반대로 BOM 자신이나 부모 pom처럼 패키징이 pom인 것, 내부 빌드 도구처럼 사용자에게 배포되지 않는 것은 BOM 목록에 들어갈 대상이 아닙니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 추려 BOM과 대조하다 보니 한 모듈이 눈에 걸렸습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;langchain4j-document-parser-docling이라는 문서 파서 모듈이었습니다. 루트 모듈 목록에는 분명히 등록되어 있고, 자체 pom을 보니 패키징도 jar이고 배포를 막는 설정도 없는 정상적인 발행 모듈이었습니다. 그런데 BOM의&lt;span&gt;&amp;nbsp;&lt;/span&gt;dependencyManagement&lt;span&gt;&amp;nbsp;&lt;/span&gt;어디에도 이 모듈이 없었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;특히 마음에 걸린 것은 주변과의 비대칭이었습니다. BOM에는 문서 파서들을 모아 둔 구역이 있는데, 거기에는 pdfbox, poi, tika, markdown, yaml까지 다섯 개의 문서 파서가 나란히 등록되어 있었습니다. 같은 부류의 형제 다섯은 모두 있는데 docling 하나만 빠져 있었던 것입니다. 한두 개가 모두 없다면 의도된 설계일 수도 있겠지만, 같은 그룹에서 다섯은 있고 하나만 없다는 것은 설계가 아니라 누락을 강하게 의심하게 하는 신호였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;일부러-뺀-것이-아니라-빠뜨린-것임을-확인했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size26&quot;&gt;일부러 뺀 것이 아니라 빠뜨린 것임을 확인했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;여기서 멈추지 않고 한 가지를 더 확인했습니다. 형제와의 비대칭만으로는 &quot;원래 없던 것을 누군가 일부러 뺀 것&quot;인지 &quot;넣었어야 하는데 빠뜨린 것&quot;인지가 분명하지 않았기 때문입니다. 둘은 다릅니다. 일부러 제외한 것이라면 거기에는 이유가 있을 테고, 함부로 되돌리면 안 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 모듈 이름이 BOM 파일의 변경 이력에 한 번이라도 등장한 적이 있는지를 살펴봤습니다. 만약 과거에 등록되었다가 어느 시점에 의도적으로 제거된 것이라면, 이력에 그 흔적이 남아 있어야 합니다. 그런데 이력을 뒤져 보니 이 모듈 이름은 BOM 파일에서 단 한 번도 나타난 적이 없었습니다. 추가된 적이 없으니 제거된 적도 없는, 처음부터 들어온 적 없는 항목이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 이 모듈이 프로젝트에 처음 도입된 시점을 확인했습니다. docling 파서를 추가한 변경을 따라가 보니, 그 변경은 모듈을 새로 만들고 루트 모듈 목록에는 등록했지만 BOM 파일은 아예 건드리지 않았습니다. 새 모듈을 추가하면서 BOM에 등재하는 단계를 놓친, 전형적인 동기화 누락이었습니다. 이로써 의심이 확신으로 바뀌었습니다. 이것은 누군가의 설계 판단이 아니라 그냥 빠뜨린 자리였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;사실 비슷하게 빠져 보이는 다른 모듈도 한둘 더 눈에 띄긴 했습니다. 다만 그중 하나는 도입 당시의 변경이 BOM 파일을 직접 손대면서도 그 모듈만 일부러 넣지 않은 흔적이 있어서, 누락이 아니라 의도된 제외일 가능성이 남아 있었습니다. 그래서 이번에는 누락이라고 확실히 말할 수 있는 docling 하나만 다루기로 했습니다. 확신이 서지 않는 항목까지 한 번에 묶어 올리면, 깔끔하게 끝낼 수 있는 변경에 불필요한 논쟁거리를 더하는 셈이라고 생각했기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;형제와-똑같은-형식으로-한-항목만-더했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size26&quot;&gt;형제와 똑같은 형식으로 한 항목만 더했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;고치는 방법은 간단했습니다. 형제 다섯이 이미 따르고 있는 형식 그대로, docling 항목 하나를 문서 파서 구역에 더하면 됐습니다.&lt;/p&gt;
&lt;pre class=&quot;dust&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;51&quot; data-info=&quot;xml {data-source-line=&amp;quot;51&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;dev.langchain4j&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;langchain4j-document-parser-docling&amp;lt;/artifactId&amp;gt;
    &amp;lt;version&amp;gt;${langchain4j.beta.version}&amp;lt;/version&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;59&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;59&quot; data-ke-size=&quot;size16&quot;&gt;여기서 가장 신경 쓴 부분은 버전을 어떻게 적느냐였습니다. 버전을 잘못 적으면 BOM이 엉뚱한 버전을 가리키게 되어, 빠진 것을 채운다면서 오히려 새로운 문제를 만들 수 있습니다. docling 모듈은 자체 버전을 따로 선언하지 않고 부모로부터 베타 버전을 물려받고 있었습니다. 그리고 형제인 markdown, yaml도 모두&lt;span&gt;&amp;nbsp;&lt;/span&gt;${langchain4j.beta.version}이라는 같은 속성을 써서 버전을 지정하고 있었습니다. 이 속성이 가리키는 값과 docling이 실제로 발행되는 버전이 정확히 같다는 것을 확인한 뒤에야, 형제와 동일한 이 속성을 쓰기로 정했습니다. 숫자를 직접 박아 넣는 대신 형제와 같은 속성을 따른 것은, 나중에 버전이 올라갈 때 형제들과 함께 자연스럽게 따라 올라가도록 하기 위해서이기도 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;59&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;61&quot; data-ke-size=&quot;size16&quot;&gt;항목을 넣는 위치도 아무 데나가 아니라 문서 파서 구역의 끝, 그러니까 yaml 바로 뒤에 두었습니다. 같은 부류끼리 모여 있는 정렬을 흐트러뜨리지 않는 것이, 이 파일을 나중에 읽을 사람에게도 자연스럽기 때문입니다. 결과적으로 바뀐 것은 기존 항목을 하나도 건드리지 않은, 순수한 추가뿐이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;소스가-없는-모듈을-검증하는-법&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size26&quot;&gt;소스가 없는 모듈을 검증하는 법&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 고민이 생겼습니다. 이 저장소는 테스트가 없는 변경은 검토하지 않는다는 규칙을 분명히 두고 있습니다. 그런데 BOM은 자바 소스가 한 줄도 없는 목록 파일입니다. 단위 테스트를 붙일 코드 자체가 없습니다. 그렇다면 이 변경이 올바른지는 어떻게 보여 줄 수 있을까요.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size16&quot;&gt;답은 빌드 도구 자체의 검증 기능을 쓰는 것이었습니다. 먼저 BOM 모듈에 대해 Maven의 검증 단계를 돌려 보았습니다. 이 단계에서는 같은 의존성이 중복으로 들어가지는 않았는지 같은 규칙들을 점검하는데, 새로 더한 항목이 이 검사를 무리 없이 통과했습니다. 그다음에는 BOM이 실제로 계산해 내는 최종 결과를 들여다봤습니다. BOM이 적용된 뒤 docling 모듈이 어떤 버전으로 풀리는지를 확인하는 명령을 돌렸더니, 의도한 베타 버전으로 정확히 해석되었습니다. 목록에 한 줄을 넣은 것이 실제로 사용자가 받게 될 버전으로 이어진다는 것을, 빌드 도구의 출력으로 직접 확인한 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;PR 본문에는 이 검증 과정을 그대로 적어 두었습니다. 테스트 항목에는 단위 테스트를 붙일 수 없는 이유를 솔직히 밝히고, 대신 어떤 방법으로 올바름을 확인했는지를 함께 남겼습니다. 검토하는 사람이 &quot;왜 테스트가 없느냐&quot;고 묻기 전에, BOM이라는 파일의 성격과 그에 맞는 검증 방식을 미리 설명해 두고 싶었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;민감한-파일일수록-변경을-작게-두었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size26&quot;&gt;민감한 파일일수록 변경을 작게 두었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;이 변경을 올리면서 한 가지 더 의식한 점이 있습니다. BOM은 프로젝트의 모든 소비자가 의존하는 파일이라, 조심해서 다뤄야 하는 자리입니다. 여기서 기존 항목의 버전을 잘못 바꾸거나 무언가를 지우면, 그 영향이 BOM을 쓰는 모든 사용자에게 번질 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이번 변경은 철저히 순수한 추가에만 머물도록 했습니다. 기존 항목은 단 하나도 손대지 않았고, 형제와 같은 형식과 같은 버전 속성을 그대로 따랐으며, 빠진 한 줄을 채우는 것 외에 다른 정리는 일절 하지 않았습니다. 민감한 파일에서 변경을 최소로 유지하는 것이, 검토하는 사람이 이 변경의 안전성을 한눈에 판단할 수 있게 하는 길이라고 생각했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size16&quot;&gt;기여 절차도 차례로 밟았습니다. 이 저장소의 기여 안내에는 새 모듈을 추가할 때 그 모듈을 BOM의 알맞은 구역에 등록하라는 내용이 분명히 적혀 있습니다. 이번 변경은 그 규칙을 뒤늦게 따르는 셈이었습니다. 또 PR 본문에는 같은 종류의 누락을 과거에 메운 선례, 즉 다른 모듈이 BOM에서 빠져 있던 것을 채운 변경이 이미 머지된 적이 있다는 점을 함께 적었습니다. 이런 종류의 수정이 처음이 아니라 이미 받아들여진 적 있는 패턴임을 보여 주면, 검토하는 사람이 변경의 정당성을 더 빨리 납득할 수 있다고 보았기 때문입니다. squash merge 정책에 맞춰 PR 제목도 영어 명령형으로, 무엇을 어디에 더하는지가 한 줄에 드러나도록 다듬었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;빠진-한-줄에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size26&quot;&gt;빠진 한 줄에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 더해진 것은 의존성 항목 하나입니다. 코드의 동작을 바꾼 것도, 새로운 무언가를 설계한 것도 아닙니다. 그런데 그 한 줄에 도달하기까지의 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 잘못된 코드만 버그가 아니라 빠진 항목도 결함이 될 수 있다는 감각입니다. 동작하는 코드를 한 줄씩 읽는 것만으로는 이런 종류의 문제를 찾기 어렵습니다. 무엇이 있어야 하는지를 알고, 실제로 있는 것과 나란히 놓고 빈자리를 찾아야 비로소 보입니다. 발행되는 모듈의 목록과 BOM의 목록을 맞춰 보는 단순한 대조가, 코드 어디에도 드러나지 않던 누락을 드러내 주었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;85&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 누락을 발견했을 때 그것이 정말 실수인지 의도인지를 가려내는 일의 중요함입니다. 비어 있다고 해서 모두 채워야 하는 것은 아닙니다. 이번에도 변경 이력과 도입 시점을 확인해 &quot;일부러 뺀 것이 아니라 빠뜨린 것&quot;임을 분명히 한 뒤에야 손을 댔고, 확신이 서지 않는 다른 항목은 일부러 남겨 두었습니다. 빈자리를 채우는 일에도 그 자리가 왜 비어 있는지를 먼저 묻는 신중함이 필요하다는 것을 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 테스트를 붙일 수 없는 변경에도 그 나름의 검증 방법이 있다는 것을 알게 되었습니다. 소스가 없는 BOM에는 단위 테스트 대신 빌드 도구의 검사와 최종 결과 확인이 그 역할을 대신했습니다. 변경의 종류가 달라지면 그것을 증명하는 방법도 달라져야 한다는 점을, 이 작은 수정이 알려 주었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 한 줄짜리 추가로 무언가를 다 안다고 말할 수는 없습니다. 다만 무엇이 있어야 하는지를 기준 삼아 빈자리를 찾고, 그 자리가 비어 있는 이유를 확인하고, 민감한 파일일수록 변경을 작게 두며, 코드가 아닌 변경에도 알맞은 검증을 붙이는 이 과정 자체가, 기능 하나를 새로 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 목록에서 빠진 한 줄을 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5505&quot;&gt;langchain4j/langchain4j#5505&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/528</guid>
      <comments>https://ebson.tistory.com/528#entry528comment</comments>
      <pubDate>Tue, 23 Jun 2026 15:00:51 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - Bedrock 도구 인자에서 큰 정수가 손상되던 오버플로 버그를 고치다</title>
      <link>https://ebson.tistory.com/527</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;버그 중에는 시끄럽게 터지는 것이 있고 조용히 번지는 것이 있습니다. 예외를 던지며 멈추는 쪽은 적어도 스택 트레이스라는 단서를 남깁니다. 그런데 아무 오류 없이, 화면도 멀쩡하게, 다만 값 하나가 슬그머니 다른 값으로 바뀌어 흘러가는 종류의 버그는 단서를 거의 남기지 않습니다. 이번 글에서 다룰 LangChain4j의 Bedrock 모듈 수정이 바로 그런 경우였습니다. 평범한 크기의 숫자에는 멀쩡하던 변환 코드가, 큰 정수 하나를 만나면 그 값을 조용히 망가뜨려서 내보내고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 한 건의 기여를, 무엇이 문제였고 그것을 어떻게 확인했으며 어떤 점을 신경 쓰며 고쳤는지 처음부터 끝까지 따라가며 정리한 기록입니다. 최종 변경은 표현식 한 줄을 바꾼 작은 수정이지만, 그 한 줄이 왜 틀렸는지를 납득하기까지 거친 확인 과정이 저에게는 더 오래 남았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;도구-호출-인자가-숫자로-변환되는-길목에서-시작했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;도구 호출 인자가 숫자로 변환되는 길목에서 시작했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j는 여러 LLM 공급자를 같은 방식으로 다룰 수 있게 해 주는데, 그중 Bedrock 모듈은 AWS의 Bedrock 서비스와 주고받는 데이터를 변환하는 계층을 가지고 있습니다. LLM에게 도구(함수)를 쓰게 하면, 모델은 &quot;이 도구를 이런 인자로 호출하라&quot;는 응답을 JSON 형태로 돌려줍니다. 이 JSON 인자를 Bedrock이 이해하는 형식으로 옮겨 담는 일을&lt;span&gt;&amp;nbsp;&lt;/span&gt;AwsDocumentConverter라는 클래스가 맡습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;AWS SDK에는 임의의 구조화된 값을 담는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document라는 타입이 있습니다. 문자열, 불리언, 숫자, 배열, 객체를 두루 담을 수 있는 그릇입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;AwsDocumentConverter는 JSON의 각 노드를 들여다보고 그 종류에 맞는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document로 바꿔 줍니다. 제가 들여다본 곳은 이 변환을 실제로 수행하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;getDocument(JsonNode)&lt;span&gt;&amp;nbsp;&lt;/span&gt;메서드였습니다. JSON 노드 하나가 들어오면 그것이 불리언인지, 실수인지, 정수인지, 배열인지 차례로 판별해 알맞은&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document를 만들어 냅니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이 변환이 특별한 상황에서만 도는 게 아니라는 점이 중요합니다. Bedrock 채팅 모델은 도구 호출 요청을 처리할 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;convertToolRequests()&lt;span&gt;&amp;nbsp;&lt;/span&gt;안에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;documentFromJson(...)을 호출해, 모델이 돌려준 도구 인자 JSON을&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document로 바꿉니다. 그리고 그 안에서 숫자 필드 하나하나가 방금 말한&lt;span&gt;&amp;nbsp;&lt;/span&gt;getDocument()를 거칩니다. 즉 도구 인자에 숫자가 들어 있으면 반드시 이 길을 지나가고, 그렇게 변환된 값이 Bedrock으로 전송됩니다. 평범하고 정상적인 호출 경로라는 뜻입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;정수를-다루는-한-줄에서-폭이-잘려-나가고-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;정수를 다루는 한 줄에서 폭이 잘려 나가고 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;문제의 자리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;getDocument()에서 정수를 처리하는 분기였습니다. 수정 전 코드는 대략 이런 모양이었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;29&quot; data-info=&quot;java {data-source-line=&amp;quot;29&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;} else if (value.isDouble() || value.isFloat() || value.isBigDecimal()) {
    doc = Document.fromNumber(value.asDouble());
} else if (value.isInt() || value.isLong() || value.isShort() || value.isBigInteger()) {
    doc = Document.fromNumber(value.asInt());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;윗줄의 실수 분기를 먼저 보면, 조건에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;double/float/BigDecimal을 받아들이고 변환도&lt;span&gt;&amp;nbsp;&lt;/span&gt;asDouble()로 합니다. 받는 폭과 변환하는 폭이 일치합니다. 그런데 바로 아래 정수 분기를 보면 어긋남이 보입니다. 조건은&lt;span&gt;&amp;nbsp;&lt;/span&gt;isInt()뿐 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;isLong(),&lt;span&gt;&amp;nbsp;&lt;/span&gt;isShort(),&lt;span&gt;&amp;nbsp;&lt;/span&gt;isBigInteger()까지 명시적으로 받아들입니다. &quot;나는 long도, BigInteger도 처리하겠다&quot;고 선언한 셈입니다. 그런데 정작 변환은&lt;span&gt;&amp;nbsp;&lt;/span&gt;value.asInt()로 합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;여기서 Jackson의 동작 하나를 짚어 두면 문제가 분명해집니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;JsonNode.asInt()는 노드가&lt;span&gt;&amp;nbsp;&lt;/span&gt;long이든&lt;span&gt;&amp;nbsp;&lt;/span&gt;BigInteger든 상관없이 그 값을&lt;span&gt;&amp;nbsp;&lt;/span&gt;int로 좁혀서 돌려줍니다. 자바의&lt;span&gt;&amp;nbsp;&lt;/span&gt;int는 약 &amp;plusmn;21억(정확히는 2,147,483,647)까지만 담을 수 있는데, 그 범위를 넘는 값을&lt;span&gt;&amp;nbsp;&lt;/span&gt;int로 좁히면 비트가 잘려 나가며 전혀 다른 값이 됩니다. 예를 들어&lt;span&gt;&amp;nbsp;&lt;/span&gt;Long.MAX_VALUE인 9223372036854775807은 이 좁힘을 거치면&lt;span&gt;&amp;nbsp;&lt;/span&gt;-1이 되고, 30억(3000000000)은 음수인&lt;span&gt;&amp;nbsp;&lt;/span&gt;-1294967296이 됩니다. 오류는 나지 않습니다. 그냥 다른 숫자가 조용히 만들어질 뿐입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;문제가 왜 생기는지를 한 문장으로 정리하면 이렇습니다. 분기 조건은 long과 BigInteger를 받겠다고 해 놓고, 실제 변환은 그것들을 담지 못하는 int로 좁히고 있었습니다. 조건과 구현의 폭이 어긋나 있었던 것입니다. 그래서 도구 인자에 타임스탬프나 큰 식별자처럼 21억을 넘는 정수가 들어오면, 그 값이 손상된 채로 Bedrock에 전달되고 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;같은-클래스의-다른-길들과-비교하니-의도가-분명해졌습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;43&quot; data-ke-size=&quot;size26&quot;&gt;같은 클래스의 다른 길들과 비교하니 의도가 분명해졌습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;코드를 읽다 보면 &quot;이게 정말 버그일까, 아니면 의도된 동작일까&quot; 하는 의심이 듭니다. 그래서 제가 자주 쓰는 방법은 같은 일을 하는 형제 코드와 나란히 놓고 비교하는 것입니다. 이 경우에는 비교 대상이 같은 클래스 안에 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째 비교 대상은 바로 위의 실수 분기입니다. 앞서 봤듯이 실수 쪽은&lt;span&gt;&amp;nbsp;&lt;/span&gt;asDouble()로 폭을 그대로 보존합니다. 같은 메서드 안에서 실수는 폭을 지키는데 정수만 좁힌다면, 정수 분기가 일부러 값을 버리려 했다고 보기는 어렵습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;두 번째 비교 대상은 반대 방향의 변환입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;AwsDocumentConverter에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document를 다시 객체로 되돌리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;documentToObject()도 있는데, 이쪽은 숫자를 꺼낼 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;asNumber()를 써서 SDK가 들고 있던 숫자 폭을 그대로 보존합니다. 즉 되돌아오는 길은 폭을 지키는데 나가는 길만 좁히고 있었습니다. JSON에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document로 갔다가 다시 돌아오는 왕복을 생각하면, 한쪽 방향에서만 값이 손상되는 비대칭이 생깁니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;이 두 비교로 의도가 분명해졌습니다. 코드의 다른 부분은 모두 숫자의 폭을 보존하려 하고 있었고, 분기 조건도 long과 BigInteger를 받겠다고 명시했습니다. 오직 정수 분기의 변환 한 줄만 그 의도를 따라가지 못하고 있었습니다. 무언가 새로운 설계를 들일 문제가 아니라, 어긋난 한 줄을 나머지와 같은 결로 맞추면 되는 문제였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;폭을-보존하는-변환으로-한-줄만-바꿨습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size26&quot;&gt;폭을 보존하는 변환으로 한 줄만 바꿨습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;고치는 방향은 분명했습니다. 정수 분기에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;int로 좁히는 대신, long과 BigInteger를 모두 담을 수 있는 변환을 쓰면 됩니다. 처음에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;SdkNumber라는 타입을 거쳐 원문을 보존하는 방법도 떠올렸지만, 확인해 보니 더 단순한 길이 있었습니다. AWS SDK의&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;BigInteger를 직접 받는&lt;span&gt;&amp;nbsp;&lt;/span&gt;fromNumber&lt;span&gt;&amp;nbsp;&lt;/span&gt;오버로드가 이미 있었습니다. 그래서 별도의 타입을 끌어들이거나 새 의존성을 더할 필요 없이, 노드에서 값을 꺼내는 방식만 바꾸면 됐습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;57&quot; data-info=&quot;java {data-source-line=&amp;quot;57&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;} else if (value.isInt() || value.isLong() || value.isShort() || value.isBigInteger()) {
    doc = Document.fromNumber(value.bigIntegerValue());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;value.asInt()를&lt;span&gt;&amp;nbsp;&lt;/span&gt;value.bigIntegerValue()로 바꾼 것이 전부입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;bigIntegerValue()는 노드의 정수 값을&lt;span&gt;&amp;nbsp;&lt;/span&gt;BigInteger로 꺼내 주므로, long이든 그보다 더 큰 값이든 폭이 잘리지 않습니다. 이렇게 하면 세 가지가 한꺼번에 맞아떨어집니다. 분기 조건이 받겠다고 한 long과 BigInteger가 실제로 손실 없이 변환되고, 바로 위 실수 분기가&lt;span&gt;&amp;nbsp;&lt;/span&gt;asDouble()로 폭을 지키는 것과 같은 결이 되며, 반대 방향&lt;span&gt;&amp;nbsp;&lt;/span&gt;documentToObject()와의 왕복 대칭도 회복됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 분명히 해 둔 점은, 이 변경이 기존 동작을 깨지 않는다는 것입니다. int 범위 안에 들어오는 평범한 값, 예를 들어 42 같은 숫자는&lt;span&gt;&amp;nbsp;&lt;/span&gt;asInt()로 꺼내든&lt;span&gt;&amp;nbsp;&lt;/span&gt;bigIntegerValue()로 꺼내든 결국 같은 숫자입니다. 달라지는 것은 오직 int 범위를 넘던, 그래서 원래 손상되던 경로뿐입니다. 손상되던 길만 정상으로 되돌리고 멀쩡하던 길은 그대로 두는 것이, 변경을 작게 유지하면서 호환을 지키는 길이었습니다. 분기 구조도, 메서드 시그니처도 건드리지 않고 표현식 하나만 고친 이유가 여기에 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;왕복을-검증하는-테스트로-사각지대를-메웠습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size26&quot;&gt;왕복을 검증하는 테스트로 사각지대를 메웠습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;코드를 한 줄 고친 만큼, 같은 비중으로 테스트를 챙겼습니다. 이 저장소는 테스트가 없는 변경은 검토하지 않는다는 규칙을 분명히 두고 있고, 무엇보다 이번 버그가 &quot;정상 입력에 대해 조용히 잘못된 값을 내보내던&quot; 종류라 재발 방지가 중요했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;다행히 Bedrock 모듈의 변환 테스트는 AWS 자격 증명 없이도 돌릴 수 있는 순수 단위 테스트였습니다. 기존에도 큰 숫자를 다루는 테스트가 있긴 했지만, 그것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document에서 JSON으로 나가는 방향만 확인하고 있어서, 정작 이번에 문제가 된 JSON에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;Document로 들어오는 방향의 정수 왕복은 비어 있었습니다. 그래서 그 사각지대를 메우는 테스트를 더했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;73&quot; data-info=&quot;java {data-source-line=&amp;quot;73&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@Test
void documentFromJson_preserves_long_value() {
    // Given - a JSON integer larger than Integer.MAX_VALUE
    String json = &quot;{\&quot;id\&quot;:9223372036854775807}&quot;;

    // When
    Document document = AwsDocumentConverter.documentFromJson(json);

    // Then - the full long value must be preserved (no narrowing to int)
    assertThat(document.asMap().get(&quot;id&quot;).asNumber().longValue()).isEqualTo(Long.MAX_VALUE);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;이 테스트는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Long.MAX_VALUE를 담은 JSON을 변환한 뒤, 그 값이 그대로 보존되는지를 단언합니다. 수정 전 코드라면 이 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;-1로 좁혀져 단언이 곧장 실패하고, 수정 후에는 통과합니다. 같은 방식으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;Long.MAX_VALUE보다 한 단계 더 큰 값, 즉 long 범위마저 넘는&lt;span&gt;&amp;nbsp;&lt;/span&gt;BigInteger가 보존되는지도 확인했습니다. 정수 분기를&lt;span&gt;&amp;nbsp;&lt;/span&gt;BigInteger로 꺼내도록 고쳤으니, long보다 큰 값까지 지켜지는지를 직접 짚어 둔 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 회귀 케이스로, 42처럼 int 범위 안에 있는 평범한 값이 여전히 그대로 변환되는지를 확인하는 테스트를 더했습니다. 이 회귀 테스트가 &quot;기존 동작은 바뀌지 않는다&quot;는 약속을 코드로 증명해 줍니다. 검토하는 사람이 호환성 걱정을 머릿속으로 따져 보지 않아도, 테스트가 그 경계를 대신 지켜 줍니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;형식-검사-도구가-끼워-넣은-변경을-정직하게-밝혔습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size26&quot;&gt;형식 검사 도구가 끼워 넣은 변경을 정직하게 밝혔습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;수정과 테스트를 마치고 나서 한 가지 더 챙긴 부분이 있습니다. 이 저장소는 코드 형식을 자동으로 검사하는 도구(spotless)를 쓰는데, 제가 테스트 파일을 건드리자 그 도구가 같은 파일 안에 이미 있던 텍스트 블록 세 군데의 형식을 저장소 기준에 맞게 다시 정리했습니다. 제가 의도해서 바꾼 줄은 아니지만, 형식 검사를 통과하려면 함께 반영되어야 하는 변경이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;95&quot; data-ke-size=&quot;size16&quot;&gt;이런 부수적인 형식 변경은 본질이 아니므로, PR 본문에 그 사실을 따로 적어 두었습니다. 정작 봐야 할 변경은 어디까지나 정수 분기의 한 줄과 새로 더한 왕복 테스트라는 점을, 검토하는 사람이 헷갈리지 않도록 미리 밝혀 두고 싶었습니다. 변경 목록에 형식 정리가 섞여 들어가면 diff가 실제보다 커 보이고, 무엇이 핵심인지 흐려질 수 있기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;95&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;그 밖의 기여 절차도 차례로 밟았습니다. 이 저장소는 버그를 발견하면 먼저 이슈로 문제를 공유하고, 그다음 그 이슈 번호를&lt;span&gt;&amp;nbsp;&lt;/span&gt;Closes #이슈번호&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태로 연결한 PR을 올리는 흐름을 권합니다. 또 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지로 남습니다. 그래서 제목을 영어 명령형으로, 무엇을 어디서 고쳤는지가 한 줄에 드러나도록 다듬었습니다. 막연히 &quot;버그 수정&quot;이 아니라, 큰 정수 도구 인자에서 일어나던 정수 오버플로를&lt;span&gt;&amp;nbsp;&lt;/span&gt;AwsDocumentConverter에서 고친다는 내용이 제목만 봐도 전해지도록 했습니다. 새 의존성을 들이지 않았는지, 기존 동작을 깨지 않는지, 모듈 단위 테스트가 모두 통과하는지를 확인하는 것까지가 제 몫이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-한-줄에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size26&quot;&gt;작은 한 줄에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 코드는 정말 한 줄입니다. 거기에 왕복을 검증하는 테스트 몇 개가 붙은 정도입니다. 그런데 그 한 줄에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 타입을 좁히는 변환이 얼마나 조용히 값을 망가뜨릴 수 있는지를 몸으로 느낀 경험입니다. 좁힘 캐스팅은 예외를 던지지 않습니다. 그냥 범위를 넘는 비트를 버리고 다른 값을 돌려줄 뿐입니다. 평소에 다루는 숫자가 대부분 작다면 이런 버그는 한참을 들키지 않고 살아남습니다. 그래서 노드에서 값을 꺼낼 때 그 폭이 원래 값을 담기에 충분한지를 한 번 더 확인하는 습관이 필요하다는 것을, 이 버그가 분명히 알려 주었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 분기 조건과 실제 구현이 같은 것을 말하고 있는지 맞춰 보는 일의 중요함입니다. 이번 버그의 핵심은 거창한 알고리즘이 아니라, &quot;long을 받겠다&quot;는 조건과 &quot;int로 좁히겠다&quot;는 변환이 어긋나 있었다는 단순한 불일치였습니다. 조건이 받아들이는 폭과 변환이 보존하는 폭이 같은지를 나란히 놓고 보면, 이런 어긋남은 의외로 눈에 잘 들어옵니다. 그리고 같은 클래스 안의 형제 코드, 반대 방향의 변환과 비교해 보는 것이 그 어긋남을 확신으로 바꿔 주었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 좋은 수정이란 새로운 무언가를 더하는 것이 아니라 이미 있는 일관성으로 되돌리는 것일 때가 많다는 점을 다시 느꼈습니다. 답은 멀리 있지 않았습니다. 같은 메서드의 실수 분기가, 반대 방향의 변환이 이미 폭을 지키고 있었고, 정수 분기를 그 결에 맞추기만 하면 됐습니다. 정답을 새로 발명하기보다 이미 있는 정답을 찾아 맞추는 쪽이, 검토하는 사람에게도 훨씬 설득력 있는 변경이 된다는 것을 이번에도 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 한 줄짜리 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 코드를 천천히 읽고, 형제 코드와 비교하고, 변경을 꼭 필요한 자리에만 한정하고, 같은 실수가 다시 들어오면 곧장 걸리도록 테스트로 못 박아 두는 이 과정 자체가, 기능 하나를 새로 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 코드를 읽다가 폭이 어긋난 변환을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;113&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5503&quot;&gt;langchain4j/langchain4j#5503&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/527</guid>
      <comments>https://ebson.tistory.com/527#entry527comment</comments>
      <pubDate>Tue, 23 Jun 2026 14:59:32 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - Anthropic tool 스키마에서 사라지던 $defs를 되살린 과정</title>
      <link>https://ebson.tistory.com/526</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스에 기여할 만한 지점을 찾는 일은, 새 기능을 떠올리는 것보다 이미 있는 코드에서 어긋난 부분을 알아채는 쪽이 저에게는 더 수월했습니다. 그중에서도 특히 단서가 분명했던 방법이 하나 있습니다. 이미 머지된 버그 수정 하나를 골라서, 그 수정이 손댄 곳과 똑같은 일을 하는 &quot;형제 경로&quot;가 같은 모듈 안에 또 있는지 살펴보는 것입니다. 같은 종류의 실수는 보통 한 군데에만 있지 않습니다. 누군가 한쪽을 고쳤다면, 같은 패턴을 쓰는 다른 쪽은 아직 안 고쳐졌을 가능성이 꽤 높습니다. 이번 글에서 다룰 LangChain4j Anthropic 모듈의 tool 스키마 수정도 바로 그런 식으로, 이미 머지된 다른 수정을 따라가다 발견한 빈자리에서 출발했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 한 건의 기여를, 무엇이 문제였고 그것을 어떻게 확인했으며 어떤 점을 신경 쓰며 고쳤는지 처음부터 끝까지 따라가며 정리한 기록입니다. 최종 변경은 필드 하나와 분기 하나를 더한 작은 수정이지만, 그 작은 변경이 왜 필요한지를 납득하기까지 거쳐야 했던 확인 과정이 저에게는 더 오래 남았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;tool에-넘기는-입력-스키마라는-한-조각에서-시작했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;tool에 넘기는 입력 스키마라는 한 조각에서 시작했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 여러 LLM 공급자를 같은 추상화로 다룰 수 있게 해 주는 라이브러리입니다. 그중 Anthropic(Claude) 모듈은 LangChain4j가 표현하는 요청을 Anthropic API가 이해하는 JSON 형태로 바꿔 주는 변환 계층을 가지고 있습니다. 그 변환을 담당하는 클래스가&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicMapper입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;제가 들여다본 부분은 tool(함수 호출) 정의를 변환하는 길목이었습니다. LLM에게 &quot;이런 도구를 쓸 수 있다&quot;고 알려 줄 때는, 그 도구가 어떤 인자를 받는지를 JSON Schema로 적어 보냅니다. LangChain4j에서는 이 인자 명세를&lt;span&gt;&amp;nbsp;&lt;/span&gt;ToolSpecification이 들고 있고, 그 안의 파라미터는&lt;span&gt;&amp;nbsp;&lt;/span&gt;JsonObjectSchema로 표현됩니다. Anthropic으로 보낼 때는 이걸&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicToolSchema라는 객체로 옮겨 담아&lt;span&gt;&amp;nbsp;&lt;/span&gt;input_schema&lt;span&gt;&amp;nbsp;&lt;/span&gt;자리에 넣습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;여기서 잠깐 JSON Schema의 작은 문법 하나를 짚어 두면 이후 이야기가 분명해집니다. 스키마가 복잡해지면, 같은 모양의 객체를 여러 군데에서 반복해 쓰는 일이 생깁니다. 이럴 때 JSON Schema는 공통 정의를 한곳에 모아 두고 그걸 가리키는 방식을 제공합니다. 공통 정의는 최상위의&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs라는 블록에 이름을 붙여 모아 두고, 실제로 쓰는 자리에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;{&quot;$ref&quot;: &quot;#/$defs/이름&quot;}&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태로 그 정의를 참조합니다. 중첩되거나 재귀적인 구조, 예를 들어 트리처럼 자기 자신을 다시 품는 모델을 표현할 때 이 참조 방식이 특히 쓸모가 있습니다. 핵심은 둘이 한 쌍이라는 점입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref로 무언가를 가리키면, 그 가리키는 대상이&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs&lt;span&gt;&amp;nbsp;&lt;/span&gt;안에 반드시 함께 있어야 합니다. 가리키기만 하고 대상이 빠지면, 받는 쪽은 어디로도 닿지 못하는 참조를 손에 쥐게 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;그런데 LangChain4j가 만들어 Anthropic으로 보내는 tool 입력 스키마를 따라가 보니, 바로 이 한 쌍이 끊어져 있었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref는 보내면서 정작 그 대상이 담긴&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs&lt;span&gt;&amp;nbsp;&lt;/span&gt;블록은 빠진 채로 나가고 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;참조는-보내면서-정의는-빼고-보냈습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;참조는 보내면서 정의는 빼고 보냈습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;문제의 자리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicMapper.toAnthropicTool()이 입력 스키마를 조립하는 부분이었습니다. 수정 전 코드는 대략 이런 모양이었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;31&quot; data-info=&quot;java {data-source-line=&amp;quot;31&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;.inputSchema(AnthropicToolSchema.builder()
        .properties(parameters != null ? toMap(parameters.properties(), strict) : emptyMap())
        .required(parameters != null ? parameters.required() : emptyList())
        .additionalProperties(strict ? Boolean.FALSE : null)
        .build());
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;이 코드는 파라미터 스키마에서 속성 목록(properties)과 필수 항목(required), 그리고 추가 속성 허용 여부(additionalProperties)만 옮겨 담습니다. 어디에도&lt;span&gt;&amp;nbsp;&lt;/span&gt;parameters.definitions(), 그러니까 최상위&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs에 해당하는 정의 묶음을 읽는 부분이 없습니다. 그래서 파라미터 안에&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref를 쓰는 속성이 하나라도 있으면, 그&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref는 그대로 직렬화되어 나가지만 그것이 가리키는 정의는 함께 실리지 못합니다. 받는 입장에서는 가리키는 곳이 비어 있는 참조가 도착하는 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 더 확인이 필요했습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;properties를 옮길 때 호출하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;toMap(parameters.properties(), strict)가 혹시 그 안에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs까지 챙겨 주지는 않을까 하는 점이었습니다. core 모듈의 변환 유틸리티를 따라가 보니, 이 호출은 속성 맵을 인자로 받는 오버로드였고 속성 하나하나를 변환할 뿐 최상위&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs는 전혀 내보내지 않았습니다. 같은 이름의 다른 오버로드, 즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;JsonObjectSchema&lt;span&gt;&amp;nbsp;&lt;/span&gt;전체를 통째로 받는 쪽은&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs를 내보내 주지만, tool 경로는 그 오버로드를 쓰지 않고 속성 맵만 떼어 넘기고 있었습니다. 결국 어느 단계에서도 tool의&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs를 실어 줄 곳이 없었던 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;같은-결함을-이미-한-번-고친-흔적이-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;43&quot; data-ke-size=&quot;size26&quot;&gt;같은 결함을 이미 한 번 고친 흔적이 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;이 지점에서 제가 형제 경로를 찾는 방법이 효과를 봤습니다. Anthropic 모듈에는 tool 스키마 말고도 비슷한 변환이 하나 더 있습니다. 구조화 출력(structured output)을 위해 응답 스키마를 만들어 보내는 경로로,&lt;span&gt;&amp;nbsp;&lt;/span&gt;toAnthropicSchema()가 담당합니다. 이쪽을 열어 보니 흥미로운 부분이 있었습니다. 정의가 비어 있지 않으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs를 채워 넣는 처리가 이미 들어가 있었고, 그 처리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;mapDefs()라는 작은 헬퍼를 호출하고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;이력을 따라가 보니 이 처리는 그냥 처음부터 있던 게 아니라, 한 번 빠졌다가 다시 채워진 것이었습니다. 앞서 머지된 한 PR이 바로 이 구조화 출력 경로에&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs&lt;span&gt;&amp;nbsp;&lt;/span&gt;직렬화를 되살려 넣은 변경이었습니다. 다만 그 수정은&lt;span&gt;&amp;nbsp;&lt;/span&gt;toAnthropicSchema&lt;span&gt;&amp;nbsp;&lt;/span&gt;쪽만 손봤고, 같은 결함을 똑같이 안고 있던 tool 경로(toAnthropicTool)는 건드리지 않았습니다. 한 모듈 안에서 거의 같은 일을 하는 두 길 중 한쪽만 고쳐진, 전형적인 형제 경로 누락이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;방향을 한 번 더 확실히 하기 위해 다른 공급자와도 비교해 봤습니다. OpenAI 매퍼는 tool 파라미터를 변환할 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;JsonObjectSchema&lt;span&gt;&amp;nbsp;&lt;/span&gt;전체를 그대로 넘기는 방식이라, 같은 입력을 줘도&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs가 보존됩니다. 즉 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref&lt;span&gt;&amp;nbsp;&lt;/span&gt;기반 스키마를 쓰더라도 OpenAI tool은 정상적으로 동작하고 Anthropic tool만 끊어진 참조를 보내고 있었습니다. 형제 경로 비교에서 이미 확신이 섰지만, 다른 공급자가 멀쩡히 보존하는 정보를 한쪽만 흘리고 있다는 사실이 수정의 정당성을 한 번 더 받쳐 주었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;빠진-자리를-메우되-그-이상은-건드리지-않았습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;51&quot; data-ke-size=&quot;size26&quot;&gt;빠진 자리를 메우되, 그 이상은 건드리지 않았습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;고치는 방향은 분명했습니다. 이미 구조화 출력 경로가 쓰고 있는 방식을 tool 경로에도 똑같이 적용하면 되는 것이었습니다. 새 설계를 들일 이유가 전혀 없었고, 오히려 두 경로를 같은 모양으로 맞추는 쪽이 검토하는 사람에게도 설명하기 쉬운 변경이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;먼저&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicToolSchema에 정의를 담을 자리가 필요했습니다. 이 클래스에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs에 해당하는 필드 자체가 없었기 때문입니다. 그래서 정의 묶음을 담을 필드 하나와 그에 맞는 빌더 메서드를 더했습니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;57&quot; data-info=&quot;java {data-source-line=&amp;quot;57&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@JsonProperty(&quot;$defs&quot;)
public Map&amp;lt;String, Map&amp;lt;String, Object&amp;gt;&amp;gt; defs;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;이 한 줄의 어노테이션이 생각보다 중요했습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicToolSchema는 클래스 차원에서 스네이크 케이스 이름 전략(@JsonNaming(SnakeCaseStrategy.class))을 쓰도록 되어 있습니다. 만약 필드 이름만&lt;span&gt;&amp;nbsp;&lt;/span&gt;defs로 두고 별도 표시를 하지 않으면, 직렬화될 때 이름이 그대로&lt;span&gt;&amp;nbsp;&lt;/span&gt;defs로 나갑니다. 그런데 JSON Schema가 약속한 키는&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;defs로 나가면 Anthropic은 그 블록을 정의 묶음으로 알아보지 못하고, 결국&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref는 다시 갈 곳을 잃습니다. 그래서 직렬화 이름을 정확히&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs로 고정하려고&lt;span&gt;&amp;nbsp;&lt;/span&gt;@JsonProperty(&quot;$defs&quot;)를 붙였습니다. 같은 클래스가 쓰는 이름 전략과 충돌하지 않도록 한 작은 장치였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;64&quot; data-ke-size=&quot;size16&quot;&gt;그다음 매퍼 쪽에서 이 필드를 채웠습니다. 파라미터에 정의가 들어 있을 때에만, 구조화 출력 경로가 이미 쓰던&lt;span&gt;&amp;nbsp;&lt;/span&gt;mapDefs()&lt;span&gt;&amp;nbsp;&lt;/span&gt;헬퍼를 그대로 재사용해 정의를 옮겨 담도록 했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;66&quot; data-info=&quot;java {data-source-line=&amp;quot;66&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;AnthropicToolSchema.Builder inputSchemaBuilder = AnthropicToolSchema.builder()
        .properties(parameters != null ? toMap(parameters.properties(), strict) : emptyMap())
        .required(parameters != null ? parameters.required() : emptyList())
        .additionalProperties(strict ? Boolean.FALSE : null);
if (parameters != null &amp;amp;&amp;amp; !parameters.definitions().isEmpty()) {
    inputSchemaBuilder.defs(mapDefs(parameters.definitions()));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;여기서 신경 쓴 부분이 두 가지 있습니다. 하나는 새 변환 로직을 직접 짜지 않고 이미 검증되어 머지된&lt;span&gt;&amp;nbsp;&lt;/span&gt;mapDefs()를 그대로 가져다 썼다는 점입니다. 같은 일을 하는 코드를 두 벌 두는 대신, 형제 경로가 쓰던 도구를 빌려 오는 쪽이 변경을 작게 유지하고 두 경로의 동작을 한곳에 묶어 두는 길이었습니다. 다른 하나는 정의가 비어 있지 않을 때에만 필드를 채우도록 분기를 둔 점입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;AnthropicToolSchema는 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;null인 필드를 직렬화에서 빼도록(@JsonInclude(NON_NULL)) 설정되어 있어서, 정의가 없는 평범한 tool은 이전과 글자 하나 다르지 않은 JSON을 만들어 냅니다. 즉 이 변경은&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref를 쓰는 스키마에만 영향을 주고, 기존 동작은 그대로 둡니다. 기여 규칙에서 가장 강조하는 &quot;기존 동작을 깨지 않는다&quot;는 조건을 지키려면 이 경계가 분명해야 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;AnthropicToolSchema에는 이전 버전과의 호환을 위해 남겨 둔 예전 생성자도 있었는데, 그쪽은 일부러 손대지 않았습니다. 필요한 것은 필드와 빌더, 그리고 그것을 비교&amp;middot;표현에 반영하는 정도였고, 기존에 잘 동작하던 부분까지 따라 고칠 이유는 없었기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;테스트가-직렬화-키까지-확인하도록-했습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;80&quot; data-ke-size=&quot;size26&quot;&gt;테스트가 직렬화 키까지 확인하도록 했습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;82&quot; data-ke-size=&quot;size16&quot;&gt;코드를 고친 만큼, 같은 비중으로 테스트를 챙겼습니다. 이 저장소는 테스트가 없는 변경은 아예 검토하지 않는다는 규칙을 분명히 두고 있고, 무엇보다 이번 버그 자체가 &quot;정상 입력에 대해 잘못된 결과를 내보내던&quot; 종류라 재발 방지가 중요했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;82&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;84&quot; data-ke-size=&quot;size16&quot;&gt;다행히 Anthropic 모듈에는 API 키 없이도 돌릴 수 있는 순수 단위 테스트가 이미 있었습니다. 매퍼 변환을 직접 검증하는 테스트들이라, 여기에 두 가지 경우를 더했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;86&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;86&quot; data-ke-size=&quot;size16&quot;&gt;하나는 정의가 있는 경우입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;$ref로 다른 정의를 가리키는 속성을 가진&lt;span&gt;&amp;nbsp;&lt;/span&gt;ToolSpecification을 만들어 변환한 뒤, 결과 입력 스키마에&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs가 실제로 들어 있고 가리키던 이름을 담고 있는지를 단언했습니다. 그리고 한 걸음 더 들어가, 변환 결과를 실제 JSON 문자열로 직렬화했을 때 키가 정확히&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;$defs&quot;로 나오는지, 혹시라도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;defs&quot;로 새어 나가지는 않는지까지 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;88&quot; data-info=&quot;java {data-source-line=&amp;quot;88&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;AnthropicTool anthropicTool = toAnthropicTool(toolSpecification, AnthropicCacheType.NO_CACHE, Set.of(), null);

assertThat(anthropicTool.inputSchema.defs).isNotNull();
assertThat(anthropicTool.inputSchema.defs).containsKey(reference);

String json = new ObjectMapper().writeValueAsString(anthropicTool.inputSchema);
assertThat(json).contains(&quot;\&quot;$defs\&quot;&quot;);
assertThat(json).doesNotContain(&quot;\&quot;defs\&quot;&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;이 마지막 두 줄이 앞서 어노테이션으로 막아 둔 함정을 그대로 지키는 그물입니다. 만약 누군가 나중에&lt;span&gt;&amp;nbsp;&lt;/span&gt;@JsonProperty(&quot;$defs&quot;)를 무심코 지운다면, 직렬화 키가&lt;span&gt;&amp;nbsp;&lt;/span&gt;defs로 바뀌면서 이 단언이 곧장 실패합니다. 필드가 채워졌는지만 보는 것이 아니라, 바깥으로 나가는 JSON의 키 이름까지 못 박아 둔 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;다른 하나는 정의가 없는 평범한 경우입니다. 정의가 없는 tool을 변환했을 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;defs가 비어 있고, 직렬화된 JSON에&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs라는 글자가 아예 나타나지 않는지를 단언했습니다. 이 음성 케이스가 &quot;기존 동작은 그대로&quot;라는 약속을 코드로 증명해 줍니다. 검토하는 사람이 호환성 걱정을 굳이 머릿속으로 따져 보지 않아도, 테스트가 대신 그 경계를 지켜 줍니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;고치는-일보다-절차를-지키는-일에-더-마음을-썼습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size26&quot;&gt;고치는 일보다 절차를 지키는 일에 더 마음을 썼습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;수정 자체는 작았지만, 이 저장소에 변경을 올리는 절차를 제대로 밟는 데에는 그만큼의 주의가 들었습니다. LangChain4j는 버그를 발견하면 곧장 PR을 올리는 대신, 먼저 이슈로 문제를 공유하고 그다음 그 이슈 번호를 연결한 PR을 올리는 흐름을 권합니다. PR 본문 맨 위에는 어떤 이슈를 닫는 변경인지&lt;span&gt;&amp;nbsp;&lt;/span&gt;Closes #이슈번호&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태로 적습니다. 또 이 저장소는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지로 남습니다. 그래서 제목을 영어 명령형으로, 무엇을 어디서 고쳤는지가 한 줄에 드러나도록 다듬었습니다. 막연히 &quot;버그 수정&quot;이 아니라, 스키마 참조를 쓰는 tool의 입력 스키마에&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs를 포함시킨다는 내용이 제목만 봐도 전달되도록 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;PR 본문에서 한 가지 의식적으로 챙긴 부분은, 이 변경이 앞서 구조화 출력 경로를 고친 그 PR의 빠진 형제 경로를 마저 채우는 작업이라는 점을 분명히 밝힌 것입니다. 변경만 떼어 놓고 보면 필드 하나 추가에 불과하지만, &quot;왜 지금 이걸 고치는가&quot;의 맥락은 그 앞선 수정과 이어 놓아야 온전히 전달됩니다. 검토하는 사람이 같은 결함의 절반이 이미 머지되었다는 사실을 알면, 이 변경의 필요성을 코드보다 먼저 이해할 수 있다고 생각했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;변경이 기존 동작을 깨지 않는지, 새 의존성을 들이지 않는지, Java 17 호환을 유지하는지 같은 기본 조건들도 차례로 확인했습니다. 새로 더한 필드는 값이 없으면 직렬화되지 않으니 동작 호환이 유지되고, 변환 로직은 이미 있던 헬퍼를 재사용했으니 새 의존성도 없습니다. 모듈 단위 테스트를 돌려 초록불을 확인하는 것까지가 제 몫이었고, 실제 API를 호출하는 통합 테스트는 키가 필요해 그 자리에서는 돌리지 않았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-필드-하나에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;111&quot; data-ke-size=&quot;size26&quot;&gt;작은 필드 하나에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;113&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 더해진 코드의 양은 정말 적습니다. 필드 하나와 빌더 하나, 그리고 정의가 있을 때만 그 필드를 채우는 분기 하나가 본질의 전부입니다. 그런데 그 작은 변경에 도달하기까지의 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;113&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;115&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 이미 머지된 수정을 단서로 같은 결함의 다른 자리를 찾는 방법이 실제로 통한다는 경험입니다. 한 번 발생한 종류의 실수는 비슷한 일을 하는 다른 경로에도 같은 모양으로 숨어 있곤 합니다. 구조화 출력 경로가 고쳐졌다는 사실 하나가, 거의 같은 일을 하는 tool 경로를 의심할 충분한 이유가 되어 주었습니다. 무언가를 처음부터 발명하기보다, 저장소가 이미 내린 결정을 따라가며 그 결정이 빠뜨린 자리를 메우는 쪽이, 검토하는 사람에게도 훨씬 받아들이기 쉬운 변경이 된다는 것을 다시 한 번 느꼈습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;115&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 직렬화처럼 눈에 잘 안 띄는 세부가 결과를 좌우할 수 있다는 점입니다. 필드를 추가하는 것만으로는 부족했고, 그 필드가 바깥으로 나갈 때 정확히&lt;span&gt;&amp;nbsp;&lt;/span&gt;$defs라는 이름을 달도록 못 박는 한 줄이 있어야 비로소 버그가 닫혔습니다. 객체 안에서 값이 맞게 채워졌는지와, 그 값이 약속된 형식으로 직렬화되어 나가는지는 다른 문제였습니다. 그래서 테스트도 필드 값만이 아니라 최종 JSON의 키 이름까지 확인하도록 두었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;119&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 작은 수정 한 건으로 무언가를 다 안다고 말할 수는 없습니다. 다만 이미 고쳐진 수정에서 단서를 얻고, 형제 경로와 다른 공급자의 동작을 나란히 놓고 비교하며, 변경을 꼭 필요한 자리에만 한정하고, 같은 실수가 다시 들어오면 곧장 걸리도록 테스트로 못 박아 두는 이 과정 자체가, 기능 하나를 새로 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 머지된 수정을 따라가다 빈자리를 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;123&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5499&quot;&gt;langchain4j/langchain4j#5499&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/526</guid>
      <comments>https://ebson.tistory.com/526#entry526comment</comments>
      <pubDate>Tue, 23 Jun 2026 14:57:37 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 필수 입력 누락이 null로 새던 버그를 잡은 LangChain4j MCP 기여 후기</title>
      <link>https://ebson.tistory.com/525</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스 코드를 읽다 보면, 어떤 클래스 하나만 주변과 미묘하게 다르게 동작하는 자리를 만날 때가 있습니다. 문법적으로 틀린 것도 아니고 테스트가 빨갛게 깨지는 것도 아닙니다. 다만 같은 일을 하는 형제 코드들과 약속이 어긋나 있을 뿐입니다. 이번에 LangChain4j에 올린 수정도 그런 자리에서 시작했습니다. 고친 줄은 세 줄이지만, 그 세 줄을 확신을 가지고 적기까지가 이 기여의 전부였다고 느낍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 자바에서 대규모 언어 모델을 다루기 위한 라이브러리입니다. 그중&lt;span&gt;&amp;nbsp;&lt;/span&gt;langchain4j-agentic-mcp&lt;span&gt;&amp;nbsp;&lt;/span&gt;모듈은 MCP(Model Context Protocol)로 노출된 외부 도구를 에이전트처럼 호출할 수 있게 해 줍니다. 다른 에이전트들과 한 워크플로우 안에 섞여, 앞선 단계가 만들어 둔 값을 입력으로 받아 도구를 부르고 결과를 다음 단계로 넘기는 식입니다. 제가 손을 댄 부분은 그 입력을 모아 도구 호출 인자로 넘기는 길목이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;형제-코드와-어긋난-한-곳&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;형제 코드와 어긋난 한 곳&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;문제의 메서드는&lt;span&gt;&amp;nbsp;&lt;/span&gt;McpClientAgentInvoker.agentInvocationArguments였습니다. 타입이 정해진(typed) MCP 에이전트가 워크플로우에서 호출될 때, 선언된 입력 키들을 하나씩 돌면서 현재 상태(AgenticScope)에서 값을 읽어 인자 배열에 담는 짧은 메서드입니다. 당시 코드는 이런 모양이었습니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;21&quot; data-info=&quot;java {data-source-line=&amp;quot;21&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;int i = 0;
for (String argName : inputKeys) {
    Object argValue = agenticScope.readState(argName);
    positionalArgs[i++] = argValue;
    namedArgs.put(argName, argValue);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;겉보기에는 평범합니다. 입력 키마다 값을 읽어 자리에 넣을 뿐입니다. 그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;readState가 돌려준 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이면 어떻게 되는지 따라가 보니 생각이 달라졌습니다. 값이 없어도 아무 검사 없이 그대로&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 인자 배열에 담기고, 그&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 MCP 도구 호출로 그대로 넘어갑니다. 즉 필수 입력이 빠져 있어도 누락이라고 알려 주는 대신, 도구는&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 정상 인자처럼 받습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;이게 왜 문제인지는 같은 프레임워크의 다른 구현들과 비교했을 때 분명해졌습니다. 표준 untyped 구현인&lt;span&gt;&amp;nbsp;&lt;/span&gt;UntypedAgentInvoker는 입력을 돌면서 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이면 곧장&lt;span&gt;&amp;nbsp;&lt;/span&gt;MissingArgumentException을 던집니다. 또 다른 표준 경로인&lt;span&gt;&amp;nbsp;&lt;/span&gt;AgentUtil의 인자 처리도, 값이 없고 기본값도 없고 선택 입력도 아니라면 같은 예외를 던집니다. 비정상이면 어디서 무엇이 빠졌는지 분명한 예외로 즉시 멈춘다는 태도가 프레임워크 전반에 깔려 있었습니다. 그런데 유독 MCP의 typed 경로만 그 태도에서 벗어나&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 조용히 흘려보내고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;이 어긋남이 실제로 사람을 괴롭히는 순간은 여러 에이전트를 한 워크플로우에 엮을 때입니다. 워크플로우 안에서 각 단계는 앞 단계가 상태에 써 둔 값을 입력으로 받습니다. 그러다 누군가 출력 키 이름을 한 글자 다르게 적거나 한 단계를 빠뜨리면, 그 입력은 상태에 존재하지 않게 됩니다. 이때 일반 에이전트라면 그 자리에서 곧장 멈추며 &quot;이 입력이 없다&quot;고 알려 줍니다. 그런데 같은 워크플로우에 섞인 MCP 에이전트만은 멈추지 않고&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 도구로 넘긴 뒤, 도구 안쪽에서 한참 뒤에 엉뚱한 모양으로 실패합니다. 같은 실수인데 에이전트 종류에 따라 실패 시점도, 오류 메시지도 달라지니, 막상 원인을 추적할 때 가장 헷갈리는 부류가 됩니다. 표면적으로는 한 인자의&lt;span&gt;&amp;nbsp;&lt;/span&gt;null&lt;span&gt;&amp;nbsp;&lt;/span&gt;검사 누락이지만, 그 여파는 워크플로우 전체의 디버깅 일관성으로 번지는 셈입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;이게-계약-위반이라는-근거&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;36&quot; data-ke-size=&quot;size26&quot;&gt;이게 계약 위반이라는 근거&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;코드 스타일이 조금 다른 것과 약속을 어긴 것은 다릅니다. 그래서 이게 단순한 취향 차이가 아니라 분명한 계약 위반인지부터 확인하고 싶었습니다. 근거는 인터페이스 시그니처 자체에 있었습니다. 이 모듈들이 공통으로 구현하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;AgentInvoker&lt;span&gt;&amp;nbsp;&lt;/span&gt;인터페이스의&lt;span&gt;&amp;nbsp;&lt;/span&gt;toInvocationArguments&lt;span&gt;&amp;nbsp;&lt;/span&gt;메서드는 시그니처에&lt;span&gt;&amp;nbsp;&lt;/span&gt;throws MissingArgumentException을 선언하고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;자바에서 메서드 시그니처에 검사 예외(checked exception)를 선언한다는 것은, 그 메서드를 구현하거나 호출하는 쪽에게 &quot;이런 상황에서는 이 예외가 날 수 있다&quot;를 코드로 약속하는 일입니다. 그러니까 &quot;필수 입력이 빠지면&lt;span&gt;&amp;nbsp;&lt;/span&gt;MissingArgumentException을 던진다&quot;는 것은 이 프레임워크가 시그니처로 못 박아 둔 약속이었습니다. 표준 구현 두 곳은 그 약속을 지키고 있었고, MCP의 typed 구현만 약속해 놓고 이행하지 않고 있었습니다. 버그라기보다 구현이 빠진 자리에 가까웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;이 인터페이스를 구현하는 다른 인보커들도 시그니처에 같은 예외 선언을 그대로 달고 있었습니다. 그러니 &quot;필수 입력이 빠지면 예외를 던진다&quot;는 약속은 어느 한 구현의 사정이 아니라 인터페이스 차원에서 모든 구현에 똑같이 걸린 공통 계약이었습니다. 표준 경로들은 그 줄을 지켰고, MCP의 typed 경로만 홀로 그 줄에서 빠져 있었던 셈입니다. 그래서 이 수정을 두고 &quot;동작을 바꾼다&quot;기보다 &quot;이미 모두가 따르던 약속에 한 곳을 마저 합류시킨다&quot;는 쪽으로 마음이 기울었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 정리하고 나니, 고쳐야 할 동작이 무엇인지가 코드로 확정되었습니다. 제가 새로운 동작을 발명할 필요가 없었습니다. 형제 구현이 이미 하고 있는 그대로를, 빠진 한 곳에 채워 넣으면 되는 일이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;고친-모습&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size26&quot;&gt;고친 모습&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;실제 수정은 값을 읽은 직후&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 가려내는 가드 하나를 더한 것입니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;50&quot; data-info=&quot;java {data-source-line=&amp;quot;50&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;for (String argName : inputKeys) {
    Object argValue = agenticScope.readState(argName);
    if (argValue == null) {
        throw new MissingArgumentException(argName);
    }
    positionalArgs[i++] = argValue;
    namedArgs.put(argName, argValue);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;61&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;61&quot; data-ke-size=&quot;size16&quot;&gt;예외에는 어떤 입력이 빠졌는지를 담았습니다. 이제 워크플로우 앞단이 어떤 출력 키를 빠뜨려서 MCP 에이전트가 필요한 입력을 못 받으면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 도구까지 흘러가 뒤늦게 모호하게 실패하는 대신, 어느 인자가 없는지 분명히 적힌&lt;span&gt;&amp;nbsp;&lt;/span&gt;MissingArgumentException이 그 자리에서 납니다. 같은 워크플로우에 섞인 다른 에이전트와 똑같은 시점에, 똑같은 방식으로 멈추게 된 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;값이 있는 정상 경로는 한 글자도 바뀌지 않았습니다. 입력이 제대로 들어오는 평범한 호출은 예전과 똑같이 동작합니다. 그래서 이 변경은 동작을 깨지 않는, 빠져 있던 검증을 채우는 수정입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;왜-일괄-검사가-안전한지-그리고-어디까지만-고쳤는지&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size26&quot;&gt;왜 일괄 검사가 안전한지, 그리고 어디까지만 고쳤는지&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 조심스러운 질문이 남습니다. 모든&lt;span&gt;&amp;nbsp;&lt;/span&gt;null&lt;span&gt;&amp;nbsp;&lt;/span&gt;입력을 다 예외로 막아 버리면, 원래&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 허용되던 선택 입력까지 막아서 멀쩡하던 동작을 깨는 건 아닐까 하는 점입니다. 기여를 할 때 이런 의심을 건너뛰면, 버그 하나 고치면서 다른 동작을 망가뜨리기 쉽습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;typed 경로에 한해서는 이 일괄 검사가 안전하다는 것을 코드로 확인할 수 있었습니다. typed MCP 에이전트의 입력 인자는&lt;span&gt;&amp;nbsp;&lt;/span&gt;AgentArgument로 표현되는데, 이 모듈에서는 그것이 항상 기본값 없이(defaultValue=null), 선택이 아닌(isOptional=false) 형태로만 만들어집니다. 즉 표준 구현이 거치는 &quot;값이 없으면 기본값을 보고, 그래도 없으면 선택 입력인지 보고, 그것도 아니면 예외&quot; 라는 3단계 검사가, typed MCP에서는 처음부터 마지막 단계 하나로 줄어듭니다. 그러니&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 곧장 예외로 막는 것이 곧 표준 동작과 같습니다. 과하게 엄격해지는 것이 아니라, 표준이 typed MCP에서 자연스럽게 도달하는 결론을 그대로 적은 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;반면 타입이 정해지지 않은(untyped) 경로는 일부러 손대지 않았습니다. untyped 경로의 입력 키는 도구의 JSON 스키마에 선언된 모든 속성에서 나오는데, 거기에는 필수가 아닌 선택 파라미터도 섞여 있습니다. 이쪽에 똑같이 일괄 검사를 넣으면, 정당하게 비어 있을 수 있는 선택 파라미터에서 예외가 나 정상 동작이 깨집니다. 그래서 이번 변경은 typed 경로 하나로만 범위를 좁혔습니다. untyped 경로의 같은 결함은 필요하다면 선택 여부를 스키마에서 받아 따로 다루는 게 맞다고 보고, 별도 후속 작업으로 미뤄 두었습니다. 한 PR에는 한 가지 주제만 담는 편이, 검토하는 쪽에도 나중에 이 코드를 볼 사람에게도 친절하다고 느낍니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;테스트로-못-박기&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size26&quot;&gt;테스트로 못 박기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 테스트 없는 변경은 리뷰하지 않는다는 원칙을 분명히 합니다. 정상 케이스와 비정상 케이스를 모두 덮어야 합니다. 그래서 고친 동작을 검증하는 단위 테스트가 필요했는데, 여기에 약간의 고민이 있었습니다. 수정한 typed 경로를 실제로 타게 하려면, typed MCP 에이전트를 워크플로우에 넣고 필수 입력이 빠진 채 호출해야 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size16&quot;&gt;다행히 이 모듈의 기존 테스트가 길을 알려 주었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;McpClient를 목(mock)으로 만들면 실제 API 키나 외부 서버 없이도 에이전트를 구성할 수 있었습니다. 그래서 번역 도구를 흉내 낸 목 클라이언트로 typed 에이전트를 만들고, 그것을&lt;span&gt;&amp;nbsp;&lt;/span&gt;sequenceBuilder&lt;span&gt;&amp;nbsp;&lt;/span&gt;워크플로우에 넣은 다음, 필수 입력인&lt;span&gt;&amp;nbsp;&lt;/span&gt;language를 일부러 빼고 호출하는 테스트를 더했습니다. 수정 전 코드에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;language=null이 도구로 흘러가 버리지만, 수정 후에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;language가 빠졌다는 메시지를 담은&lt;span&gt;&amp;nbsp;&lt;/span&gt;MissingArgumentException이 납니다. 테스트는 예외가 났다는 사실에서 그치지 않고, 그 메시지에 빠진 입력 이름인&lt;span&gt;&amp;nbsp;&lt;/span&gt;language가 들어 있는지까지 확인하도록 적었습니다. 어느 인자가 비어 있는지가 예외에 정확히 담겨야 디버깅하는 사람에게 쓸모가 있는데, 이 수정이 노린 효과가 바로 거기에 있었기 때문입니다. 버그를 먼저 재현하는 테스트를 적고, 그 테스트가 통과하도록 고친 셈입니다. 기존 테스트들이 정상 입력 케이스를 이미 덮고 있어서, 정상과 비정상이 함께 검증되도록 맞췄습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;포맷이-데려온-군더더기&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size26&quot;&gt;포맷이 데려온 군더더기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;마무리하면서 예상하지 못한 일이 하나 있었습니다. 제가 건드린 두 파일은 저장소가 palantir 코드 포맷을 도입하기 전에 만들어진 오래된 파일이었습니다. CI가 쓰는 spotless 포맷 검사는&lt;span&gt;&amp;nbsp;&lt;/span&gt;ratchetFrom=origin/main&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정 때문에, 한 번 손댄 파일을 새 포맷 기준으로 다시 정렬합니다. 그래서 제가 의미 있게 바꾼 곳은 가드 하나와 import 한 줄뿐인데, diff에는 메서드 체인을 다시 줄바꿈하고 import 순서를 바꾸는 변경이 함께 섞여 들어왔습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 군더더기 같은 변경을 어떻게 설명해야 하나 망설였습니다. &quot;한 줄 고쳤다&quot;고 적어 놓고 diff에는 포맷 변경이 들어 있으면 검토하는 사람이 의아할 테니까요. 결국 PR 본문에 그대로 적었습니다. 이 파일들이 palantir 포맷 이전 것이라 손대는 순간 spotless가 일부 줄을 재포맷했고, 이는 CI가 요구하는 사항이라는 설명입니다. 일부러 인접 코드를 다듬은 것이 아니라 도구의 규칙 때문에 생긴 변경임을 분명히 해 두었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;절차에-대해-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;85&quot; data-ke-size=&quot;size26&quot;&gt;절차에 대해 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;이번 기여도 LangChain4j가 권하는 절차를 차분히 밟아 본 경험이었습니다. 버그 수정도 이슈를 먼저 등록하고, 그 이슈를&lt;span&gt;&amp;nbsp;&lt;/span&gt;Closes #번호로 연결한 Draft PR을 올린 다음, 승인을 받고 나서 문서나 예제를 더하는 흐름입니다. 그래서 먼저 이슈에 문제를 정리했습니다. 어떤 경로에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 조용히 새는지, 그것이 시그니처의&lt;span&gt;&amp;nbsp;&lt;/span&gt;throws MissingArgumentException&lt;span&gt;&amp;nbsp;&lt;/span&gt;선언과 어떻게 어긋나는지, 형제 구현 두 곳은 어떻게 처리하는지를 적었습니다. 표준 구현의 정확한 위치를 함께 짚어 두니, &quot;이게 정말 버그냐 의도된 동작이냐&quot;는 논의를 미리 줄일 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;변경 자체는 작게 유지했습니다. 가드 하나가 핵심이고, 리팩터링이나 다른 개선을 끼워 넣지 않았습니다. 새 의존성도 추가하지 않았습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;MissingArgumentException은 이 모듈이 이미 의존하는 같은 프레임워크 안의 타입이라 import 한 줄로 충분했습니다. LangChain4j는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치 커밋 메시지가 됩니다. 그래서 제목도 &quot;Throw MissingArgumentException for missing required input in McpClientAgentInvoker&quot;처럼 무엇을 했는지가 한눈에 들어오는 영어 명령형으로 적었습니다. 이 변경은 결국 머지되었습니다(&lt;a href=&quot;https://github.com/langchain4j/langchain4j/pull/5477&quot;&gt;https://github.com/langchain4j/langchain4j/pull/5477&lt;/a&gt;).&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;남은-생각&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size26&quot;&gt;남은 생각&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;돌이켜 보면, 이번 일에서 코드를 고친 시간보다 &quot;고쳐도 되는 곳인지&quot;를 확인한 시간이 훨씬 길었습니다. 형제 구현이 어떻게 동작하는지 읽고, 인터페이스 시그니처가 무엇을 약속하는지 확인하고, typed와 untyped 경로가 입력 키를 어디서 얻는지 따라가고 나서야 비로소 가드 한 줄을 자신 있게 적을 수 있었습니다. 그리고 그 확인 과정에서, 어디까지 고치고 어디는 남겨 두어야 하는지의 경계도 함께 그어졌습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;95&quot; data-ke-size=&quot;size16&quot;&gt;좋은 기여가 반드시 큰 변경일 필요는 없다는 것을 이번에도 다시 느꼈습니다. 빠져 있던 검증 한 줄을 채우고, 그 동작을 테스트로 못 박고, 일괄 검사가 안전한 범위만 골라 손대고, 위험한 범위는 정직하게 미뤄 둔다. 코드량으로 보면 작은 변경이지만, 이제 이 라이브러리로 MCP 에이전트를 엮는 누군가는 입력을 빠뜨렸을 때 정체불명의&lt;span&gt;&amp;nbsp;&lt;/span&gt;null&lt;span&gt;&amp;nbsp;&lt;/span&gt;대신 어느 인자가 없는지 적힌 예외를 곧바로 받습니다. 디버깅하는 사람에게 그 차이는 결코 작지 않습니다. 다음에 또 형제 코드와 어긋난 자리를 만난다면, 이번처럼 시그니처가 약속한 계약과 표준 구현을 근거로 삼아, 경계를 분명히 긋고 한 걸음씩 확인해 가며 고쳐 보려 합니다.&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/525</guid>
      <comments>https://ebson.tistory.com/525#entry525comment</comments>
      <pubDate>Tue, 23 Jun 2026 14:55:55 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - A2A 클라이언트의 조용한 실패(silent failure)를 잡아낸 기여 후기</title>
      <link>https://ebson.tistory.com/524</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스에 기여할 때 가장 어려운 부분은 코드를 고치는 일이 아니라, 고칠 만한 지점을 찾는 일이라고 느낍니다. 큰 기능을 새로 넣는 일은 메인테이너의 설계 방향과 부딪히기 쉽고, 사소한 오타 수정은 도움은 되지만 깊이 배우기는 어렵습니다. 그 사이 어딘가, 코드를 한참 들여다봐야 비로소 보이는 작은 논리 오류가 기여하기 좋은 자리라고 생각합니다. 이번 글에서 다루는 LangChain4j의 A2A 클라이언트 수정도 그런 자리에서 출발했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 자바에서 대규모 언어 모델을 다루기 위한 라이브러리입니다. 그중&lt;span&gt;&amp;nbsp;&lt;/span&gt;langchain4j-agentic-a2a&lt;span&gt;&amp;nbsp;&lt;/span&gt;모듈은 A2A(agent-to-agent) 프로토콜을 통해 원격에 있는 다른 에이전트를 마치 로컬 객체처럼 호출할 수 있게 해 줍니다. 원격 에이전트에게 작업을 보내고, 그쪽에서 처리한 결과를 받아 오는 통신 계층입니다. 제가 손을 댄 부분은 그 결과를 받아서 호출자에게 돌려주는 마지막 단계였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;처음-의심이-든-지점&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;처음 의심이 든 지점&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;코드를 읽다가 눈에 걸린 메서드는&lt;span&gt;&amp;nbsp;&lt;/span&gt;DefaultA2AClientBuilder.completeFromTask였습니다. 원격에서 돌아온&lt;span&gt;&amp;nbsp;&lt;/span&gt;Task&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체를 보고, 그 안에서 텍스트를 뽑아&lt;span&gt;&amp;nbsp;&lt;/span&gt;CompletableFuture를 완료시키는 짧은 메서드입니다. 당시 코드는 대략 이런 모양이었습니다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;21&quot; data-info=&quot;java {data-source-line=&amp;quot;21&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;private static void completeFromTask(Task task, CompletableFuture&amp;lt;String&amp;gt; messageResponse) {
    if (!isTerminalState(task.status().state()) &amp;amp;&amp;amp; task.artifacts().isEmpty()) {
        return;
    }
    messageResponse.complete(extractTextFromParts(
            task.artifacts().stream().flatMap(a -&amp;gt; a.parts().stream()).toList()));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;처음 읽었을 때는 평범해 보였습니다. 아직 끝나지 않았고 결과물(artifacts)도 없으면 그냥 돌아가서 다음 이벤트를 기다리고, 그렇지 않으면 결과물에서 텍스트를 모아 future를 완료한다. 흐름 자체는 자연스럽습니다. 그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;isTerminalState가 어떤 상태를 terminal로 보는지 따라가 보니 생각이 달라졌습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;pf&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;33&quot; data-info=&quot;java {data-source-line=&amp;quot;33&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;private static boolean isTerminalState(TaskState state) {
    return state == TaskState.TASK_STATE_COMPLETED
            || state == TaskState.TASK_STATE_FAILED
            || state == TaskState.TASK_STATE_CANCELED
            || state == TaskState.TASK_STATE_REJECTED;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;t&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;erminal 상태에는 정상 완료(COMPLETED)뿐 아니라 실패(FAILED), 취소(CANCELED), 거부(REJECTED)도 포함되어 있었습니다. 다시 가드 조건으로 돌아가 보면, early-return은&lt;span&gt;&amp;nbsp;&lt;/span&gt;!isTerminalState(state) &amp;amp;&amp;amp; artifacts.isEmpty()일 때만 동작합니다. 즉 상태가 terminal이기만 하면, 그게 실패든 취소든 상관없이 가드를 그냥 통과합니다. 그러고 나서 실패한 작업에는 보통 결과물이 없으니,&lt;span&gt;&amp;nbsp;&lt;/span&gt;extractTextFromParts는 빈 리스트에서 빈 문자열을 만들어 내고, future는&lt;span&gt;&amp;nbsp;&lt;/span&gt;complete(&quot;&quot;)로 정상 완료됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. 원격 에이전트가 작업에 실패해서&lt;span&gt;&amp;nbsp;&lt;/span&gt;FAILED&lt;span&gt;&amp;nbsp;&lt;/span&gt;상태로 응답을 돌려보내도, 그 호출을 한 쪽은 예외도 받지 못하고 빈 문자열 하나를 정상 결과처럼 받습니다. 실패가 빈 성공으로 둔갑하는 셈입니다. 이런 종류의 버그를 흔히 silent failure라고 부르는데, 에러가 어디에서도 드러나지 않고 조용히 삼켜지기 때문에 정작 디버깅할 때 가장 골치 아픈 부류입니다. 호출한 쪽에서는 왜 결과가 비었는지 단서조차 잡을 수 없습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;이게-정말-의도된-동작인지부터-확인하기&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size26&quot;&gt;이게 정말 의도된 동작인지부터 확인하기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;여기서 곧장 코드를 고치고 싶은 마음이 들었지만, 먼저 멈춰서 확인할 것이 있었습니다. 빈 결과를 허용하는 동작이 혹시 누군가 의도해서 만든 것은 아닐까 하는 점입니다. 기여를 하다 보면, 버그처럼 보이는 코드가 사실은 과거에 어떤 이유로 그렇게 만들어진 경우가 적지 않습니다. 그런 코드를 확인 없이 되돌리면, 정작 막아 둔 다른 문제를 다시 열어 버립니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;저장소 이력을 따라가 보니 이슈 #3867이 보였습니다. &quot;A2A agent task artifacts should not be mandatory&quot;라는 제목의 이슈로, 결과물이 비어 있거나 null일 때 발생하던 문제를 다룬 기록이었습니다. 그 이슈의 의도는 명확했습니다. 정상적으로 완료(COMPLETED)된 작업이라면 결과물이 비어 있어도 예외 없이 받아들여야 한다는 것입니다. 결과물이 비었다는 이유만으로 죽어서는 안 된다는 취지였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;이 대목이 중요했습니다. #3867이 보호하려는 동작은 &quot;성공했는데 결과물이 빈 경우&quot;였고, 제가 문제 삼는 동작은 &quot;실패했는데 빈 결과로 끝나는 경우&quot;였습니다. 둘은 겉보기에 비슷하지만 전혀 다른 상황입니다. 그래서 제 수정의 범위를 처음부터 분명히 그었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;COMPLETED&lt;span&gt;&amp;nbsp;&lt;/span&gt;경로는 손대지 않고, 오직&lt;span&gt;&amp;nbsp;&lt;/span&gt;FAILED,&lt;span&gt;&amp;nbsp;&lt;/span&gt;CANCELED,&lt;span&gt;&amp;nbsp;&lt;/span&gt;REJECTED라는 실패 terminal 상태만 다르게 다루기로 했습니다. 이렇게 경계를 그어 두면, #3867이 의도한 관용은 그대로 살아 있으면서 silent failure만 걷어 낼 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;같은-클래스가-이미-알려-주던-정답&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;54&quot; data-ke-size=&quot;size26&quot;&gt;같은 클래스가 이미 알려 주던 정답&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;수정 방향을 잡을 때 가장 든든했던 근거는 같은 클래스 안에 이미 있었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;DefaultA2AClientBuilder는 비정상적인 상황을 만나면 대부분 future를 예외로 완료시킵니다. 스트리밍 도중 에러가 나면&lt;span&gt;&amp;nbsp;&lt;/span&gt;completeExceptionally(error)를 부르고, 예상치 못한 이벤트 타입이 오면 예외를 던지며, 응답을 받아 오다 실패하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;RuntimeException을 던집니다. 비정상이면 예외로 알린다는 일관된 태도가 클래스 전반에 깔려 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;그런데 유독 실패한 terminal 작업만 그 패턴에서 벗어나 조용히 빈 문자열로 끝나고 있었습니다. 그러니 제 수정은 새로운 규칙을 만드는 일이 아니라, 빠져 있던 한 경로를 나머지와 맞춰 주는 일에 가까웠습니다. 기여를 검토하는 입장에서도 이런 종류의 변경은 받아들이기 편합니다. 취향의 문제가 아니라, 같은 클래스 안에서 이미 합의된 방식을 따르는 것이기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;고친-모습&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;60&quot; data-ke-size=&quot;size26&quot;&gt;고친 모습&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;실제 수정은 가드 다음에 실패 상태를 가려내는 분기 하나를 더하고, 그 분기에서 future를 예외로 완료시키는 것이었습니다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;64&quot; data-info=&quot;java {data-source-line=&amp;quot;64&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;static void completeFromTask(Task task, CompletableFuture&amp;lt;String&amp;gt; messageResponse) {
    TaskState state = task.status().state();
    if (!isTerminalState(state) &amp;amp;&amp;amp; task.artifacts().isEmpty()) {
        return;
    }
    if (isFailureState(state)) {
        Message statusMessage = task.status().message();
        String reason = statusMessage != null ? extractTextFromParts(statusMessage.parts()) : &quot;&quot;;
        messageResponse.completeExceptionally(new RuntimeException(&quot;A2A task &quot; + task.id()
                + &quot; ended in terminal state &quot; + state + (reason.isEmpty() ? &quot;&quot; : &quot;: &quot; + reason)));
        return;
    }
    messageResponse.complete(extractTextFromParts(
            task.artifacts().stream().flatMap(a -&amp;gt; a.parts().stream()).toList()));
}

private static boolean isFailureState(TaskState state) {
    return state == TaskState.TASK_STATE_FAILED
            || state == TaskState.TASK_STATE_CANCELED
            || state == TaskState.TASK_STATE_REJECTED;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;예외 메시지에는 작업 id와 어떤 terminal 상태로 끝났는지를 담았고, 가능하면 실패 사유까지 붙였습니다. A2A 프로토콜의&lt;span&gt;&amp;nbsp;&lt;/span&gt;Task는 상태와 함께&lt;span&gt;&amp;nbsp;&lt;/span&gt;status().message()로 실패 사유 메시지를 전달할 수 있는데, 기존 코드는 이 정보를 그냥 버리고 있었습니다. 다만 이 메시지는 없을 수도 있어서, null인지 먼저 확인한 다음에만 텍스트를 뽑도록 했습니다. 사유가 있으면 &quot;이러이러해서 실패했다&quot;까지 알려 주고, 없으면 적어도 어떤 작업이 어떤 상태로 끝났는지는 알려 줍니다. 호출한 쪽에서 디버깅할 때 손에 쥘 단서가 빈손에서 두 가지로 늘어난 셈입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;90&quot; data-ke-size=&quot;size16&quot;&gt;COMPLETED&lt;span&gt;&amp;nbsp;&lt;/span&gt;경로는 한 글자도 바꾸지 않았습니다. 정상 완료된 작업은 결과물이 비어 있어도 예전 그대로 빈 문자열로 완료됩니다. #3867이 지키려던 동작이 그대로 유지된다는 뜻입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;테스트를-위해-가시성을-한-칸만-연-이유&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;92&quot; data-ke-size=&quot;size26&quot;&gt;테스트를 위해 가시성을 한 칸만 연 이유&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 &quot;테스트 없는 변경은 리뷰하지 않는다(no tests, no review)&quot;는 원칙을 분명히 합니다. 그것도 정상 케이스와 비정상 케이스를 모두 덮어야 합니다. 그래서 고친 동작을 검증하는 단위 테스트가 반드시 필요했는데, 여기서 한 가지 고민이 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;96&quot; data-ke-size=&quot;size16&quot;&gt;completeFromTask는 원래&lt;span&gt;&amp;nbsp;&lt;/span&gt;private이었고, 이 메서드까지 가 닿으려면 실제 원격 서버에 연결해 메시지를 주고받아야 했습니다. 그렇게 하면 테스트가 외부 서버에 의존하게 되어, 서버 상태에 따라 통과했다 실패했다 하는 불안정한 테스트가 됩니다. 실패 상태를 결정적으로 재현하기도 어렵습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;96&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;다행히 A2A SDK가 제공하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Task,&lt;span&gt;&amp;nbsp;&lt;/span&gt;TaskStatus,&lt;span&gt;&amp;nbsp;&lt;/span&gt;TaskState&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 타입은 모두 공개된 빌더로 직접 만들 수 있었습니다. 그래서 네트워크 없이&lt;span&gt;&amp;nbsp;&lt;/span&gt;FAILED&lt;span&gt;&amp;nbsp;&lt;/span&gt;상태의&lt;span&gt;&amp;nbsp;&lt;/span&gt;Task를 손으로 조립해&lt;span&gt;&amp;nbsp;&lt;/span&gt;completeFromTask에 바로 넘기면, future가 예외로 완료되는지를 결정적으로 확인할 수 있었습니다. 이렇게 테스트하려면 메서드에 접근할 수 있어야 했고, 그래서&lt;span&gt;&amp;nbsp;&lt;/span&gt;private을 같은 패키지에서만 보이는 package-private&lt;span&gt;&amp;nbsp;&lt;/span&gt;static으로 한 칸만 넓혔습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;이 부분은 솔직하게 짚고 넘어가야 할 지점이라고 봤습니다. 테스트를 위해 가시성을 넓히는 것은 그 자체로 마냥 깔끔한 선택은 아닙니다. 프로덕션 코드가 요구하지 않는 변경이기 때문입니다. 그래서 PR 본문에 이 변경이 결정적 단위 테스트를 위한 것이며 공개 API를 바꾸는 것은 아니라는 점을 분명히 적었습니다. 숨기기보다 드러내고 설명하는 편이 검토하는 사람에게도, 나중에 이 코드를 볼 사람에게도 정직하다고 생각했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;102&quot; data-ke-size=&quot;size16&quot;&gt;테스트는 네 가지 경우를 덮었습니다. 사유가 있는 실패 작업, 사유가 없는 취소 작업, 텍스트 결과물이 있는 정상 완료 작업, 그리고 결과물이 빈 정상 완료 작업입니다. 앞의 둘은 future가 예외로 끝나면서 메시지에 작업 id와 상태, 사유가 담기는지를 확인하고, 뒤의 둘은 예전처럼 정상 완료되는지를 확인합니다. 고친 동작과 보존한 동작을 함께 못 박아 두는 셈입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;포맷이-데려온-뜻밖의-변경&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;104&quot; data-ke-size=&quot;size26&quot;&gt;포맷이 데려온 뜻밖의 변경&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;작업을 마무리하면서 예상하지 못한 일이 하나 있었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;DefaultA2AClientBuilder.java는 저장소가 palantir 코드 포맷을 도입하기 전에 만들어진 오래된 파일이었습니다. LangChain4j의 CI는 spotless라는 포맷 검사를 쓰는데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;ratchetFrom=origin/main&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정 때문에 한 번 건드린 파일은 전체가 새 포맷 기준으로 다시 정렬됩니다. 그래서 제가 바꾼 곳은 메서드 하나뿐인데도, import 순서가 바뀌고 줄바꿈이 다시 잡히는 변경이 diff에 함께 섞여 들어왔습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 군더더기 같은 변경을 어떻게 설명해야 하나 고민했습니다. &quot;한 메서드만 고쳤다&quot;고 적어 놓고 diff에는 포맷 변경이 잔뜩 들어 있으면 검토하는 사람이 의아할 테니까요. 결국 이것도 PR 본문에 그대로 적었습니다. 이 파일이 palantir 포맷 이전 것이라 touch하는 순간 spotless가 전체를 재포맷했고, 이는 CI가 요구하는 사항이라는 설명입니다. 일부러 인접 코드를 손댄 것이 아니라 도구의 규칙 때문에 생긴 변경임을 분명히 해 둔 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;절차에-대해-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;110&quot; data-ke-size=&quot;size26&quot;&gt;절차에 대해 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;이번 기여는 LangChain4j가 요구하는 절차를 한 번 차분히 밟아 본 경험이기도 했습니다. 이 저장소는 버그 수정도 이슈를 먼저 등록하고, 그 이슈를&lt;span&gt;&amp;nbsp;&lt;/span&gt;Closes #번호로 연결한 Draft PR을 올린 다음, 승인을 받고 나서야 문서나 예제를 추가하는 흐름을 권합니다. 그래서 먼저 이슈에 문제를 정리했습니다. 어떤 상태에서 silent failure가 나는지, 그것이 #3867과 어떻게 다른지, 기대 동작은 무엇인지를 적었습니다. #3867과의 구분을 이슈 단계에서 명확히 해 둔 덕분에, 나중에 PR에서 &quot;이건 이미 의도된 동작 아니냐&quot;는 오해를 미리 줄일 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;114&quot; data-ke-size=&quot;size16&quot;&gt;변경 자체는 작게 유지하려 애썼습니다. 분기 하나와 헬퍼 메서드 하나가 핵심이고, 리팩터링이나 다른 개선을 끼워 넣지 않았습니다. 새 의존성도 추가하지 않았습니다. 테스트에 쓴 JUnit과 AssertJ는 테스트 범위에서만 쓰는 것이라 별도 의존성 추가가 필요 없었습니다. LangChain4j는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지가 됩니다. 그래서 제목도 &quot;fix: Surface failed A2A tasks as exceptions instead of empty results&quot;처럼 변경 종류를 앞에 붙이고 뒤는 영어 명령형으로, 무엇을 했는지가 한눈에 들어오도록 적었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;114&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;116&quot; data-ke-size=&quot;size16&quot;&gt;이 변경은 결국 머지되었습니다(&lt;a href=&quot;https://github.com/langchain4j/langchain4j/pull/5471&quot;&gt;https://github.com/langchain4j/langchain4j/pull/5471&lt;/a&gt;). 돌이켜 보면 코드를 고친 시간보다 &quot;이게 정말 버그인지, 의도된 동작은 아닌지&quot;를 확인하는 데 더 많은 시간을 들였습니다. 그리고 그 시간이 가장 쓸모 있었다고 느낍니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;남은-생각&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;118&quot; data-ke-size=&quot;size26&quot;&gt;남은 생각&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;120&quot; data-ke-size=&quot;size16&quot;&gt;이번 일을 통해 다시 확인한 것은, 좋은 기여가 반드시 화려한 변경일 필요는 없다는 점입니다. 실패를 빈 성공으로 둔갑시키던 분기 하나를 바로잡고, 그 동작을 테스트로 못 박고, 보존해야 할 동작은 건드리지 않는다. 코드량으로 보면 작은 변경이지만, 이 라이브러리를 쓰는 누군가는 이제 원격 작업이 실패했을 때 빈 문자열 대신 작업 id와 실패 사유가 담긴 예외를 받습니다. 디버깅하는 사람에게는 그 차이가 결코 작지 않습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;120&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;122&quot; data-ke-size=&quot;size16&quot;&gt;기여를 시작하기 전에는 오픈소스에 무언가를 보탠다는 일이 막연하게 거창하게 느껴졌습니다. 막상 해 보니, 코드를 천천히 읽으면서 &quot;여기 이상한데?&quot; 싶은 지점을 붙잡고, 그게 정말 문제인지 공식 기록과 코드로 확인하고, 고친 뒤에는 그 변경이 다른 동작을 깨지 않는지 테스트로 증명하는 일의 반복이었습니다. 한 번에 하나씩, 확인할 수 있는 만큼만 손대는 태도가 결국 가장 멀리 가더라는 것을 이번 기여에서 배웠습니다. 다음에 또 코드를 읽다가 조용히 삼켜지는 실패를 만난다면, 이번처럼 차분히 경계를 긋고 한 걸음씩 확인해 가며 고쳐 보려 합니다.&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/524</guid>
      <comments>https://ebson.tistory.com/524#entry524comment</comments>
      <pubDate>Tue, 23 Jun 2026 14:54:26 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 문서 예제에 남아 있던 사라진 애너테이션을 걷어내다</title>
      <link>https://ebson.tistory.com/523</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;문서는 코드보다 오래 사는 것처럼 보이지만, 사실 코드만큼이나 빨리 낡습니다. 코드가 바뀌면 그에 딸린 설명과 예제도 함께 바뀌어야 하는데, 사람의 손이 닿는 일이다 보니 한두 군데가 빠지기 쉽습니다. 그렇게 빠진 자리는 한동안 아무 표시도 내지 않습니다. 문서는 컴파일되지 않으니, 그 안의 예제가 더 이상 동작하지 않게 되어도 빌드가 깨지며 알려 주지 않습니다. 누군가 그 예제를 믿고 그대로 따라 쓰기 전까지는 조용히 남아 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룰 LangChain4j의 문서 수정은 바로 그런 종류의 문제였습니다. 한 애너테이션이 라이브러리에서 제거되었는데, 그것을 사용하는 예제가 여러 문서에 그대로 남아 있었습니다. 그 예제를 복사해 붙이면 더 이상 컴파일되지 않는 상태였습니다. 결과만 보면 문서 안의 예제 표기를 바꾼 작은 변경이지만, 사라진 표기가 어디어디에 남아 있는지를 빠짐없이 찾는 과정과 문서 전용 수정이 기여에서 어떤 위치를 갖는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;애너테이션으로-에이전트를-엮는-선언형-방식&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;애너테이션으로 에이전트를 엮는 선언형 방식&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 맥락에 있는지부터 짚어 두겠습니다. LangChain4j에는 여러 동작 단위를 엮어 하나의 흐름을 구성하는 기능이 있습니다. 그리고 그 흐름을 코드로 일일이 조립하는 대신, 인터페이스의 메서드에 애너테이션을 붙여 선언적으로 나타내는 방식을 제공합니다. 예를 들어 &quot;이 메서드는 여러 단위를 순서대로 실행한다&quot;거나 &quot;이 단위들을 동시에 실행한다&quot;는 것을, 메서드 위에 붙인 애너테이션과 그 안에 나열한 하위 단위 목록으로 표현하는 식입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;이런 선언형 애너테이션들은 각자 클래스 설명 안에 사용 예제를 담고 있습니다. 이 기능을 처음 쓰는 사람이 설명을 읽고 곧바로 따라 할 수 있도록, 실제로 어떻게 작성하는지를 보여 주는 예제 코드를 함께 적어 두는 것입니다. 문서 안에 들어가는 예제이지만, 사용자가 그대로 복사해 출발점으로 삼는다는 점에서 이 예제들은 단순한 장식이 아니라 사실상 하나의 약속에 가깝습니다. 보여 준 대로 쓰면 동작해야 한다는 약속입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;문제는 이 약속을 떠받치던 표기 하나가 어느 시점에 사라졌다는 데 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;하위-단위를-적는-방식이-바뀌었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;하위 단위를 적는 방식이 바뀌었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;이전에 어떤 변경이 있었습니다. 하위 단위들을 나열할 때 쓰던 별도의 애너테이션이 제거되고, 더 간결한 방식으로 바뀐 것입니다. 예전에는 하위 단위 하나하나를 별도의 애너테이션으로 감싸 나열했습니다. 지금은 그냥 클래스 목록만 적으면 됩니다. 같은 내용을 적는 두 방식을 나란히 놓으면 차이가 분명합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;29&quot; data-info=&quot;java {data-source-line=&amp;quot;29&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;// 예전 방식 (지금은 사라진 표기)
@SequenceAgent(outputKey = &quot;story&quot;, subAgents = {
        @SubAgent(type = CreativeWriter.class, outputKey = &quot;story&quot;),
        @SubAgent(type = AudienceEditor.class, outputKey = &quot;story&quot;) })

// 지금 방식 (배열 형태)
@SequenceAgent(outputKey = &quot;story&quot;,
        subAgents = { CreativeWriter.class, AudienceEditor.class })
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;예전 방식에서 하위 단위를 감싸던 그 애너테이션은 라이브러리에서 완전히 제거되었습니다. 더 이상 존재하지 않는 표기가 된 것입니다. 그 변경을 한 사람은 주요 애너테이션들의 예제를 새 방식으로 바꿔 두었습니다. 다만 그때 모든 예제가 함께 바뀌지는 못했습니다. 같은 패키지 안의 여러 보조 애너테이션들이, 자기 설명 속 예제에서 여전히 사라진 옛 표기를 쓰고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;이것이 문제의 핵심이었습니다. 사라진 표기를 쓰는 예제는, 그것을 복사해 자기 코드에 붙이는 순간 컴파일되지 않습니다. 존재하지 않는 애너테이션을 참조하기 때문입니다. 문서는 컴파일 대상이 아니라서 빌드가 이를 잡아내지 못하고, 그래서 이 어긋남은 조용히 남아 있었습니다. 라이브러리를 처음 익히려는 사람이 하필 이 예제들 중 하나를 출발점으로 삼으면, 설명을 정확히 따랐는데도 코드가 컴파일되지 않는 당황스러운 상황을 만나게 되는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;빠짐없이-찾는-일이-먼저였습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size26&quot;&gt;빠짐없이 찾는 일이 먼저였습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;이런 종류의 문제는 한 군데를 고치는 것보다, 같은 문제가 어디어디에 흩어져 있는지를 빠짐없이 찾는 일이 먼저입니다. 한두 개만 고치고 나머지를 남겨 두면, 다음 사람이 또 같은 함정에 빠지기 때문입니다. 그래서 사라진 표기가 들어 있는 파일을 패키지 전체에서 검색했습니다. 단순한 텍스트 검색만으로도 충분했습니다. 그 사라진 표기 문자열을 포함한 파일을 모두 찾으니 아홉 개가 나왔습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;확인을 위해 한 가지를 더 살폈습니다. 그 표기가 정말로 제거되었는지, 즉 라이브러리 어딘가에 아직 남아 있는 것은 아닌지를 변경 이력에서 직접 확인한 것입니다. 그 표기를 정의하던 파일이 과거의 한 변경에서 삭제되었고, 저장소 어디에도 그 정의가 남아 있지 않다는 것을 확인했습니다. 예제 속 표기가 단순히 모양만 옛것인 게 아니라, 실제로 존재하지 않는 것을 가리키는 죽은 참조라는 점이 분명해졌습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;찾아낸 아홉 개의 파일은 저마다 다른 보조 애너테이션이었지만, 안고 있는 문제는 똑같았습니다. 설명 속 예제가 사라진 옛 표기로 하위 단위를 나열하고 있었습니다. 고칠 방향은 이미 주요 애너테이션들이 보여 주고 있었습니다. 그것들이 새 방식으로 바뀐 예제를 가지고 있었으니, 아홉 개의 예제도 그와 똑같은 형태로 맞추면 되는 것이었습니다. 무엇으로 바꿔야 하는지를 같은 저장소 안의 이미 고쳐진 예제로 확인할 수 있어, 새로 판단할 여지가 거의 없었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;왜 이런 빠짐이 생기는지도 짚어 둘 만합니다. 한 가지 기능을 바꿀 때, 그 기능을 언급하는 자리가 코드 곳곳에 흩어져 있으면 사람의 눈은 그중 일부를 놓치기 쉽습니다. 특히 이번처럼 같은 사용 예제가 여러 애너테이션의 설명에 비슷하게 반복되어 들어가 있는 경우, 주요한 몇 곳을 고치고 나면 나머지가 다 처리된 것 같은 착각이 들기 쉽습니다. 그런데 그 나머지는 컴파일되지 않는 문서 안에 있어, 빌드도 테스트도 그것이 남아 있다고 알려 주지 못합니다. 결국 사람이 의식적으로 검색해 훑지 않으면 드러나지 않습니다. 그래서 이런 종류의 정리는 &quot;내 기억으로 어디어디였더라&quot;가 아니라, 사라진 표기 그 자체를 검색어로 삼아 기계적으로 훑는 편이 안전합니다. 기억은 빠뜨리지만 검색은 빠뜨리지 않기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;54&quot; data-ke-size=&quot;size16&quot;&gt;또 하나 마음에 걸렸던 것은, 이 예제들이 단순한 참고용이 아니라 이 기능을 처음 익히는 사람이 가장 먼저 마주치는 출발점이라는 점이었습니다. 새로운 라이브러리를 배울 때, 사람들은 대개 설명을 꼼꼼히 읽기보다 예제를 먼저 복사해 돌려 보며 감을 잡습니다. 그 첫걸음에서 곧바로 컴파일 오류를 만나면, 자기 코드가 잘못된 것인지 라이브러리가 잘못된 것인지조차 헷갈려 한참을 헤매게 됩니다. 가장 친절해야 할 자리가 오히려 가장 먼저 발을 거는 자리가 되는 셈입니다. 그래서 이 수정은 작은 문서 손질처럼 보여도, 이 기능을 새로 접하는 사람의 첫인상을 바로잡는 일이라는 생각이 들었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;아홉-곳의-예제를-같은-형태로-맞췄습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size26&quot;&gt;아홉 곳의 예제를 같은 형태로 맞췄습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;수정은 단순하지만 손이 가는 일이었습니다. 아홉 개의 파일을 하나씩 열어, 설명 속 예제에서 사라진 옛 표기를 새 배열 형태로 바꿨습니다. 하위 단위를 별도 애너테이션으로 감싸 나열하던 부분을, 클래스 목록만 적는 형태로 고친 것입니다. 동작을 동시에 실행하는 예제든, 순서대로 실행하는 예제든, 반복하는 예제든, 조건에 따라 갈라지는 예제든, 모두 같은 원칙으로 바꿨습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;60&quot; data-info=&quot;java {data-source-line=&amp;quot;60&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@ConditionalAgent(outputKey = &quot;response&quot;,
        subAgents = { MedicalExpert.class, TechnicalExpert.class, LegalExpert.class })
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;여기서 분명히 해 둘 점은, 이 변경이 오직 설명 안의 예제만 건드린다는 것입니다. 실제 동작하는 코드는 한 줄도 바뀌지 않았습니다. 애너테이션의 정의도, 그것이 받는 값도, 라이브러리가 실제로 동작하는 방식도 전혀 달라지지 않았습니다. 바뀐 것은 사람이 읽는 예제의 글자뿐이고, 그 예제가 이제 실제로 컴파일되는 올바른 코드를 보여 준다는 점만 달라졌습니다. 라이브러리를 쓰는 누구의 코드에도 영향을 주지 않는, 순수한 문서 수정이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;67&quot; data-ke-size=&quot;size16&quot;&gt;다만 아홉 개 중 두 개의 파일은, 예제를 고치느라 파일을 건드린 김에 코드 정리 도구가 사소한 형식까지 함께 손보았습니다. 가져오는 항목의 순서나 빈 본문의 표기 같은 부분인데, 동작과는 무관한 형식상의 변화였습니다. 이런 형식 정리는 파일을 수정하면 자동으로 적용되도록 되어 있어, 빌드를 통과시키기 위해 받아들여야 하는 부분이었습니다. 정작 봐야 할 변경은 어디까지나 예제 표기를 새 방식으로 바꾼 부분이라는 점은 스스로 분명히 해 두었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;테스트가-없는-것이-맞는-경우&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;69&quot; data-ke-size=&quot;size26&quot;&gt;테스트가 없는 것이 맞는 경우&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;이 기여에서 흥미로웠던 점은, 테스트를 더하지 않은 것이 오히려 올바른 선택이었다는 것입니다. LangChain4j에는 &quot;테스트가 없으면 리뷰하지 않는다&quot;는 강한 원칙이 있습니다. 그래서 변경을 할 때마다 그에 맞는 테스트를 함께 작성하는 것이 기본입니다. 그런데 이번 변경은 동작하는 코드를 전혀 건드리지 않았습니다. 바뀐 것은 사람이 읽는 설명뿐입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;테스트는 코드의 동작을 확인하는 장치입니다. 그런데 이번 수정에는 확인할 새로운 동작이 없습니다. 예제의 글자가 달라졌을 뿐, 라이브러리가 하는 일은 전과 똑같기 때문입니다. 이런 순수한 문서 수정에 굳이 테스트를 붙이려 하면, 오히려 의미 없는 테스트를 억지로 만들게 됩니다. &quot;테스트가 없으면 리뷰하지 않는다&quot;는 원칙은 동작을 바꾸는 변경을 향한 것이지, 모든 변경에 무조건 테스트를 요구하는 것이 아니라는 점을 이번에 분명히 이해했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size16&quot;&gt;대신 이런 변경에는 다른 종류의 확인이 어울립니다. 변경의 성격을 정직하게 밝히는 것입니다. 이 수정이 문서 전용이고 동작이나 외부에서 보이는 부분을 바꾸지 않는다는 점, 그리고 문서를 갱신했다는 점을 분명히 표시했습니다. 변경의 종류에 따라 그에 맞는 증명 방식이 다르다는 것, 그리고 어떤 변경에는 &quot;테스트가 없다&quot;는 사실 자체가 올바른 상태라는 것을 배웠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;저장소의-절차를-따라가며-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size26&quot;&gt;저장소의 절차를 따라가며 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size16&quot;&gt;문서 수정이라고 해서 절차가 가벼워지지는 않았습니다. LangChain4j는 변경을 올리기 전에 먼저 문제를 이슈로 등록해 공유하는 흐름을 따릅니다. 이번에도 사라진 표기가 어느 파일들의 예제에 남아 있는지, 그리고 그것이 왜 문제인지를 이슈로 먼저 정리했습니다. 어떤 변경으로 그 표기가 제거되었는지, 지금 어떤 파일들이 여전히 옛 표기를 쓰는지를 구체적으로 적어 두니, 검토하는 사람이 문제의 범위를 한눈에 파악할 수 있었습니다. 문서 문제임을 알리는 표시도 함께 달았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;변경을 작게, 한 가지 목적에 집중해 유지하라는 원칙도 지켰습니다. 아홉 개의 파일을 건드렸지만, 모든 변경은 &quot;사라진 표기를 현재의 배열 형태로 바꾼다&quot;는 단 하나의 목적으로 묶여 있었습니다. 예제와 무관한 다른 손질을 끼워 넣지 않았고, 바뀐 줄 하나하나가 그 목적으로 곧장 설명될 수 있었습니다. 파일 수가 여럿이라고 해서 변경이 산만해지는 것은 아니며, 하나의 분명한 목적으로 묶여 있으면 그것이 곧 작고 초점 있는 변경이라는 것을 이번에 느꼈습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하기 때문에 PR 제목이 그대로 기록에 남는데, 그 제목 역시 무엇을 왜 바꿨는지가 한눈에 드러나도록 다듬었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-수정에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;83&quot; data-ke-size=&quot;size26&quot;&gt;작은 수정에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;85&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 것은 사람이 읽는 예제의 글자들뿐입니다. 동작하는 코드는 한 줄도 달라지지 않았습니다. 하지만 그 작은 변경에 도달하기까지 거친 과정에서 얻은 것은 적지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;85&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 문서도 코드처럼 낡으며 그래서 코드만큼 관리되어야 한다는 감각입니다. 코드가 바뀌면 그에 딸린 설명과 예제도 함께 바뀌어야 합니다. 그런데 문서는 컴파일되지 않으니, 안의 예제가 더 이상 동작하지 않게 되어도 빌드가 깨지며 알려 주지 않습니다. 그래서 문서 속 예제는 빌드라는 안전망 밖에 있고, 사람이 의식적으로 챙기지 않으면 조용히 낡아 갑니다. 특히 사용자가 복사해 쓰는 예제는 사실상 약속에 가까우므로, 그 약속이 깨지지 않도록 살펴야 한다는 것을 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 무언가를 제거할 때는 그것을 가리키는 모든 자리를 함께 정리해야 한다는 점입니다. 한 표기가 사라졌는데 그것을 쓰던 예제가 흩어진 채 남으면, 그 예제들은 존재하지 않는 것을 가리키는 죽은 참조가 됩니다. 무언가를 없앨 때는 그 흔적이 어디에 남아 있는지를 검색으로 빠짐없이 훑어, 한 번에 함께 정리하는 것이 안전하다는 것을 이번에 다시 느꼈습니다. 빠짐없이 찾는 데에는 거창한 도구가 필요하지 않았습니다. 사라진 표기를 텍스트로 검색하는 것만으로 아홉 곳을 모두 찾을 수 있었습니다. 그리고 아홉 곳을 모두 새 형태로 바꾼 뒤, 같은 검색을 한 번 더 돌려 사라진 옛 표기가 한 건도 남지 않았음을 직접 확인했습니다. 찾을 때 쓴 그 검색을 고친 뒤에도 다시 써서 빠뜨린 자리가 없음을 같은 방법으로 매듭짓는 것이, 기억에 기대 &quot;다 고쳤겠지&quot; 하고 넘어가는 것보다 훨씬 마음이 놓였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 변경의 종류에 따라 그에 맞는 증명 방식이 다르다는 점입니다. 동작을 바꾸는 변경에는 테스트가 필요하지만, 순수한 문서 수정에는 그 변경이 문서 전용이며 동작을 건드리지 않는다는 사실을 정직하게 밝히는 것이 더 어울립니다. 규칙을 기계적으로 적용하기보다, 그 규칙이 무엇을 위한 것인지를 이해하고 변경의 성격에 맞게 적용하는 것이 중요하다는 것을 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 문서 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 코드처럼 낡은 문서를 알아채고, 사라진 흔적이 어디에 남았는지를 빠짐없이 찾고, 그것을 이미 올바른 예제와 같은 형태로 맞추며, 변경의 성격에 맞는 방식으로 마무리하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 낡은 흔적을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5473&quot;&gt;langchain4j/langchain4j#5473&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/523</guid>
      <comments>https://ebson.tistory.com/523#entry523comment</comments>
      <pubDate>Sat, 20 Jun 2026 15:32:19 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 에이전트 인자 변환에서 short와 byte가 빠진 자리를 채우다</title>
      <link>https://ebson.tistory.com/522</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 읽다 보면 &quot;여기에 당연히 있어야 할 것이 왜 없지&quot; 싶은 순간이 있습니다. 비슷한 항목들이 나란히 줄지어 있는데, 그중 두 개만 빠져 있는 식입니다. 빠진 자리는 평소에는 아무 표시도 내지 않습니다. 그 자리를 실제로 밟는 입력이 들어오기 전까지는요. 그러다 어느 날 누군가 하필 그 빠진 자리에 해당하는 값을 넘기면, 갑자기 알 수 없는 오류가 튀어나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룰 LangChain4j의 에이전트 모듈 수정은 바로 그런 종류의 버그였습니다. 값을 알맞은 타입으로 바꿔 주는 코드가 있는데, 그 안에서 처리하는 타입 목록에 두 가지가 빠져 있었습니다. 빠진 타입을 쓰는 경우에만 변환이 제대로 되지 않아, 엉뚱한 타입의 값이 그대로 흘러가 오류로 이어졌습니다. 결과만 보면 두 줄을 더하고 테스트 두 개를 추가한 작은 변경이지만, 왜 그 두 줄이 빠져 있었고 그 빠짐이 어떻게 오류로 이어지는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;에이전트들이-값을-주고받는-방식&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;에이전트들이 값을 주고받는 방식&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j에는 여러 에이전트를 엮어 하나의 흐름을 구성하는 기능이 있습니다. 이때 에이전트들은 공용 저장 공간을 통해 값을 주고받습니다. 한 에이전트가 결과를 이 공간에 적어 두면, 다음 에이전트가 그 값을 읽어 자기 일에 씁니다. 이 공간은 이름표가 붙은 값들의 묶음, 즉 키와 값의 모음이라고 보면 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;에이전트의 동작은 메서드로 정의되고, 그 메서드의 매개변수에는 &quot;이 자리에는 공용 공간의 어떤 이름표에 해당하는 값을 넣어 달라&quot;는 표시가 붙습니다. 그래서 에이전트를 실행할 때 프레임워크는, 메서드가 요구하는 매개변수의 타입과 공용 공간에 담긴 값을 맞춰 봅니다. 그런데 공용 공간에 담긴 값의 타입과 메서드가 요구하는 타입이 늘 똑같지는 않습니다. 예를 들어 공간에는 정수가 들어 있는데 메서드는 더 작은 정수 타입을 요구할 수 있습니다. 이 어긋남을 메워 주는 것이, 값을 요구된 타입으로 바꿔 주는 변환 코드였습니다. 이번 버그는 바로 이 변환 코드 안에 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;숫자를-타입별로-바꿔-주는-목록에-두-자리가-비어-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;숫자를 타입별로 바꿔 주는 목록에 두 자리가 비어 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;값을 요구된 타입으로 바꿔 주는 메서드는 값의 종류에 따라 갈래가 나뉘어 있습니다. 그중 값이 숫자일 때를 다루는 부분은 다음과 같이 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;27&quot; data-info=&quot;java {data-source-line=&amp;quot;27&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;if (value instanceof Number n) {
    return switch (type.getName()) {
        case &quot;java.lang.String&quot; -&amp;gt; &quot;&quot; + n;
        case &quot;int&quot;, &quot;java.lang.Integer&quot; -&amp;gt; n.intValue();
        case &quot;long&quot;, &quot;java.lang.Long&quot; -&amp;gt; n.longValue();
        case &quot;double&quot;, &quot;java.lang.Double&quot; -&amp;gt; n.doubleValue();
        case &quot;float&quot;, &quot;java.lang.Float&quot; -&amp;gt; n.floatValue();
        default -&amp;gt; value;
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;요구된 타입의 이름을 보고, 그에 맞춰 숫자를 해당 타입의 값으로 바꿔 돌려주는 구조입니다. 요구된 타입이 정수면 정수 값으로, 긴 정수면 긴 정수 값으로, 실수면 실수 값으로 바꿉니다. 자바의 기본 숫자 타입들이 여기에 줄지어 들어 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이 목록을 가만히 보면, 자바의 숫자 타입 중 두 가지가 빠져 있었습니다. 짧은 정수와 한 바이트짜리 정수, 흔히 short와 byte라고 부르는 타입입니다. 다른 정수 타입과 실수 타입은 모두 자기 자리가 있는데, 이 두 가지만 목록에 없었습니다. 목록에 없는 타입이 요구되면 어떻게 될까요. 맨 아래의 기본 처리가 받아, 값을 아무것도 바꾸지 않고 원래 그대로 돌려줍니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;여기서 문제가 생깁니다. 공용 공간에 담긴 숫자는 흔히 일반 정수 형태입니다. 데이터를 외부에서 읽어 들이거나 형식을 주고받는 과정에서, 정수는 보통 일반 정수 타입으로 만들어지기 때문입니다. 그런데 메서드가 요구하는 타입이 short나 byte라면, 변환 목록에 그 자리가 없으니 일반 정수가 변환되지 않고 그대로 돌아옵니다. 요구된 것은 짧은 정수인데 손에 쥔 것은 일반 정수인, 타입이 어긋난 상태가 되는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;리플렉션은-타입에-엄격합니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size26&quot;&gt;리플렉션은 타입에 엄격합니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;타입이 어긋난 값이 어떻게 오류로 이어지는지를 짚어 둘 필요가 있습니다. 프레임워크는 에이전트 메서드를 직접 호출하는 것이 아니라, 실행 시점에 메서드 정보를 보고 호출하는 방식을 씁니다. 흔히 리플렉션이라고 부르는 방식입니다. 그리고 이 방식은 타입에 무척 엄격합니다. 평범하게 코드를 작성할 때라면, 일반 정수를 더 작은 정수 자리에 넣으려 할 때 컴파일러나 언어 차원의 도움을 어느 정도 받을 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;하지만 실행 시점에 메서드 정보를 보고 호출하는 방식에서는 그런 도움이 없습니다. 메서드가 짧은 정수를 요구하면, 넘기는 값도 정확히 짧은 정수 타입이어야 합니다. 일반 정수를 그 자리에 넘기면, 그 값이 표현하는 숫자가 아무리 작아도 타입이 맞지 않는다는 이유로 거절됩니다. &quot;인자 타입이 맞지 않는다&quot;는 오류가 바로 그것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;그래서 변환 목록에서 short와 byte가 빠진 것이 곧장 실행 오류로 이어졌습니다. 짧은 정수 매개변수를 가진 에이전트 메서드에, 공용 공간의 일반 정수 값을 넣어 실행하면, 변환되지 않은 일반 정수가 그대로 전달되고, 엄격한 호출 방식이 그것을 타입 불일치로 거절합니다. 사용자 입장에서는 단지 짧은 정수 매개변수를 썼을 뿐인데 알 수 없는 타입 오류를 만나게 되는 것입니다. 더 흔히 쓰는 정수나 긴 정수 매개변수를 썼다면 멀쩡했을 텐데, 덜 쓰이는 두 타입을 골랐다는 이유만으로 막히는 셈이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;형제-분기들이-답을-보여-주고-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;54&quot; data-ke-size=&quot;size26&quot;&gt;형제 분기들이 답을 보여 주고 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;이 버그를 두고 마음이 놓였던 점은, 고칠 방향을 바로 그 코드 자신이 분명히 보여 주고 있었다는 것입니다. 변환 목록에는 이미 일반 정수, 긴 정수, 실수 타입을 다루는 자리들이 나란히 있었습니다. 각 자리는 숫자를 해당 타입의 값으로 바꿔 돌려주는, 똑같은 모양의 처리를 하고 있었습니다. 짧은 정수와 한 바이트짜리 정수도 자바가 똑같이 제공하는 숫자 타입이고, 숫자를 그 타입의 값으로 바꾸는 방법도 다른 타입들과 똑같이 마련되어 있습니다. 즉 빠진 두 자리를 채우는 일은, 옆에 이미 있는 형제 자리들과 같은 모양으로 두 줄을 더하면 끝나는 것이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;같은 메서드 안의 다른 갈래, 곧 값이 글자일 때를 다루는 부분과 비교해 보아도 이 빠짐이 드러났습니다. 글자를 다루는 갈래는 더 넓은 범위의 타입을 다루고 있었는데, 숫자를 다루는 갈래만 두 타입에서 비어 있어 둘 사이가 어긋나 있었습니다. 한쪽은 폭넓게 처리하는데 다른 쪽만 일부 타입을 빠뜨린, 일관되지 않은 상태였던 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;60&quot; data-ke-size=&quot;size16&quot;&gt;이 비교를 통해 이번 수정이 새로운 무언가를 만드는 일이 아니라, 이미 자리 잡은 패턴에서 빠진 두 칸을 마저 채우는 일이라는 점이 분명해졌습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 자연스러운지를, 바로 옆에 있는 형제 자리들로 설명할 수 있었습니다. 이런 종류의 수정은 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;빠진-두-줄을-채웠습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;62&quot; data-ke-size=&quot;size26&quot;&gt;빠진 두 줄을 채웠습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;64&quot; data-ke-size=&quot;size16&quot;&gt;방향이 분명해지니 수정 자체는 두 줄이 전부였습니다. 변환 목록에 짧은 정수와 한 바이트짜리 정수를 다루는 자리를 더했습니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;66&quot; data-info=&quot;java {data-source-line=&amp;quot;66&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;case &quot;short&quot;, &quot;java.lang.Short&quot; -&amp;gt; n.shortValue();
case &quot;byte&quot;, &quot;java.lang.Byte&quot; -&amp;gt; n.byteValue();
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 요구된 타입이 짧은 정수일 때 숫자를 짧은 정수 값으로, 한 바이트짜리 정수일 때 그에 맞는 값으로 바꿔 돌려줍니다. 각 타입은 기본형과 그에 대응하는 객체형 두 가지 이름으로 들어올 수 있어, 두 이름을 한 자리에서 함께 받도록 했습니다. 옆에 이미 있던 일반 정수나 실수 처리와 정확히 같은 모양입니다. 빠져 있던 두 칸을 형제들과 똑같은 모양으로 채운 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;여기서 마음을 놓을 수 있었던 점은, 이 변경이 기존 동작을 조금도 건드리지 않는다는 것입니다. 원래 처리되던 타입들은 전과 똑같이 동작합니다. 달라지는 것은 그동안 변환되지 않고 그대로 돌아오던 두 타입뿐이고, 이제 그 두 타입도 다른 타입들과 똑같이 올바르게 변환됩니다. 잘 동작하던 경우는 그대로 두고 막혀 있던 경우만 열어 주는, 더하기만 하는 변경이라 호환성을 걱정할 부분이 없었습니다. 메서드의 바깥 모습이나 다른 어떤 줄도 손대지 않았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;고치기-전에는-실패하고-고친-뒤에는-통과하는-테스트&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;75&quot; data-ke-size=&quot;size26&quot;&gt;고치기 전에는 실패하고 고친 뒤에는 통과하는 테스트&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size16&quot;&gt;코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 &quot;테스트가 없으면 리뷰하지 않는다&quot;는 원칙이 있고, 이런 변환 버그는 잘못된 타입이 정말로 올바른 타입으로 바뀌는지를 테스트로 못 박아 두는 것이 핵심이었습니다. 다행히 이 변환 코드는 외부 모델 호출 없이 순수하게 동작을 확인할 수 있어, 인증 키나 네트워크 없이도 결과를 들여다볼 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;77&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;79&quot; data-ke-size=&quot;size16&quot;&gt;그래서 기존에 있던 비슷한 테스트의 모양을 그대로 따라, 두 개의 테스트를 더했습니다. 공용 공간에 일반 정수를 적어 두고, 짧은 정수 매개변수를 요구하는 상황을 만든 다음, 변환된 값이 정확히 짧은 정수가 되는지를 확인하는 식입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;81&quot; data-info=&quot;java {data-source-line=&amp;quot;81&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@Test
void should_coerce_integer_to_short() throws Exception {
    DefaultAgenticScope scope = DefaultAgenticScope.ephemeralAgenticScope();
    scope.writeState(&quot;count&quot;, 42);

    AgentInvocationArguments args =
            AgentUtil.agentInvocationArguments(scope, List.of(new AgentArgument(short.class, &quot;count&quot;)));

    assertThat(args.positionalArgs()[0]).isEqualTo((short) 42);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;한 바이트짜리 정수에 대해서도 같은 모양의 테스트를 더했습니다. 이 테스트들이 의미가 있었던 이유는, 고치기 전에는 분명히 실패하고 고친 뒤에는 통과한다는 점이었습니다. 고치기 전이라면 변환되지 않은 일반 정수가 돌아오므로, 짧은 정수와 비교하는 단언이 타입이 다르다는 이유로 실패합니다. 고친 뒤에는 제대로 짧은 정수로 바뀌므로 통과합니다. 단언이 통과하는지뿐 아니라, 그 단언이 버그를 실제로 잡아내는지를 확인할 수 있는 형태였습니다. 같은 숫자라도 타입이 다르면 같지 않다고 보는 비교를 써서, 값뿐 아니라 타입까지 정확히 검증했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;저장소의-절차를-따라가며-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;96&quot; data-ke-size=&quot;size26&quot;&gt;저장소의 절차를 따라가며 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. 이번처럼 특정 타입에서만 나타나는 미묘한 버그는, 어떤 조건에서 어떤 오류가 나는지를 글로 차근차근 정리해 두는 것이 중요했습니다. 짧은 정수 매개변수에 일반 정수 값이 들어오면 타입 불일치 오류가 난다는 재현 조건을 먼저 적어 두면, 검토하는 사람도 코드를 보기 전에 문제의 그림을 그릴 수 있기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;변경을 작게 유지하라는 원칙도 자연스럽게 지켜졌습니다. 이번 수정은 변환 목록에 두 줄을 더하고 그것을 확인하는 테스트 두 개를 추가한 것이 전부였습니다. 변환과 무관한 다른 손질을 섞지 않았고, 바뀐 줄 하나하나가 &quot;빠진 두 타입을 다른 타입과 똑같이 변환한다&quot;는 목적으로 곧장 설명될 수 있었습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하기 때문에 PR 제목이 그대로 기록에 남는데, 그 제목 역시 어떤 변환에 어떤 타입을 더했는지가 한눈에 드러나도록 다듬었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-수정에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;102&quot; data-ke-size=&quot;size26&quot;&gt;작은 수정에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;104&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 코드의 양은 정말 적습니다. 변환 목록에 두 줄을 더하고, 그것을 확인하는 테스트 두 개를 추가한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;104&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 여러 경우를 나누어 처리하는 코드에서는 &quot;빠진 경우&quot;가 곧 잠재적인 버그라는 감각입니다. 비슷한 항목들이 줄지어 있는 목록은 편리하지만, 그중 일부가 빠지면 그 자리를 밟는 입력이 들어올 때까지 아무도 모르게 숨어 있다가 어느 순간 드러납니다. 특히 맨 아래의 기본 처리가 &quot;아무것도 하지 않고 원래 값을 돌려주는&quot; 형태일 때는, 빠진 경우가 조용히 잘못된 값을 흘려보내기 쉽습니다. 가능한 경우들을 나열할 때는 빠진 것이 없는지를 형제 항목들과 견주어 확인해야 한다는 것을 이번에 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는, 실행 시점에 타입을 다루는 코드는 평소의 너그러움을 기대할 수 없다는 점입니다. 평범하게 짠 코드라면 언어가 어느 정도 알아서 맞춰 주던 타입 변환도, 실행 시점에 메서드 정보를 보고 호출하는 방식에서는 정확히 맞아떨어져야 합니다. 값이 표현하는 내용이 아니라 값의 타입 그 자체가 문제가 되는 상황을, 이번 버그가 분명하게 보여 주었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;110&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 테스트가 버그를 실제로 잡아내는지를 확인하는 일의 중요함입니다. 고치기 전에 실패하고 고친 뒤에 통과하는 테스트는, 단지 동작을 확인하는 데 그치지 않고 그 테스트가 정말로 의미 있는 검증인지를 함께 증명합니다. 만약 고치기 전에도 통과하는 테스트였다면, 그것은 버그를 잡지 못하는 헛된 그물이었을 것입니다. 테스트를 더할 때는 그 테스트가 고치기 전 코드에서 실패하는지를 한번 확인해 보는 습관이 왜 필요한지를, 이번에 다시 느꼈습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;110&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 타입 변환 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 빠진 경우를 형제 항목들과 비교해 찾아내고, 실행 시점의 엄격한 타입 규칙을 이해하고, 버그를 실제로 잡아내는 테스트를 남기는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 빠진 자리를 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;116&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5469&quot;&gt;langchain4j/langchain4j#5469&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/522</guid>
      <comments>https://ebson.tistory.com/522#entry522comment</comments>
      <pubDate>Sat, 20 Jun 2026 15:31:01 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 멀티파트 업로드에 새던 Content-Type: null을 고치다</title>
      <link>https://ebson.tistory.com/521</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서 문자열을 더하는 일은 너무 익숙해서 위험을 잘 느끼지 못합니다. 두 문자열을&lt;span&gt;&amp;nbsp;&lt;/span&gt;+로 이으면 그만이고, 숫자든 다른 객체든 옆에 붙이면 알아서 글자로 바뀝니다. 그런데 이 편리함에는 조용한 함정이 하나 있습니다. 비어 있는 값, 즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;null을 문자열에 더하면 오류가 나는 것이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;null&quot;이라는 네 글자가 그대로 붙는다는 것입니다. 예외가 터지면 적어도 무언가 잘못됐다는 신호라도 받겠지만, 이 경우에는 아무 일도 없었다는 듯 멀쩡해 보이는 문자열이 만들어집니다. 그 문자열이 어딘가로 전송되기 전까지는 말입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룰 LangChain4j의 HTTP 클라이언트 수정은 바로 그 함정에 관한 것이었습니다. 파일을 업로드할 때 만들어지는 본문 안에, 비어 있어야 할 자리에&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이라는 글자가 그대로 박혀 잘못된 형식의 데이터가 서버로 나가고 있었습니다. 결과만 보면 헤더를 만드는 코드 몇 줄을 손보고 테스트 세 개를 더한 작은 변경이지만, 이 버그가 정상적인 사용에서 어떻게 도달하는지, 그리고 같은 일을 하는 형제 구현들과 어떻게 어긋나 있었는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;파일을-업로드할-때-만들어지는-본문&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;파일을 업로드할 때 만들어지는 본문&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. 웹에서 파일을 업로드할 때는 멀티파트라는 형식을 씁니다. 한 요청 안에 여러 조각을 담을 수 있는 형식인데, 각 조각은 자기 자신을 설명하는 머리말과 실제 내용으로 이루어집니다. 머리말에는 이 조각이 어떤 이름의 항목인지, 파일이라면 파일 이름이 무엇인지, 그리고 그 내용이 어떤 종류의 데이터인지를 알려주는 정보가 들어갑니다. 마지막의 &quot;어떤 종류인지&quot;를 나타내는 것이 Content-Type입니다. 예를 들어 음성 파일이라면&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type: audio/wav&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 식입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j는 여러 가지 HTTP 클라이언트 구현을 함께 제공합니다. Apache 기반, JDK 기본 기능 기반, 그리고 OkHttp 기반의 세 가지입니다. 같은 일을 하는 세 가지 구현이 있는 셈인데, 이 가운데 Apache와 JDK 구현은 멀티파트 본문을 손수 글자로 조립합니다. 즉 머리말의 각 줄을 문자열로 직접 이어 붙여 만듭니다. 이번에 들여다본 것은 그중 Apache 구현에서 파일 조각을 만드는&lt;span&gt;&amp;nbsp;&lt;/span&gt;addFile이라는 부분이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;비어-있는-값이-글자가-되어-박혔습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;비어 있는 값이 글자가 되어 박혔습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;addFile이 머리말을 만드는 수정 전 코드는 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;lsl&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;27&quot; data-info=&quot;java {data-source-line=&amp;quot;27&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;String header = &quot;--&quot; + BOUNDARY + CRLF + &quot;Content-Disposition: form-data; name=\&quot;&quot;
        + name + &quot;\&quot;; filename=\&quot;&quot; + file.fileName() + &quot;\&quot;&quot; + CRLF + &quot;Content-Type: &quot;
        + file.contentType() + CRLF + CRLF;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;여러 조각을 문자열로 이어 머리말 한 덩어리를 만드는 코드입니다. 여기서 눈여겨볼 부분은 마지막에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;Content-Type: &quot;&lt;span&gt;&amp;nbsp;&lt;/span&gt;뒤에&lt;span&gt;&amp;nbsp;&lt;/span&gt;file.contentType()을 그대로 이어 붙이는 대목입니다. 파일의 종류 정보가 제대로 들어 있을 때는 아무 문제가 없습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type: audio/wav처럼 올바른 줄이 만들어집니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;문제는 그 종류 정보가 비어 있을 때였습니다. 만약 그 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이면, 문자열 연결은 오류를 내지 않고&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type: null이라는 줄을 만듭니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이 네 글자짜리 단어가 되어 버린 것입니다. 값이 빈 문자열이면&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type:&lt;span&gt;&amp;nbsp;&lt;/span&gt;뒤에 아무것도 없는, 값이 비어 있는 머리말 줄이 만들어집니다. 두 경우 모두 멀티파트 형식에 어긋난 잘못된 머리말입니다. 그런데 코드 입장에서는 그저 문자열 몇 개를 이어 붙였을 뿐이라 아무런 이상 신호도 나지 않습니다. 잘못된 머리말이 그대로 본문에 담겨 서버로 전송됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;이것이 이 버그의 첫 번째 핵심이었습니다. 비어 있는 값을 문자열에 더하는 순간, 그 비어 있음이 오류가 아니라 글자로 둔갑한다는 것입니다. 예외라면 어디선가 걸려 멈췄을 텐데, 글자로 바뀌어 버리니 아무 데도 걸리지 않고 끝까지 흘러갑니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;이상한-입력이-아니라-정상적인-사용이었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;39&quot; data-ke-size=&quot;size26&quot;&gt;이상한 입력이 아니라 정상적인 사용이었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이런 경우가 실제로 일어날까 의심했습니다. 누가 일부러 파일의 종류를 비워 두겠나 싶었던 것입니다. 그런데 코드를 따라가 보니, 이것은 억지로 만들어낸 상황이 아니라 평범한 사용에서 도달하는 경로였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;LangChain4j에는 음성 데이터를 다루는 자료형이 있는데, 이 자료형은 종류 정보를 반드시 채우도록 강제하지 않습니다. 문서에도 그 값이 설정되지 않으면 비어 있을 수 있다고 분명히 적혀 있습니다. 즉 음성 데이터를 만들 때 종류를 따로 지정하지 않으면 그 값은 비어 있는 상태가 됩니다. 그리고 음성을 글자로 옮기는 기능을 호출하는 코드는, 이 비어 있을 수 있는 종류 값을 따로 확인하지 않고 그대로 멀티파트 본문 조립부에 넘깁니다. 결국 종류를 지정하지 않은 음성 데이터를 전사 기능에 넣으면, 본문에&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type: null이라는 잘못된 줄이 박히게 되는 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;여기에 더해, 프로젝트 자신의 계약 테스트 중에도 종류 정보를 빈 문자열로 넘기는 경우가 있었습니다. 즉 빈 값은 외부의 이상한 입력이 아니라, 프로젝트 스스로가 정상으로 간주하고 쓰는 입력이었습니다. 정상 경로에서 도달하는 결함이라는 점이, 이 버그를 고칠 가치가 있는 것으로 만들었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;형제-구현이-올바른-답을-보여-주었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;47&quot; data-ke-size=&quot;size26&quot;&gt;형제 구현이 올바른 답을 보여 주었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;&quot;내가 보기에 잘못됐다&quot;는 판단만으로 고치기에는 늘 조심스럽습니다. 그래서 같은 일을 하는 세 구현이 이 상황에서 각각 어떻게 동작하는지를 나란히 비교해 보았습니다. 비교 대상으로 가장 좋은 것은 OkHttp 구현이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;Apache와 JDK 구현이 머리말을 손수 문자열로 조립하는 것과 달리, OkHttp 구현은 종류 정보를 OkHttp 라이브러리의 처리 함수에 맡깁니다. 그 함수는 빈 값이 들어오면 종류가 없는 것으로 판단하고, 결과적으로 그 조각에서 Content-Type 줄을 아예 빼 버립니다. 즉 OkHttp 구현은 종류가 비어 있으면 잘못된 줄을 만드는 대신 그 줄을 생략합니다. 같은 입력에 대해 세 구현이 서로 다른 바이트를 내보내고 있었고, 그중 OkHttp의 방식이 올바른 쪽이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;이 비교를 통해 고칠 방향이 분명해졌습니다. 새로운 규칙을 만들 필요 없이, OkHttp 형제가 이미 보여 주고 있는 방식, 곧 &quot;종류가 비어 있으면 Content-Type 줄을 생략한다&quot;는 동작으로 Apache 구현을 맞추면 되는 것이었습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 옳은지를, 같은 저장소 안의 형제 구현으로 설명할 수 있었습니다. 어긋난 구현을 이미 올바르게 동작하는 형제 쪽으로 수렴시키는 변경은, 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;비어-있으면-줄을-빼도록-고쳤습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;55&quot; data-ke-size=&quot;size26&quot;&gt;비어 있으면 줄을 빼도록 고쳤습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;방향이 분명해지니 수정 자체는 단출했습니다. 머리말을 한 번에 이어 붙이는 대신, 종류 정보가 비어 있지 않을 때만 Content-Type 줄을 더하도록 나눴습니다. 수정 후 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;59&quot; data-info=&quot;java {data-source-line=&amp;quot;59&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;String header = &quot;--&quot; + BOUNDARY + CRLF + &quot;Content-Disposition: form-data; name=\&quot;&quot; + name + &quot;\&quot;; filename=\&quot;&quot;
        + file.fileName() + &quot;\&quot;&quot; + CRLF;
if (!isNullOrBlank(file.contentType())) {
    header += &quot;Content-Type: &quot; + file.contentType() + CRLF;
}
header += CRLF;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;68&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;68&quot; data-ke-size=&quot;size16&quot;&gt;먼저 조각의 이름과 파일 이름까지를 담은 머리말의 앞부분을 만듭니다. 그다음 종류 정보가 비어 있지 않은 경우에만 Content-Type 줄을 덧붙입니다. 마지막으로 머리말과 내용을 가르는 빈 줄을 더합니다. 이렇게 하면 종류가 제대로 있을 때는 전과 똑같이 Content-Type 줄이 들어가고, 종류가 비어 있을 때는 그 줄이 통째로 빠집니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이라는 글자가 박히던 자리도, 값이 비어 있던 머리말 줄도 더 이상 만들어지지 않습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;여기서 비어 있는지를 판단하는 데 쓴 도구는 LangChain4j가 이미 가지고 있던 검사 함수였습니다. 값이&lt;span&gt;&amp;nbsp;&lt;/span&gt;null이거나 공백뿐인지를 한 번에 확인해 주는 것으로, 저장소의 핵심 모듈에 들어 있어 새로 끌어올 것이 없었습니다. 외부 라이브러리를 추가하지 않는 것은 이 저장소의 중요한 규칙인데, 이번 수정은 그 규칙과 부딪힐 일이 없었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;72&quot; data-ke-size=&quot;size16&quot;&gt;이 변경이 기존 동작을 깨지 않는다는 점도 분명히 해 두었습니다. 종류 정보가 제대로 들어 있던 기존 요청은 전과 똑같은 본문을 만듭니다. 달라지는 것은 종류가 비어 있던 경우뿐이고, 그 경우는 잘못된 줄 대신 올바른 생략으로 바뀝니다. 잘 동작하던 경우는 건드리지 않고 어긋나던 경우만 바로잡는 변경이라, 호환성을 걱정할 부분이 없었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;만들어진-본문을-직접-확인하는-테스트&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;74&quot; data-ke-size=&quot;size26&quot;&gt;만들어진 본문을 직접 확인하는 테스트&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 &quot;테스트가 없으면 리뷰하지 않는다&quot;는 원칙이 있고, 이번처럼 만들어지는 결과물이 정확히 어떤 형태여야 하는지가 분명한 경우에는 그 결과물을 직접 확인하는 테스트가 적절했습니다. 다행히 이 멀티파트 조립부에는 외부 연결 없이 순수하게 동작을 확인할 수 있는 단위 테스트가 이미 있었습니다. 실제 네트워크나 인증 키 없이도, 만들어진 본문 문자열을 그대로 들여다볼 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;그래서 세 가지 경우를 확인하는 테스트를 더했습니다. 종류가 비어 있는 경우, 종류가 빈 문자열인 경우, 그리고 종류가 제대로 들어 있는 경우입니다. 앞의 두 경우에서는 만들어진 본문에 Content-Type 줄이 들어 있지 않은지를 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;80&quot; data-info=&quot;java {data-source-line=&amp;quot;80&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@Test
void should_omit_content_type_header_when_content_type_is_null() {
    MultipartBodyPublisher publisher = new MultipartBodyPublisher();
    FormDataFile file = new FormDataFile(&quot;audio.wav&quot;, null, &quot;hello&quot;.getBytes(UTF_8));

    publisher.addFile(&quot;file&quot;, file);
    publisher.build();

    String body = bodyAsString(publisher.parts());
    assertThat(body).doesNotContain(&quot;Content-Type:&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;세 번째 경우에서는 반대로, 종류가 제대로 있을 때는&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Type: audio/wav&lt;span&gt;&amp;nbsp;&lt;/span&gt;줄이 그대로 들어가는지를 확인합니다. 잘못된 경우를 막는 것만큼이나, 올바른 경우의 동작이 그대로 유지되는지를 확인하는 것도 중요했습니다. 비어 있는 입력에 대한 부정적인 검증과 정상 입력에 대한 긍정적인 검증을 함께 갖춰, 이 수정이 한쪽만 고치고 다른 쪽을 망가뜨리지 않았음을 분명히 했습니다. 만들어진 본문을 글자 단위로 비교하니, 머리말에서 어떤 줄이 빠지고 어떤 줄이 남는지가 테스트만 봐도 한눈에 드러났습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;한-모듈만-먼저-고치는-절제&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;96&quot; data-ke-size=&quot;size26&quot;&gt;한 모듈만 먼저 고치는 절제&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;이번 기여에서 특히 의식한 것은 변경의 범위를 어디까지로 둘 것인가 하는 문제였습니다. 코드를 비교하면서, 손수 멀티파트를 조립하는 JDK 구현이 Apache 구현과 사실상 똑같은 방식으로 짜여 있다는 것을 알게 됐습니다. 즉 JDK 구현에도 정확히 같은 버그가 있었습니다. 두 곳을 한 번에 고치고 싶은 마음이 드는 것이 자연스러웠습니다. 같은 버그이니 같은 PR에서 함께 처리하면 효율적으로 보였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;그렇지만 한 번에 여러 모듈을 건드리면 변경이 커지고, 검토하는 사람이 살펴야 할 범위도 넓어집니다. 그래서 이번에는 Apache 구현 한 곳만 먼저 고치기로 했습니다. 같은 버그를 가진 JDK 구현은 이번 수정이 받아들여지는 것을 확인한 뒤에 따로 다루기로 미뤄 두었습니다. 한 곳을 먼저 올려 그 방향이 유지보수하는 사람들에게 받아들여지는지를 확인하고, 그 신호를 본 다음에 같은 패치를 다른 곳으로 넓히는 방식입니다. 만약 첫 수정이 다른 방향으로 바뀌어야 한다면, 한 곳만 고쳐 둔 편이 되돌리기도 쉽습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;102&quot; data-ke-size=&quot;size16&quot;&gt;이 절제는 &quot;변경은 작게, 한 번에 한 가지에 집중하라&quot;는 저장소의 원칙과도 맞닿아 있었습니다. 같은 버그가 여러 곳에 있다는 사실은 이슈와 PR 본문에 분명히 적어 두되, 실제 변경은 한 곳으로 좁혔습니다. 고쳐야 할 것을 다 알면서도 한 번에 다 손대지 않는 것이, 처음에는 답답하게 느껴졌지만 협업하는 저장소에서는 오히려 신중한 태도라는 것을 이번에 배웠습니다. 물론 버그 수정인 만큼, PR을 올리기 전에 먼저 이슈를 등록해 문제를 공유하고 그 번호를 PR에 연결하는 절차도 그대로 따랐습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-수정에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;104&quot; data-ke-size=&quot;size26&quot;&gt;작은 수정에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 코드의 양은 많지 않습니다. 머리말을 만드는 방식을 조금 나누고, 만들어진 본문을 확인하는 테스트 세 개를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 문자열 연결처럼 익숙한 동작에도 함정이 숨어 있다는 감각입니다. 비어 있는 값을 문자열에 더하면 오류가 아니라 글자가 됩니다. 예외라면 어디선가 걸렸을 잘못이, 글자로 둔갑하는 순간 아무 데도 걸리지 않고 끝까지 흘러갑니다. 값이 비어 있을 가능성이 있는 자리를 문자열에 직접 이어 붙일 때는, 그 비어 있음을 먼저 다루어야 한다는 것을 이번에 분명히 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;110&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는 같은 일을 하는 구현이 여럿일 때, 그것들이 같은 입력에 같은 결과를 내는지를 살피는 일의 가치입니다. 세 클라이언트가 같은 요청에 서로 다른 바이트를 내보내고 있었고, 그 차이가 곧 버그의 단서였습니다. 형제 구현 중 올바르게 동작하는 쪽이 있다면, 그것이 곧 고칠 방향이자 그 방향이 옳다는 근거가 됩니다. 여러 구현을 나란히 놓고 비교하는 습관이 어긋남을 드러내 준다는 것을 다시 느꼈습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;110&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 고쳐야 할 것을 다 알면서도 범위를 절제하는 일의 의미입니다. 같은 버그가 두 곳에 있어도, 한 곳만 먼저 고치고 다른 곳은 그다음을 기약하는 편이 협업에서는 더 신중한 선택일 수 있습니다. 변경을 작게 유지하는 것은 단지 코드의 양을 줄이는 일이 아니라, 검토하는 사람과 호흡을 맞추고 위험을 나눠 다루는 일이라는 것을 이번에 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;114&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 헤더 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 익숙한 동작에 숨은 함정을 알아채고, 형제 구현과 비교해 올바른 방향을 찾고, 만들어지는 결과물을 직접 확인하는 테스트를 남기며, 변경의 범위를 절제하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 어긋난 출력을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;118&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5467&quot;&gt;langchain4j/langchain4j#5467&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/521</guid>
      <comments>https://ebson.tistory.com/521#entry521comment</comments>
      <pubDate>Sat, 20 Jun 2026 15:29:34 +0900</pubDate>
    </item>
    <item>
      <title>[Open source contribution] langchain4j 오픈소스 기여 경험기 - 셸 도구의 null 인자가 만든 가공되지 않은 NPE를 고치다</title>
      <link>https://ebson.tistory.com/520</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;서비스를 만들다 보면 &quot;정상적인 입력&quot;을 가정하고 짠 코드가 의외로 많습니다. 사용자가 빈 값을 보내지 않을 것이고, 필수 항목은 채워서 보낼 것이라는 전제 위에 코드가 서 있습니다. 그런데 그 입력이 사람이 아니라 언어 모델에서 온다면 이야기가 달라집니다. 언어 모델의 출력은 대체로 형식을 잘 지키지만, 늘 그런 것은 아닙니다. 가끔은 비워 두어야 할 자리를 채우거나, 채워야 할 자리를 비운 채로 내놓습니다. 그래서 모델의 출력을 받아 실제 동작으로 옮기는 코드는, 그 입력이 어긋날 수 있다는 것을 전제로 단단하게 짜여 있어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룰 LangChain4j의 셸 도구 수정은 바로 그 지점에 관한 것이었습니다. 모델이 만들어낸 도구 인자 중 하나가 비어 있을 때, 코드가 의도한 방식으로 부드럽게 거절하지 못하고 가공되지 않은 오류를 그대로 토해 냈습니다. 결과만 보면 한 메서드를 손보고 테스트 두 개를 더한 작은 변경이지만, &quot;키는 있는데 값이 없다&quot;는 미묘한 상황과, 그 오류가 하필 잘못된 자리에서 새어 나가던 구조를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;모델이-만든-인자로-셸-명령을-실행하는-도구&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;모델이 만든 인자로 셸 명령을 실행하는 도구&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j에는 언어 모델이 직접 도구를 호출하도록 연결해 주는 구조가 있습니다. 모델이 &quot;이 도구를 이런 인자로 실행해 줘&quot;라고 요청하면, 그 요청을 받아 실제 동작을 수행하는 식입니다. 이번에 들여다본 것은 그중 셸 명령을 실행하는 도구였습니다. 이 도구를 다루는&lt;span&gt;&amp;nbsp;&lt;/span&gt;RunShellCommandToolExecutor라는 클래스는, 모델이 보낸 요청에서 실행할 명령을 꺼내 셸에서 돌리고 그 결과를 돌려줍니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 모델이 보내는 인자가 글자 그대로의 텍스트, 즉 JSON 형태로 온다는 것입니다. 예를 들어 모델이 셸 명령을 실행하고 싶으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;{&quot;command&quot;: &quot;echo hello&quot;}&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 JSON을 만들어 보냅니다. 도구를 다루는 코드는 이 JSON을 읽어 키와 값의 묶음으로 바꾼 다음, 그 안에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;command라는 필수 인자를 꺼내 씁니다. 필수 인자를 꺼내는 일은&lt;span&gt;&amp;nbsp;&lt;/span&gt;getRequiredArgument라는 메서드가 맡고 있었습니다. 이번 버그는 바로 이 메서드 안에 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;키가-있는지만-확인하고-값은-확인하지-않았습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;키가 있는지만 확인하고 값은 확인하지 않았습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;getRequiredArgument&lt;span&gt;&amp;nbsp;&lt;/span&gt;메서드의 수정 전 모습은 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;27&quot; data-info=&quot;java {data-source-line=&amp;quot;27&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;private String getRequiredArgument(String argumentName, Map&amp;lt;String, Object&amp;gt; arguments) {
    if (isNullOrEmpty(arguments) || !arguments.containsKey(argumentName)) {
        throwException(&quot;Missing required tool argument '%s'&quot;.formatted(argumentName));
    }
    return arguments.get(argumentName).toString();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;이 메서드는 먼저 인자 묶음이 비어 있는지, 그리고 찾는 이름의 키가 들어 있는지를 확인합니다. 둘 중 하나라도 어긋나면 &quot;필수 인자가 빠졌다&quot;는 메시지와 함께 의도된 예외를 던집니다. 여기까지는 괜찮습니다. 문제는 그 확인을 통과한 다음 줄에 있었습니다. 키가 있다고 판단하고 나면, 그 키에 해당하는 값을 꺼내 곧바로 텍스트로 바꿉니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;여기서 놓친 경우가 &quot;키는 있는데 값이 비어 있는&quot; 상황입니다. 키가 들어 있는지를 확인하는 검사는, 그 키에 딸린 값이 무엇인지까지는 보지 않습니다. 키가 존재하기만 하면, 그 값이 비어 있어도 검사를 통과합니다. 즉 모델이&lt;span&gt;&amp;nbsp;&lt;/span&gt;{&quot;command&quot;: null}처럼 명령 자리를 비운 채로 보내면, JSON을 읽어 만든 묶음에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;command라는 키가 분명히 들어 있고 그 값만 비어 있는 상태가 됩니다. 키 존재 검사는 이를 통과시키고, 다음 줄에서 비어 있는 값을 텍스트로 바꾸려다 가공되지 않은 오류, 즉 흔히 말하는 널 포인터 예외가 터집니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&quot;키가 있다&quot;와 &quot;값이 있다&quot;가 다른 이야기라는 것이 이 버그의 첫 번째 핵심이었습니다. 사람이 직접 입력을 채운다면 키만 만들고 값을 비워 두는 일이 드물지도 모릅니다. 하지만 모델이 만든 JSON에서는 이런 형태가 충분히 나올 수 있었습니다. 빈자리를 명시적으로 &quot;비어 있음&quot;으로 채워 보내는 것은 모델 입장에서 자연스러운 출력 중 하나이기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;오류가-새어-나간-자리가-문제를-키웠습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;42&quot; data-ke-size=&quot;size26&quot;&gt;오류가 새어 나간 자리가 문제를 키웠습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;이 버그가 단순한 빈 값 처리 누락에 그치지 않았던 이유는, 오류가 터지는 위치에 있었습니다. 이 셸 도구에는 인자 오류를 다루는 나름의 규칙이 있었습니다. 무언가 잘못되면 가공되지 않은 오류를 그대로 내보내는 것이 아니라, 상황에 맞는 타입 있는 예외로 감싸서 던지도록 되어 있었습니다. 설정에 따라 어떤 경우에는 한 종류의 예외를, 다른 경우에는 또 다른 종류의 예외를 골라 던지는 방식이었습니다. 도구를 쓰는 쪽이 그 예외의 종류를 보고 적절히 대응할 수 있도록 한 배려였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이 예외를 감싸 주는 안전망은 명령을 실제로 실행하는 구간을 감싸고 있었지, 인자를 꺼내는 구간까지는 덮고 있지 않았습니다. 필수 인자를 꺼내는&lt;span&gt;&amp;nbsp;&lt;/span&gt;getRequiredArgument&lt;span&gt;&amp;nbsp;&lt;/span&gt;호출은 그 안전망보다 앞쪽에서 일어났습니다. 그래서 비어 있는 값 때문에 터진 가공되지 않은 오류는, 타입 있는 예외로 감싸지는 과정을 거치지 못한 채 그대로 바깥으로 새어 나갔습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로 두 가지가 한꺼번에 어그러졌습니다. 하나는 도구를 쓰는 쪽이 받게 되는 오류가, 의도된 타입 있는 예외가 아니라 맥락 없는 가공되지 않은 오류였다는 것입니다. 다른 하나는, 어떤 예외를 던질지 고르도록 마련해 둔 설정이 이 경우에는 아무 의미가 없어졌다는 것입니다. 어떤 설정이든 결국 같은 오류가 나왔기 때문입니다. 빈 값 하나가, 공들여 만들어 둔 예외 처리 정책을 통째로 비껴간 셈이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;같은-메서드가-이미-답을-가지고-있었습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;50&quot; data-ke-size=&quot;size26&quot;&gt;같은 메서드가 이미 답을 가지고 있었습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;흥미로웠던 점은, 이 문제를 어떻게 다뤄야 하는지를 바로 그 메서드 자신이 이미 보여 주고 있었다는 것입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;getRequiredArgument는 키가 아예 없는 경우에는 가공되지 않은 오류를 내지 않고, &quot;필수 인자가 빠졌다&quot;는 메시지와 함께 의도된 예외를 던지는 처리를 이미 갖추고 있었습니다. 같은 클래스의 다른 부분에서도 인자와 관련된 오류는 한결같이 이 의도된 방식으로 처리하고 있었습니다. 즉 이 클래스에는 &quot;인자에 문제가 있으면 정해진 방식으로 예외를 던진다&quot;는 일관된 규약이 자리 잡고 있었고, 오직 &quot;키는 있지만 값이 비어 있는&quot; 경우만 그 규약에서 빠져 가공되지 않은 오류로 새어 나가고 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;54&quot; data-ke-size=&quot;size16&quot;&gt;그러니 이번 수정은 새로운 규칙을 만드는 일이 아니라, 이미 그 메서드가 따르고 있던 방식으로 빠진 한 경우를 마저 맞추는 일이었습니다. 비어 있는 값을 키가 없는 경우와 똑같이 취급해, 같은 메시지와 함께 같은 의도된 예외로 보내면 되는 것이었습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 자연스러운지를, 같은 메서드 안의 기존 처리로 설명할 수 있었습니다. 이런 종류의 수정은 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;값을-한-번만-꺼내-확인하도록-고쳤습니다&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;56&quot; data-ke-size=&quot;size26&quot;&gt;값을 한 번만 꺼내 확인하도록 고쳤습니다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;고치는 방향은 분명했지만, 구현 방식에는 작은 선택지가 있었습니다. 단순하게는 기존 검사에 &quot;값이 비어 있는 경우&quot;를 한 가지 더 붙이는 방법이 있었습니다. 다만 그렇게 하면 값을 묶음에서 꺼내는 동작이 검사할 때 한 번, 실제로 쓸 때 또 한 번, 모두 두 번 일어납니다. 그래서 값을 한 번만 꺼내 변수에 담아 두고, 그 변수가 비어 있는지를 확인하는 형태로 정리했습니다. 수정 후 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;60&quot; data-info=&quot;java {data-source-line=&amp;quot;60&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;private String getRequiredArgument(String argumentName, Map&amp;lt;String, Object&amp;gt; arguments) {
    Object value = isNullOrEmpty(arguments) ? null : arguments.get(argumentName);
    if (value == null) {
        throwException(&quot;Missing required tool argument '%s'&quot;.formatted(argumentName));
    }
    return value.toString();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;먼저 인자 묶음이 비어 있으면 값을 비어 있는 것으로 두고, 그렇지 않으면 묶음에서 값을 꺼내 변수에 담습니다. 이렇게 하면 키가 아예 없어 꺼낼 값이 없는 경우든, 키는 있지만 값이 비어 있는 경우든, 모두 변수가 비어 있는 상태로 모입니다. 그다음 그 변수가 비어 있으면 기존과 똑같은 메시지로 의도된 예외를 던집니다. 비어 있지 않을 때만 그 값을 텍스트로 바꿔 돌려줍니다. 키 존재만 보던 검사를 값의 유무를 보는 검사로 바꾼 것이고, 그 과정에서 두 경우를 하나의 흐름으로 합친 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;72&quot; data-ke-size=&quot;size16&quot;&gt;여기서 마음을 놓을 수 있었던 점은, 이 변경이 기존 동작을 깨지 않는다는 것입니다. 정상적인 명령을 담아 보내던 기존 요청은 전과 똑같이 동작합니다. 키가 없던 경우에 나오던 메시지도 그대로입니다. 달라지는 것은 키는 있지만 값이 비어 있던 경우 하나뿐이고, 그 경우는 원래 가공되지 않은 오류로 새어 나가던 것이 이제 같은 메시지의 의도된 예외로 바뀝니다. 잘 동작하던 경우는 건드리지 않고 어긋나던 경우만 바로잡는 변경이라, 호환성을 걱정할 부분이 없었습니다. 메서드의 바깥 모습, 즉 이름이나 받는 값과 돌려주는 값의 형태도 전혀 바뀌지 않았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;비어-있는-입력을-다루는-부정-케이스-테스트&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;74&quot; data-ke-size=&quot;size26&quot;&gt;비어 있는 입력을 다루는 부정 케이스 테스트&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 &quot;테스트가 없으면 리뷰하지 않는다&quot;는 원칙이 있고, 특히 이런 버그는 잘못된 입력에 대해 정말로 의도된 예외가 나오는지를 테스트로 못 박아 두는 것이 핵심이었습니다. 정상 입력이 잘 처리되는지는 기존 테스트들이 이미 확인하고 있었으니, 이번에 더해야 할 것은 비어 있는 입력을 다루는 부정 케이스였습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;그래서 모델이 명령 자리를 비운 채 보낸 상황을 그대로 재현하는 테스트 두 개를 더했습니다. 하나는 기본 설정에서 그 입력을 넣으면 한 종류의 타입 있는 예외가 나오는지를 확인하고, 다른 하나는 다른 종류의 예외를 던지도록 설정을 바꾼 상태에서 그에 맞는 예외가 나오는지를 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;background-color: #f5f5f5; color: #333333; text-align: left;&quot; data-source-line=&quot;80&quot; data-info=&quot;java {data-source-line=&amp;quot;80&amp;quot;}&quot; data-role=&quot;codeBlock&quot;&gt;&lt;code&gt;@Test
void should_throw_ToolExecutionException_when_command_argument_value_is_null() {
    RunShellCommandToolExecutor executor = executor(false);

    assertThatThrownBy(() -&amp;gt; executor.executeWithContext(requestWithRawArguments(&quot;{\&quot;command\&quot;: null}&quot;), null))
            .isInstanceOf(ToolExecutionException.class)
            .hasMessageContaining(&quot;Missing required tool argument&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;91&quot; data-ke-size=&quot;size16&quot;&gt;이 두 테스트는 세 가지를 한꺼번에 못 박습니다. 비어 있는 값에 대해 가공되지 않은 오류가 아니라 의도된 예외가 나온다는 것, 설정에 따라 올바른 종류의 예외가 선택된다는 것, 그리고 그 메시지가 키가 없던 경우와 똑같다는 것입니다. 마지막 확인이 특히 의미가 있었습니다. 비어 있는 값을 키가 없는 경우와 같은 메시지로 다룬다는 약속을 테스트가 직접 지켜 주기 때문입니다. 모델이 어긋난 출력을 보내더라도 도구가 그것을 정해진 방식으로 거절한다는 것을, 이 테스트들이 앞으로도 보장해 줄 것이었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;저장소의-절차와-범위를-지키며-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;93&quot; data-ke-size=&quot;size26&quot;&gt;저장소의 절차와 범위를 지키며 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;95&quot; data-ke-size=&quot;size16&quot;&gt;코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. 특히 이번처럼 &quot;어떤 입력이, 왜, 어떤 오류로 이어지는가&quot;가 미묘한 버그는 재현 조건과 원인을 글로 먼저 정리해 두는 것이 중요했습니다. 검토하는 사람이 코드를 보기 전에 문제의 그림을 그릴 수 있어야 하기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;95&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;이번에 특히 의식한 것은 변경의 범위를 좁게 지키는 일이었습니다. 코드를 살펴보니, 같은 방식으로 인자를 꺼내는 비슷한 코드가 다른 곳에도 있어 같은 빈 값 문제를 안고 있을 가능성이 보였습니다. 한 번에 그 모든 곳을 함께 고치고 싶은 마음도 들었지만, 그렇게 하면 변경이 여러 모듈로 번지고 PR이 커집니다. 그래서 이번 PR은 셸 도구 한 모듈로만 범위를 한정하고, 비슷한 다른 곳은 이번 수정이 받아들여진 뒤에 따로 다루기로 미뤄 두었습니다. 변경을 작게 유지하고, 리팩터링과 기능 변경을 한 PR에 섞지 않으며, 한 번에 한 가지 문제에 집중하라는 저장소의 원칙을 따른 것입니다. 바뀐 줄 하나하나가 &quot;비어 있는 필수 인자를 의도된 예외로 거절한다&quot;는 목적으로 곧장 설명될 수 있어야 한다는 기준을 세워 두고 변경 내용을 다시 살폈습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style1&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 id=&quot;작은-수정에서-배운-것&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-source-line=&quot;99&quot; data-ke-size=&quot;size26&quot;&gt;작은 수정에서 배운 것&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;이 기여를 마치고 돌아보면, 바뀐 코드의 양은 많지 않습니다. 한 메서드의 검사 방식을 다듬고, 비어 있는 입력을 다루는 테스트 두 개를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 것은, 신뢰할 수 없는 입력을 다루는 코드는 그 어긋남까지 미리 생각해 두어야 한다는 감각입니다. 사람이 채우는 입력이라면 잘 일어나지 않을 형태라도, 모델이 만든 입력에서는 충분히 나올 수 있습니다. 키가 있는지만 보고 값까지는 보지 않는 검사처럼, 평소에는 별 탈 없던 가정이 신뢰할 수 없는 입력 앞에서는 구멍이 됩니다. 입력의 출처가 무엇인지에 따라 검사의 촘촘함도 달라져야 한다는 것을 이번에 분명히 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;또 하나는 오류가 &quot;어디에서&quot; 발생하는지가 중요하다는 점입니다. 같은 오류라도 안전망 안에서 터지면 의도된 형태로 감싸지지만, 안전망 밖에서 터지면 가공되지 않은 채 그대로 새어 나갑니다. 이번 버그는 빈 값 자체보다도, 그 빈 값을 다루는 코드가 예외를 감싸 주는 구간 밖에 있었다는 점이 문제를 키웠습니다. 오류를 다루는 정책을 마련했다면, 오류가 날 수 있는 모든 자리가 그 정책의 우산 안에 들어와 있는지를 함께 살펴야 한다는 것을 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;105&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 좋은 수정이란 이미 존재하는 일관성으로 어긋난 한 곳을 되돌리는 것일 때가 많다는 점입니다. 이번에도 답은 같은 메서드가 키 없는 경우에 이미 하고 있던 처리 안에 있었습니다. 새로운 방식을 만들기보다 이미 자리 잡은 규약에 빠진 경우를 맞추는 쪽이, 코드의 결을 흩뜨리지 않으면서 문제를 푸는 길이었습니다. 그리고 그 수정을 셸 도구 한 곳으로 좁게 지키며, 비슷한 다른 곳은 다음을 기약한 것도 이번에 의식적으로 연습한 부분이었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 예외 처리 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 신뢰할 수 없는 입력의 어긋남을 미리 떠올려 보고, 오류가 새어 나가던 자리를 따라가고, 이미 있는 규약으로 그것을 되돌리며, 변경의 범위를 절제하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 어긋난 입력을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-source-line=&quot;113&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 기여:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0088cc;&quot; href=&quot;https://github.com/langchain4j/langchain4j/pull/5456&quot;&gt;langchain4j/langchain4j#5456&lt;/a&gt;&lt;/p&gt;</description>
      <category>OPEN SOURCE</category>
      <author>ebson</author>
      <guid isPermaLink="true">https://ebson.tistory.com/520</guid>
      <comments>https://ebson.tistory.com/520#entry520comment</comments>
      <pubDate>Sat, 20 Jun 2026 15:28:05 +0900</pubDate>
    </item>
  </channel>
</rss>