<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>HeyMoa 기술 문서 Blog</title>
        <link>https://tech.heymoa.app/blog</link>
        <description>HeyMoa 기술 문서 Blog</description>
        <lastBuildDate>Mon, 27 Jul 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ko</language>
        <item>
            <title><![CDATA[요청 본문이 통째로 사라졌다 — h2c 업그레이드와 목(mock)이 못 잡는 층]]></title>
            <link>https://tech.heymoa.app/blog/2026/07/27/request-body-vanished</link>
            <guid>https://tech.heymoa.app/blog/2026/07/27/request-body-vanished</guid>
            <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[heymoa-server가 heymoa-ai로 분석을 요청하는데, ai 쪽에서 본문이 비어서 도착했다. 헤더는 멀쩡했고 경로도 맞았고 상태 코드도 200이었다. 서버 테스트는 전부 초록이었다.]]></description>
            <content:encoded><![CDATA[<p><code>heymoa-server</code>가 <code>heymoa-ai</code>로 분석을 요청하는데, ai 쪽에서 <strong>본문이 비어서</strong> 도착했다. 헤더는 멀쩡했고 경로도 맞았고 상태 코드도 200이었다. 서버 테스트는 전부 초록이었다.</p>
<p>원인은 우리 코드가 아니라 <strong>두 런타임의 HTTP 버전 협상</strong>이었다.</p>
<h2 class="anchor anchorTargetStickyNavbar_RHNx" id="증상">증상<a href="https://tech.heymoa.app/blog/2026/07/27/request-body-vanished#%EC%A6%9D%EC%83%81" class="hash-link" aria-label="증상에 대한 직접 링크" title="증상에 대한 직접 링크" translate="no">​</a></h2>
<table><thead><tr><th>항목</th><th>내용</th></tr></thead><tbody><tr><td>보내는 쪽</td><td>Spring <code>RestClient</code> · <code>JdkClientHttpRequestFactory</code></td></tr><tr><td>받는 쪽</td><td>FastAPI · uvicorn (h11)</td></tr><tr><td>관측된 것</td><td>ai 핸들러에서 <code>body</code>가 빈 값. 예외 없음</td></tr><tr><td>서버 테스트</td><td>전부 통과</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_RHNx" id="원인">원인<a href="https://tech.heymoa.app/blog/2026/07/27/request-body-vanished#%EC%9B%90%EC%9D%B8" class="hash-link" aria-label="원인에 대한 직접 링크" title="원인에 대한 직접 링크" translate="no">​</a></h2>
<p>JDK <code>HttpClient</code>는 기본적으로 <strong>HTTP/2를 먼저 시도한다.</strong> 평문 구간에서는 <code>h2c</code> 업그레이드를 거는데, uvicorn이 쓰는 <code>h11</code>은 <strong>HTTP/1.1 전용</strong>이다.</p>
<p>업그레이드 협상이 어긋나는 과정에서 요청 라인과 헤더는 살아남고 본문이 유실됐다. 양쪽 다 자기 기준으로는 "정상"이라 어느 쪽도 에러를 내지 않았다.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_RHNx" id="고친-방법">고친 방법<a href="https://tech.heymoa.app/blog/2026/07/27/request-body-vanished#%EA%B3%A0%EC%B9%9C-%EB%B0%A9%EB%B2%95" class="hash-link" aria-label="고친 방법에 대한 직접 링크" title="고친 방법에 대한 직접 링크" translate="no">​</a></h2>
<p>ai 전용 factory를 만들어 <strong>HTTP/1.1로 고정</strong>했다. 전역 설정을 바꾸지 않은 이유는, 다른 외부 연동에서는 HTTP/2가 문제가 없었고 <strong>경계마다 상대 런타임이 다르기 때문</strong>이다. 버전 협상은 클라이언트의 전역 성질이 아니라 <strong>그 경계의 성질</strong>이다.</p>
<h2 class="anchor anchorTargetStickyNavbar_RHNx" id="진짜-교훈은-테스트-쪽이었다">진짜 교훈은 테스트 쪽이었다<a href="https://tech.heymoa.app/blog/2026/07/27/request-body-vanished#%EC%A7%84%EC%A7%9C-%EA%B5%90%ED%9B%88%EC%9D%80-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%AA%BD%EC%9D%B4%EC%97%88%EB%8B%A4" class="hash-link" aria-label="진짜 교훈은 테스트 쪽이었다에 대한 직접 링크" title="진짜 교훈은 테스트 쪽이었다에 대한 직접 링크" translate="no">​</a></h2>
<p>이 버그가 통과한 이유는 명확하다.</p>
<blockquote>
<p><code>MockRestServiceServer</code>는 <strong>소켓을 타지 않는다.</strong></p>
</blockquote>
<p>Spring의 <code>MockRestServiceServer</code>는 <code>RestClient</code>의 요청을 가로채서 검증한다. 요청 객체는 완벽하게 만들어졌으므로 검증은 통과한다. 그런데 <strong>버그는 그 아래층, 실제 TCP 연결과 프로토콜 협상에서 났다.</strong> 목이 대신해 준 층이 정확히 깨진 층이었다.</p>
<p>회귀 테스트를 <code>MockWebServer</code>로 옮겼다. 실제 소켓을 열고 실제 HTTP를 말하므로 같은 실수가 다시 나면 잡힌다.</p>
<table><thead><tr><th>테스트 도구</th><th>검증하는 층</th><th>이 버그를 잡나</th></tr></thead><tbody><tr><td><code>MockRestServiceServer</code></td><td>요청 객체 조립</td><td>✗</td></tr><tr><td><code>MockWebServer</code></td><td>소켓 · 프로토콜 · 와이어 포맷</td><td>✓</td></tr></tbody></table>
<p><strong>목은 자기가 대신한 층을 검증하지 못한다.</strong> 당연한 말인데, 어느 층을 대신하고 있는지는 대개 의식하지 않는다.</p>
<h2 class="anchor anchorTargetStickyNavbar_RHNx" id="같은-계열의-사례가-더-있었다">같은 계열의 사례가 더 있었다<a href="https://tech.heymoa.app/blog/2026/07/27/request-body-vanished#%EA%B0%99%EC%9D%80-%EA%B3%84%EC%97%B4%EC%9D%98-%EC%82%AC%EB%A1%80%EA%B0%80-%EB%8D%94-%EC%9E%88%EC%97%88%EB%8B%A4" class="hash-link" aria-label="같은 계열의 사례가 더 있었다에 대한 직접 링크" title="같은 계열의 사례가 더 있었다에 대한 직접 링크" translate="no">​</a></h2>
<p>경계에서 난 문제는 이것만이 아니었다. 전부 <strong>"양쪽 다 자기 기준으로는 정상"</strong> 이라는 공통점이 있다.</p>
<ul>
<li class=""><strong>콜백이 전부 400</strong> — server가 보내는 origin(사설 IP)과 ai의 <code>SERVER_BASE_URL</code>(공개 도메인)이 어긋났다. <code>same_origin</code> 가드는 정상 작동했고 <strong>설정이 낡았던 것</strong>이다</li>
<li class=""><strong>챗봇 스트리밍이 뭉쳐서 나옴</strong> — <code>X-Accel-Buffering: no</code>가 <strong>nginx가 없는 구간</strong>(ai→server)에 붙어 있었고, 정작 필요한 구간(server→브라우저)에는 없었다</li>
</ul>
<p>세 건 모두 코드 리뷰로는 안 잡힌다. 한쪽 저장소만 보면 옳기 때문이다.</p>
<p>정리된 목록은 <a class="" href="https://tech.heymoa.app/architecture/system-architecture#9-%EC%8B%A4%EC%B8%A1%EB%90%9C-%EC%8B%A4%ED%8C%A8-%EC%82%AC%EB%A1%80--%EA%B2%BD%EA%B3%84%EA%B0%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B9%A8%EC%A1%8C%EB%82%98">System Architecture §9</a>에 있다.</p>]]></content:encoded>
            <category>장애 기록</category>
            <category>경계 설계</category>
            <category>테스트</category>
        </item>
    </channel>
</rss>